AV
angelvelasquez.dev
Backend & Architecture September 15, 2026 • 7 min read

Domain-Driven Design (DDD) & Clean Architecture in PHP 8.4 and Symfony 7.2

Architectural blueprint for isolating domain logic, modeling aggregates, and enforcing transactional integrity in high-stakes Symfony monolithic cores.

#PHP 8.4 #Symfony 7.2 #DDD #Clean Architecture #CQRS #Enterprise Backend

When enterprise monoliths grow over several years, the most common architectural failure is framework entanglement: business rules leaking into ORM entities, controller actions dictating domain transactions, and database schemas governing the shape of business concepts.

In high-stakes transactional domains—such as pensions, banking, or enterprise resource planning—this degradation leads to subtle transactional corruption and fear of refactoring.

Here is the exact architectural blueprint I use to enforce Domain-Driven Design (DDD) and Clean Architecture in modern PHP 8.4 and Symfony 7.2 systems.


1. Directory Structure: Isolating Bounded Contexts

Rather than grouping code by technical layer (Controller/, Entity/, Repository/), divide the codebase into bounded contexts. Within each context, enforce strict unidirectional dependency flow:

src/
├── PensionManagement/                  # Bounded Context
│   ├── Domain/                         # 100% Framework-Agnostic
│   │   ├── Model/                      # Aggregates, Entities, Value Objects
│   │   │   ├── PensionCalculation.php
│   │   │   └── PensionBeneficiary.php
│   │   ├── ValueObject/
│   │   │   ├── CurrencyAmount.php
│   │   │   └── SocialSecurityNumber.php
│   │   ├── Event/
│   │   │   └── PensionSettledEvent.php
│   │   └── Repository/
│   │       └── PensionCalculationRepositoryInterface.php
│   ├── Application/                    # Use Cases & Command Handlers
│   │   ├── Command/
│   │   │   ├── CalculatePensionCommand.php
│   │   │   └── CalculatePensionHandler.php
│   │   └── Query/
│   │       ├── GetBeneficiaryStatementQuery.php
│   │       └── GetBeneficiaryStatementHandler.php
│   └── Infrastructure/                 # Framework & DB Adapters
│       ├── Persistence/
│       │   ├── DoctrinePensionCalculationRepository.php
│       │   └── Mapping/                # XML or PHP mapping (zero entity annotations)
│       └── Web/
│           └── Controller/
│               └── PensionCalculationController.php

The Inviolable Rule

The Domain directory must never import anything from Symfony\*, Doctrine\*, or third-party vendors. It only knows pure PHP 8.4 syntax, types, and standard library primitives.


2. Modeling Aggregates with Pure PHP 8.4

An aggregate root protects its business invariants. It cannot be instantiated with invalid data and its internal state cannot be modified via public setters:

<?php

declare(strict_types=1);

namespace App\PensionManagement\Domain\Model;

use App\PensionManagement\Domain\ValueObject\CurrencyAmount;
use App\PensionManagement\Domain\ValueObject\SocialSecurityNumber;
use App\PensionManagement\Domain\Event\PensionSettledEvent;
use InvalidArgumentException;

final class PensionCalculation
{
    private string $id;
    private SocialSecurityNumber $beneficiarySsn;
    private CurrencyAmount $accruedAmount;
    private string $status;
    private array $recordedEvents = [];

    private function __construct(
        string $id,
        SocialSecurityNumber $beneficiarySsn,
        CurrencyAmount $initialAmount
    ) {
        $this->id = $id;
        $this->beneficiarySsn = $beneficiarySsn;
        $this->accruedAmount = $initialAmount;
        $this->status = 'DRAFT';
    }

    public static function create(
        string $id,
        SocialSecurityNumber $beneficiarySsn,
        CurrencyAmount $initialAmount
    ): self {
        return new self($id, $beneficiarySsn, $initialAmount);
    }

    public function settle(CurrencyAmount $finalIndexedAmount): void
    {
        if ($this->status !== 'DRAFT') {
            throw new InvalidArgumentException('Only draft calculations can be settled.');
        }

        $this->accruedAmount = $finalIndexedAmount;
        $this->status = 'SETTLED';

        $this->recordedEvents[] = new PensionSettledEvent(
            $this->id,
            $this->beneficiarySsn->value(),
            $this->accruedAmount->cents()
        );
    }

    public function pullEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];
        return $events;
    }
}

Notice:

  • No Doctrine annotations or attributes in the entity.
  • Value Objects (CurrencyAmount, SocialSecurityNumber) guarantee formatting and arithmetic consistency.
  • Domain events are recorded internally and released atomically upon persistence.

3. CQRS & Transaction Boundaries via Symfony Messenger

Do not execute complex database transactions in controllers. Wrap business mutations into immutable command objects dispatched through Symfony Messenger:

<?php

declare(strict_types=1);

namespace App\PensionManagement\Application\Command;

final readonly class CalculatePensionCommand
{
    public function __construct(
        public string $calculationId,
        public string $socialSecurityNumber,
        public int $amountInCents,
        public string $currency = 'EUR'
    ) {}
}

And the application handler encapsulates the transaction boundary:

<?php

declare(strict_types=1);

namespace App\PensionManagement\Application\Command;

use App\PensionManagement\Domain\Model\PensionCalculation;
use App\PensionManagement\Domain\ValueObject\SocialSecurityNumber;
use App\PensionManagement\Domain\ValueObject\CurrencyAmount;
use App\PensionManagement\Domain\Repository\PensionCalculationRepositoryInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final readonly class CalculatePensionHandler
{
    public function __construct(
        private PensionCalculationRepositoryInterface $repository
    ) {}

    public function __invoke(CalculatePensionCommand $command): void
    {
        $ssn = SocialSecurityNumber::fromString($command->socialSecurityNumber);
        $amount = CurrencyAmount::fromCents($command->amountInCents, $command->currency);

        $calculation = PensionCalculation::create(
            $command->calculationId,
            $ssn,
            $amount
        );

        $calculation->settle($amount);

        // Repository saves entity and dispatches recorded domain events
        $this->repository->save($calculation);
    }
}

4. Doctrine Mappings Without Entity Contamination

To keep the domain pure, configure Doctrine mappings via XML in src/PensionManagement/Infrastructure/Persistence/Mapping/:

<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping">
    <entity name="App\PensionManagement\Domain\Model\PensionCalculation" table="pension_calculations">
        <id name="id" type="string" length="36" />
        <field name="status" type="string" length="32" />
        <embedded name="beneficiarySsn" class="App\PensionManagement\Domain\ValueObject\SocialSecurityNumber" use-column-prefix="false" />
        <embedded name="accruedAmount" class="App\PensionManagement\Domain\ValueObject\CurrencyAmount" use-column-prefix="false" />
    </entity>
</doctrine-mapping>

This pattern guarantees that if Doctrine or Symfony upgrades in the future, your business core remains untouched.


5. Production Impact & Verification

This architecture is not theoretical. I deployed and operated this model in the French Enterprise Wealth & Pension Platform, managing critical pension distribution workflows:

  • +99.98% Operational Availability: Zero runtime crashes triggered by unhandled domain boundary violations.
  • Zero Transaction Corruption: Invariant enforcement at the aggregate root level eliminated partial state mutations.
  • 60% Query Latency Reduction: By decoupling write aggregates from read queries (CQRS), complex settlement calculation times dropped drastically.

Key Takeaway

Frameworks are delivery mechanisms, not architecture. When you treat Symfony as an infrastructure adapter and protect your domain with pure PHP 8.4 aggregates and CQRS handlers, your system survives framework updates and scales without cognitive drag.