A REST API for managing encrypted credentials, built as a showcase of clean architecture patterns in PHP.
Stack: PHP 8.4 · Symfony 8.0 · Doctrine ORM · MariaDB · Docker
The project applies Hexagonal Architecture (Ports & Adapters) combined with CQRS, organized into bounded contexts. Each module is self-contained and follows the same layered structure:
src/<Module>/
├── Domain/ # Aggregates, Value Objects, Domain Events, Port interfaces
├── Application/ # Commands, Queries, Handlers, DTOs
├── Infrastructure/ # Doctrine repositories, framework adapters, concrete impls
└── Presentation/ # HTTP controllers, CLI commands
| Module | Responsibility |
|---|---|
Identity |
User registration, authentication, profile management |
Vault |
CRUD for encrypted credentials (passwords, logins, URLs) |
Shared |
Cross-cutting concerns: CommandBus, QueryBus, HTTP helpers |
Autowire and autoconfigure are disabled. Every service is explicitly registered in config/services.yaml. This makes dependencies visible and intentional — no magic, no surprises when the container grows.
Invokable single-action controllers. Each controller does exactly one thing. They are plain classes with no framework coupling — no AbstractController, no $this->json(), no inherited state.
Symfony Messenger as the command/query bus. The application layer knows nothing about Messenger — it only depends on CommandBus and QueryBus port interfaces. The Messenger implementation lives in Infrastructure and can be swapped without touching a single handler.
Domain events via ports. Aggregates collect events internally and release them through releaseEvents(). Handlers dispatch them via the EventDispatcher port, keeping the domain completely framework-free.
Sodium encryption. Vault entries are encrypted at rest with sodium_crypto_secretbox (XSalsa20-Poly1305). Each ciphertext includes a random nonce prepended before the MAC — encryption is authenticated and nonce is never reused.
Value Objects for all identity. UserId, Email, Username, HashedPassword, EntryId, EntryTitle, OwnerId — primitive obsession is avoided throughout the domain. Each VO validates its invariants on construction and maps to a custom Doctrine type.
- Docker & Docker Compose
- Make
git clone https://github.com/mar3q/password-vault.git
cd password-vault
make start # Build and start containers (PHP-FPM + Nginx + MariaDB)
make app-install # Install Composer dependencies
make db-migrate # Create databases and run migrationsAPI is available at http://localhost:8081/api/doc (Swagger UI).
Register via the API:
POST /api/users
Content-Type: application/json
{"email": "you@example.com", "username": "you", "password": "secret"}Or via the CLI (e.g. for seeding environments):
make sh
bin/console app:user:createThen obtain a JWT token:
POST /api/login
Content-Type: application/json
{"username": "...", "password": "..."}Use the returned token as Authorization: Bearer <token> on all subsequent requests.
| Method | Path | Description |
|---|---|---|
POST |
/api/login |
Obtain JWT token |
POST |
/api/users |
Register a new account |
GET |
/api/users/{id} |
Get user profile |
PATCH |
/api/users/{id}/email |
Change email |
PATCH |
/api/users/{id}/password |
Change password |
GET |
/api/vault |
List vault entries (authenticated user) |
POST |
/api/vault |
Create a vault entry |
GET |
/api/vault/{id} |
Get a single entry |
PUT |
/api/vault/{id} |
Update an entry |
DELETE |
/api/vault/{id} |
Delete an entry |
Full request/response schemas are available in the Swagger UI.
Three levels of tests, all executed via PHPUnit:
| Layer | Location | What it tests |
|---|---|---|
| Unit | tests/Unit/ |
Domain model, Value Objects, Doctrine types, encrypter |
| Application | tests/Application/ |
Command handlers with mocked ports |
| HTTP | tests/Http/ |
Full request–response cycle against the test database |
make qa-test # Run full test suite
make qa # Tests + PHPStan + code style checkThe HTTP test suite uses Symfony's KernelBrowser with a dedicated vault_test database. Each test class manages its own fixtures — no shared mutable state between tests.
| Tool | Configuration | Scope |
|---|---|---|
| PHPStan | level 6, phpstan.dist.neon |
src/ + tests/ |
| php-cs-fixer | @auto ruleset, risky: false |
entire codebase |
make qa-stan # Static analysis
make qa-cs # Check style
make qa-cs-fix # Auto-fix styleMigrations live in migrations/, managed by Doctrine Migrations.
make db-migrate # Apply pending migrations (creates DBs if missing)
make db-status # Show migration status
make db-validate # Validate mapping vs schemaAdding a migration:
- Edit the Doctrine XML mapping in
src/*/Infrastructure/Persistence/Mapping/ make db-diff— generates migration SQL from the diff- Review the generated file in
migrations/ make db-migrate— apply
| Service | Image | Port |
|---|---|---|
php |
PHP 8.4-FPM (custom) | — |
nginx |
nginx:1.25-alpine | 8081 |
db |
mariadb:10.11 | 33061 |
make start # docker compose up -d
make stop # docker compose down
make restart # stop + start
make sh # shell into the PHP container
make docker-build # rebuild the PHP imageAll Make targets pass the host UID/GID into Docker so generated files (migrations, cache) are owned by the current user.
| Variable | Description |
|---|---|
DATABASE_URL |
Doctrine DSN |
JWT_SECRET_KEY / JWT_PUBLIC_KEY |
RSA key paths for LexikJWT |
JWT_PASSPHRASE |
Passphrase for the JWT private key |
VAULT_ENCRYPTION_KEY |
Base64-encoded 32-byte key for Sodium (sodium_crypto_secretbox) |
Generate an encryption key:
php -r "echo base64_encode(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES)) . PHP_EOL;"