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

LayerCan depend onCannot depend on
DomainNothing (pure PHP)Application, Infrastructure, Symfony
ApplicationDomainInfrastructure, Symfony
InfrastructureDomain, 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:

BusPurposeExample
command.busWrite operations (state changes)SignUpCommand, ResetPasswordCommand
query.busRead operations (no side effects)GetUserProfileQuery, GetAvailablePlansQuery
event.busReact to domain eventsUserCreated, 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:

  1. Parse the request into a command/query DTO
  2. Dispatch via the appropriate bus
  3. Return the result as a JSON response

No business logic lives in controllers.