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. 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
PasswordResetRequested → SendPasswordResetEmailHandler
PasswordChanged → SendPasswordChangedEmailHandler
SubscriptionPaid → AddToGitHubOrganizationHandler
Events are dispatched synchronously 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, PasswordResetRequested, PasswordChanged
- Ports: UserRepositoryInterface, EmailSenderInterface, SocialTokenVerifierInterface
Subscription
Handles plans, payments, and feature gating.
- Domain Models: Subscription, PlanType, Feature, PaymentType, SubscriptionStatus
- Domain Events: SubscriptionPaid, GitHubMembershipAdded
- Ports: SubscriptionRepositoryInterface, PaymentGatewayInterface, GitHubOrganizationClientInterface
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
No business logic lives in controllers.