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:

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 email
  • PasswordResetRequested — triggers reset email
  • PasswordChanged — triggers confirmation email

Ports (Interfaces):

  • UserRepositoryInterface
  • EmailValidationTokenRepositoryInterface
  • PasswordResetTokenRepositoryInterface
  • EmailSenderInterface
  • SocialTokenVerifierInterface

Subscription

Handles plans, payments, and feature gating.

Domain Models: Subscription, PlanType, Feature, PaymentType, BillingInterval, SubscriptionStatus

Domain Events:

  • SubscriptionPaid — triggered after successful payment

Ports (Interfaces):

  • SubscriptionRepositoryInterface
  • PaymentGatewayInterface

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
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:

AdapterUsed inTransport
SymfonyMailerEmailSenderdev, testMailpit (SMTP)
BrevoTransactionalEmailSenderprodBrevo 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 TypePHP Class
user_idUserId
emailEmail
hashed_passwordHashedPassword
subscription_idSubscriptionId
plan_typePlanType
subscription_statusSubscriptionStatus
billing_intervalBillingInterval
payment_typePaymentType

ORM mapping is done via XML files (not PHP attributes), keeping domain models free of framework annotations.