Architecture
Hexagonal architecture, CQRS, and bounded contexts
Architecture
Hexagonal Architecture (Ports & Adapters)
The codebase is organized in three layers per bounded context:
Context/
├── Domain/ # Pure PHP — no framework dependencies
│ ├── Model/ # Entities, value objects, enums
│ ├── Port/ # Interfaces (repositories, services)
│ ├── Event/ # Domain events
│ └── Exception/ # Domain-specific exceptions
│
├── Application/ # Use cases — one folder per feature
│ ├── SignUp/ # Command DTO + Handler
│ ├── Login/ # Command DTO + Handler
│ └── ...
│
└── Infrastructure/ # Framework adapters implementing domain ports
├── Http/ # Controllers
├── Persistence/ # Doctrine repositories + read models
├── Email/ # Brevo / Symfony Mailer
└── ...
Layer Rules
| Layer | Can depend on | Cannot depend on |
|---|---|---|
| Domain | Nothing (pure PHP) | Application, Infrastructure, Symfony |
| Application | Domain | Infrastructure, Symfony |
| Infrastructure | Domain, Application | (implements ports) |
Domain defines ports (interfaces). Infrastructure provides adapters (implementations). This means you can swap Stripe for another payment provider, or Brevo for another email service, without touching domain or application code.
CQRS
The application uses three Symfony Messenger buses:
| Bus | Purpose | Example |
|---|---|---|
command.bus | Write operations (state changes) | SignUpCommand, ResetPasswordCommand |
query.bus | Read operations (no side effects) | GetUserProfileQuery, GetAvailablePlansQuery |
event.bus | React to domain events | UserCreated, SubscriptionPaid |
Write Side (ORM)
Commands go through handlers that use Doctrine ORM entities mapped via XML files:
src/UserManagement/Infrastructure/Persistence/Mapping/User.orm.xml
src/Subscription/Infrastructure/Persistence/Mapping/Subscription.orm.xml
Domain models are rich objects with business logic (e.g., Subscription::upgrade(), Subscription::cancel()).
Read Side (DBAL)
Queries go through handlers that use DBAL repositories returning lightweight view models. Read-model repositories use raw SQL via Doctrine DBAL — no ORM hydration overhead. This keeps reads fast and decoupled from the write model.
Event System
Domain events are dispatched by command handlers via the event.bus:
UserCreated → SendWelcomeEmailHandler (welcome email + validation link)
PasswordResetRequested → SendPasswordResetEmailHandler
PasswordChanged → SendPasswordChangedEmailHandler
SubscriptionPaid → (add your own post-payment handlers here)
Events are dispatched synchronously (sync:// transport) by default. See Customization for switching to async processing via Redis.
Bounded Contexts
UserManagement
Handles authentication, user profiles, and password management.
Domain Models: User, EmailValidationToken, PasswordResetToken
Domain Events:
UserCreated— triggers welcome emailPasswordResetRequested— triggers reset emailPasswordChanged— triggers confirmation email
Ports (Interfaces):
UserRepositoryInterfaceEmailValidationTokenRepositoryInterfacePasswordResetTokenRepositoryInterfaceEmailSenderInterfaceSocialTokenVerifierInterface
Subscription
Handles plans, payments, and feature gating.
Domain Models: Subscription, PlanType, Feature, PaymentType, BillingInterval, SubscriptionStatus
Domain Events:
SubscriptionPaid— triggered after successful payment
Ports (Interfaces):
SubscriptionRepositoryInterfacePaymentGatewayInterface
Screaming Architecture
Each feature is a folder in Application/ containing its command/query DTO and handler:
Application/
├── SignUp/
│ ├── SignUpCommand.php # DTO with public readonly fields
│ └── SignUpHandler.php # Handles the command
├── GetUserProfile/
│ ├── GetUserProfileQuery.php
│ ├── GetUserProfileHandler.php
│ └── UserProfileViewModel.php
└── ...
You can see what the application does just by reading the folder names.
Controller Pattern
Controllers are thin — they handle HTTP concerns only:
- Parse the request into a command/query DTO
- Dispatch via the appropriate bus
- Return the result as a JSON response
public function __invoke(Request $request): JsonResponse
{
$command = new SignUpCommand(
name: $request->getPayload()->getString('name'),
email: $request->getPayload()->getString('email'),
password: $request->getPayload()->getString('password'),
);
$result = $this->commandBus->dispatch($command);
return new JsonResponse($result, Response::HTTP_CREATED);
}
No business logic lives in controllers.
Email Architecture
Two adapters implement EmailSenderInterface:
| Adapter | Used in | Transport |
|---|---|---|
SymfonyMailerEmailSender | dev, test | Mailpit (SMTP) |
BrevoTransactionalEmailSender | prod | Brevo API |
The active adapter is configured in config/services.yaml via the when@dev / when@test overrides. In production, the default Brevo adapter is used.
Database
PostgreSQL with custom Doctrine types for value objects:
| Doctrine Type | PHP Class |
|---|---|
user_id | UserId |
email | Email |
hashed_password | HashedPassword |
subscription_id | SubscriptionId |
plan_type | PlanType |
subscription_status | SubscriptionStatus |
billing_interval | BillingInterval |
payment_type | PaymentType |
ORM mapping is done via XML files (not PHP attributes), keeping domain models free of framework annotations.