Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Symfony 8: The Hexagonal Track

Avatar for Robin Chalas Robin Chalas
June 13, 2026
250

Symfony 8: The Hexagonal Track

Presented at SymfonyDay Montréal and SymfonyOnline June 2026.

Structuring an application around its domain rather than its framework is an old idea. Making it practical has sometimes felt like swimming against the current.

Thanks to the way Symfony 8 leverages modern PHP, this drastically changes. Recent evolutions in both the language and the framework align naturally with hexagonal thinking and tactical DDD patterns — no workarounds, no fighting the tools.

Join me as I wear both my Core Team member and DDD practitioner hats to give a pragmatic look at putting your business logic first, building applications that scale with your domain's complexity and remain maintainable as they grow, with Symfony's blessing.

Avatar for Robin Chalas

Robin Chalas

June 13, 2026

Transcript

  1. H E L L O Robin Chalas Symfony Core Team

    Member · Bakslash consultant @chalas_r @chalasr @chalasr.bsky.social baksla.sh
  2. A C T I · T H E O L

    D I D E A An old idea: domain at the center ↳ Cockburn, 2005 · ports & adapters Alistair Cockburn names the pattern ↳ Evans, 2003 · Domain-Driven Design The blue book; the domain comes first ↳ Three layers · Domain · Application · Infrastructure Dependencies point inward, always Infrastructure Application Domain
  3. A C T I · W H Y B O

    T H E R Four things you keep 01 Integrity The domain says exactly what it means. 02 Testability Fast, honest tests. No database to boot. 03 Deferral Decide your DBMS or transport later, not on day one. 04 Agnostic The domain never knows who is calling it.
  4. “ “ DDD is not a fast way to build

    software. But it eases your life when you deal with complex business expectations. WILLIAM DURAND
  5. A C T I I · T H E F

    A S T P A T H The fast path Symfony shows you One class Doctrine attributes Ships in 5 minutes #[ORM\Entity] final class Book { #[ORM\Id, ORM\Column] public ?int $id = null; #[ORM\Column] public ?string $name = null; #[ORM\Column] public ?int $price = null; } For many apps this is the right answer. The fast path is a feature, not a flaw.
  6. A C T I I · T H E F

    A S T P A T H …then validation joins in + Controller + Validation + Serialization groups #[ORM\Entity] final class Book { #[ORM\Column, Assert\NotNull , Groups ( [ 'book:read' ] )] public ?string $name = null; #[ORM\Column, Assert\Positive , Groups ( [ 'book:read' ] )] public ?int $price = null; } Still fine. Still ships. Still works, for one use case.
  7. A C T I I · W H E N

    I T B R E A K S Sugar cookies: when the fast path breaks #[ORM\Entity] final class Book { #[Assert\NotNull(groups: ['create'])] #[Assert\Length(min: 1, max: 255, groups: ['create', 'rename'])] public ?string $name = null; #[Assert\Positive(groups: ['create', 'discount'])] public ?int $price = null; // … nullable to satisfy "discount", required for "create" … } ▲ 11 warnings ⊘ 7 errors Two use cases disagreed. The model started lying.
  8. A C T I I · T H E C

    O S T O F S P L I T T I N G The historical cost of splitting src/BookStore/ ├── Domain/ ├── Application/ └── Infrastructure/ // what ONE use case once cost × CommandInterface · CommandBusInterface × MessengerCommandBus × HandlerInterface × services.yaml bus binding × Translator::fromModel() × Custom ValueResolverInterface × Getters / setters on every VO Hex was always possible. This ceremony is what made it feel like swimming against the current.
  9. A C T I I I · W H A

    T M O D E R N P H P C H A N G E D “Reading is dreaming with open eyes…” Value objects Behaviour on the entity No getX / setX final class Book { public function __construct( public BookName $name, public BookDescription $description, public Author $author, public Price $price, ) {} public function applyDiscount(Discount $discount): void { $this->price = $this->price->applyDiscount($discount); } }
  10. A C T I I I · W H A

    T M O D E R N P H P C H A N G E D Value objects, at language level PHP 8.0 promotion PHP 8.1 enum PHP 8.2 readonly final readonly class Price { public function __construct( public int $amount, public Currency $currency, ) {} } enum Currency: string { case EUR = 'EUR'; case USD = 'USD'; } A value object is now four lines. Immutable, typed, closed at the language level.
  11. A C T I I I · W H A

    T M O D E R N P H P C H A N G E D Asymmetric visibility PHP 8.4 final class Book { public function __construct( public private(set) BookName $name, public private(set) Price $price, ) {} public function applyDiscount(Discount $d): void { $this->price = $this->price->applyDiscount($d); } } Read everywhere, write only from inside the aggregate. No getName() boilerplate.
  12. A C T I I I · W H A

    T M O D E R N P H P C H A N G E D Property hooks PHP 8.4 public string $displayName { get => $this->name->value . ' by ' . $this->author->name; } Computed values live in the property. The model reads like what it is, not a list of accessors.
  13. A C T I I I · T H E

    U N L O C K Attributes are soft coupling Zero framework // Domain class, zero attributes final class Book { public function __construct( public BookName $name, public Price $price, ) {} } No Doctrine. No Symfony. No Validator. It compiles. It tests. It runs.
  14. A C T I I I · T H E

    U N L O C K Same class, described from outside + #[ORM\Entity] #[ORM\Entity] final class Book { public function __construct( public BookName $name, public Price $price, ) {} } Still compiles. Still tests. Still runs without Doctrine booted. Infra describes domain from the outside.
  15. A C T I V · T H E V

    E N D O R I S T H E P R O O F Symfony already does this, not for your convenience Messenger → message bus + HandlersLocator Validator → MetadataFactoryInterface + LoaderChain HttpKernel → event-driven, EventDispatcherInterface Security → AuthenticatorInterface strategies DependencyInjection → CompilerPassInterface Symfony reaches for these patterns because its own complexity deserves them. When yours does too, you deserve them too.
  16. A C T I V · T H E B

    O U N D A R Y I S U N C H A N G E D The ports stay DOMAIN Book model ADAPTER APPLICATION · PORT Command / Query bus interface ADAPTER INFRASTRUCTURE API resource · Doctrine
  17. A C T I V · W H A T

    A C T U A L L Y C H A N G E S These don't go away. What changes is the ceremony around binding them. Symfony 8 drives it close to zero.
  18. A A W A L K T H R O

    U G H · U S E C A S E # 1 Applying a discount A command through every layer.
  19. W A L K T H R O U G

    H A · T H E C O M M A N D The command is just data final class DiscountBookCommand { public function __construct( public readonly BookId $id, public readonly Discount $discount, ) {} } The user's intent, in the user's words.
  20. W A L K T H R O U G

    H A · T H E H A N D L E R Pure Application logic final class DiscountBookHandler { public function __construct( private BookRepositoryInterface $books, ) {} public function __invoke(DiscountBookCommand $cmd): void { // load · mutate · save } } No framework. No interface. No service tag.
  21. W A L K T H R O U G

    H A · W I R I N G …wired with one attribute Symfony 5.4 use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler(bus: 'command.bus')] final class DiscountBookHandler { // …unchanged… } No services.yaml tag. No interface required. Remove the attribute and the class still works.
  22. W A L K T H R O U G

    H A · T H E H T T P A D A P T E R Start with #[Route] #[AsController] final class DiscountBookController { public function __construct(private CommandBusInterface $bus) {} #[Route('/books/{id}/discount', methods: ['POST'])] public function __invoke(string $id, Request $request): JsonResponse { $payload = json_decode($request->getContent(), true); $this->validator->validate($payload, /* … */); $this->bus->dispatch(/* … */); return new JsonResponse(null, 204); } } The old way: deserialize, validate, wrap a response. All by hand.
  23. W A L K T H R O U G

    H A · T H E H T T P A D A P T E R …add #[MapRequestPayload] Symfony 6.3 #[Route('/books/{id}/discount', methods: ['POST'])] public function __invoke( string $id, #[MapRequestPayload] DiscountBookPayload $payload, ): JsonResponse { $this->bus->dispatch(new DiscountBookCommand( new BookId($id), new Discount($payload->percentage), )); return new JsonResponse(null, 204); } Deserialize + validate: gone. The controller receives a typed, validated DTO.
  24. W A L K T H R O U G

    H A · T H E H T T P A D A P T E R …add #[Serialize] Symfony 8.1 · five days old #[Route('/books/{id}/discount', methods: ['POST'])] #[Serialize(code: 204)] public function __invoke( string $id, #[MapRequestPayload] DiscountBookPayload $payload, ): void { $this->bus->dispatch(new DiscountBookCommand( new BookId($id), new Discount($payload->percentage), )); } No JsonResponse . No serializer inject. Seven lines, all adapter.
  25. W A L K T H R O U G

    H A · T H E P A Y O F F Same use case, any context HTTP · CONTROLLER $this->bus->dispatch( new DiscountBookCommand( new BookId($id), new Discount($payload->percentage), ), ); CLI · #[ASCOMMAND] $this->bus->dispatch( new DiscountBookCommand( new BookId($input->id), new Discount($input->percentage), ), ); Messenger consumer? Same handler. Form post? Same handler. Webhook? Same handler.
  26. B B W A L K T H R O

    U G H · U S E C A S E # 2 Find the cheapest books Same shape, query side. All the way through.
  27. W A L K T H R O U G

    H B · Q U E R Y & H A N D L E R Same pattern, on the query bus final class FindCheapestBooksQuery { public function __construct(public int $size = 10) {} } #[AsMessageHandler(bus: 'query.bus')] final class FindCheapestBooksHandler { public function __construct(private BookRepositoryInterface $books) {} public function __invoke(FindCheapestBooksQuery $q): iterable { return $this->books ->withCheapestsFirst() ->withPagination(1, $q->size); } }
  28. W A L K T H R O U G

    H B · T H E G E T A D A P T E R #[MapQueryString] + #[Serialize] Symfony 6.3 Symfony 8.1 #[Route('/books/cheapest', methods: ['GET'])] #[Serialize(context: ['groups' => ['book:read']])] public function __invoke( #[MapQueryString] CheapestBooksFilter $filter, ): iterable { return $this->bus->ask( new FindCheapestBooksQuery($filter->size), ); } The adapter is the routing seam. Nothing more.
  29. A C T I V · T H E T

    W O B I G A D D I T I O N S Keep the domain pristine // src/BookStore/Domain/Model/Book.php #[ORM\Entity] final class Book { public function __construct( #[ORM\Embedded(columnPrefix: false)] public private(set) BookName $name, #[ORM\Embedded(columnPrefix: false)] public private(set) Price $price, ) {} public function applyDiscount(Discount $d): void { $this->price = $this->price->applyDiscount($d); } } Zero #[Assert]. Zero #[Groups]. Just behaviour. So where does validation live?
  30. A C T I V · T H E T

    W O B I G A D D I T I O N S Validation, as a separate class Symfony 7.4 use Symfony\Component\Validator\Attribute\ExtendsValidationFor; #[ExtendsValidationFor(Book::class)] final class BookValidation { #[Assert\Length(min: 1, max: 255)] public string $name; #[Assert\Positive] public int $price; } The classic objection: “attributes pollute the domain.” Answered.
  31. A C T I V · T H E T

    W O B I G A D D I T I O N S DTO ↔ Entity, no translators Symfony 7.4 · stable use Symfony\Component\ObjectMapper\Attribute\Map; #[Map(target: Book::class)] final class BookResource { public function __construct( public string $name, public int $price, ) {} } $book = $mapper->map($resource, Book::class); The Application-layer Translator : gone. Mapping is metadata.
  32. A C T V · W H E N S

    H O U L D Y O U A D O P T I T ? Right after the prototype. Hexagonal is adaptability insurance. It costs the least when the domain is still moving.
  33. A C T V · N O T E V

    E R Y W H E R E , O N L Y W H E R E I T M O V E S Different context → Different needs BookStore Hexagonal Subscription RAD Payment context-fit
  34. A C T V · T H E T A

    K E A W A Y Three contexts. Three architectures. One app. BookStore earns the hex. Subscription is settled. RAD all day. Adopt early where the domain moves.
  35. A C T V · S I D E B

    Y S I D E RAD coexists, in the same src/ #[ORM\Entity] final class Subscription { #[ORM\Id, ORM\Column] public ?int $id = null; #[ORM\Column, Assert\NotBlank] public string $email; #[ORM\Column] public \DateTimeImmutable $startsAt; } src/ ├── BookStore/ │ ├── Domain/ │ ├── Application/ │ └── Infrastructure/ └── Subscription/ └── Entity/Subscription.php Same src/ , a different effort tier per context. Symfony enables both.
  36. A C T V · H O N E S

    T T R A D E O F F S The dependency rule, violated on purpose // Symfony Uid AND Doctrine, both "leaked" into a Domain VO abstract class AggregateRootId implements \Stringable { #[ORM\Id] #[ORM\Column(type: 'uuid')] public readonly AbstractUid $value; public function __construct(?AbstractUid $value = null) { $this->value = $value ?? Uuid::v4(); } } “Classes can be considered object-pure if they don't contain code that requires IO to run.” Matthias Noback · the leak does no IO at runtime
  37. “ “ Pointing out that a rule has been violated

    should not be a sufficient reason to adhere to that rule. MATTHIAS NOBACK
  38. A C T V · E N F O R

    C I N G T H E B O U N D A R I E S Soft coupling needs hard rules: Deptrac layers: - { name: Domain, collectors: [{ type: directory, regex: .+/Domain/.* }] } - { name: Application, collectors: [{ type: directory, regex: .+/Application/.* }] } - { name: Infrastructure, collectors: [{ type: directory, regex: .+/Infrastructure/.* }] } - { name: Attributes, collectors: [{ type: className, regex: ^Doctrine\\ORM\\Mapping }] } ruleset: Domain: [Attributes] # only metadata allowed in Application: [Domain, Attributes] Infrastructure: [Domain, Application, Vendors, Attributes] The Attributes lane whitelists Doctrine mapping. The leak is intentional and bounded.
  39. A C T V · T H E P A

    Y O F F One port, two adapters, fast tests DoctrineBookRepository Integration tests against the real DBMS. Run rarely, run thoroughly. InMemoryBookRepository Every other test: handler, controller, end-to-end. Same port contract. Handler tests in milliseconds Controllers without a database Repo tests where they matter Bonus. The HTTP-less kernel (Sf 8.1) boots the DI container without HttpKernel.
  40. Symfony is the enabler, not the prescriber. Attributes are soft

    coupling. Ports stay. Advanced patterns are showcased in the vendor itself. Choose the right tier per context.
  41. C R E D I T S Sources & credits

    Matthias Noback Violating the Dependency Rule matthiasnoback.nl Mathias Verraes What is DDD William Durand DDD with Symfony 2: Making things clear DDD & API Platform 3 with Mathias Arlaud · the talk this one builds on github.com/mtarld/apip-ddd