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.