When Code Is Cheap, Design Is Everything.

keep your business rules at the core of your application.

Business Lasts for Years. Frameworks Come and Go.

Frameworks come and go. Remember symfony 1, Zend Framework or CodeIgniter? Many businesses that started on them are still running today. Their rules haven’t changed much. A product still needs a price, and an order still needs to be paid. But the code around those rules has been rewritten again and again.

Now think about your own project. If you had to move to a new framework tomorrow, how much of your business logic would you have to touch? If the answer is “most of it”, your business rules are married to your framework.

Domain Driven Design fixes this. Your business rules live in plain PHP, in their own layer. Symfony and Doctrine stay outside, as replaceable tools. Change the framework, and the core business doesn’t even notice.

Why DDD Matters Even More with Coding Agents

Before LLMs, DDD had one big cost: extra code. Value objects, repositories, commands, mappings. Many teams said “too much code for a simple feature” and skipped it.

Today, a coding harness like Claude Code or Codex makes generating code much more efficient and saves us a lot of time. Writing code is no longer the slow part. But coding was always only one part of the software development life cycle. We still do requirement engineering, planning, system modeling and architectural design. And somebody still has to read, review and maintain the generated code.

So the question changed. It’s not “how fast can we write the code?” anymore. It’s “how clean and readable is the code, and how accurately does it describe the business?” In this area, DDD is hard to beat. Its strict rules even help the agent: clear names to follow, clear boundaries to respect, and aggregates that protect business rules from careless code. That’s why DDD deserves a place in your next big Symfony project.

Who is this article for?

This article is for Symfony developers who already know the basics of DDD. We focus on implementing DDD in Symfony, with short definitions along the way. If you’re new to the theory, Domain-Driven Design in PHP (Buenosvinos, Soronellas, Akbary) is a great start.

In my previous article, Mastering Symfony Service Container: Modern PHP Attributes Edition, we built a CreateProductService. In this guide, we take the next step. We'll follow one coding journey: create a product, give it a price, publish it, let customers review it, and tell the rest of the shop about it. When the journey is complete, so is this article.

All examples use PHP 8.5, Symfony 8.1, Doctrine ORM 3.7 and DoctrineBundle 3. We only show the core code here.

Where DDD Starts: Talk to the Domain Expert

Every DDD project starts with a conversation, not with code. Before our first line, we sat with our shop’s Catalog Manager. He doesn’t write code, but he knows every rule of the catalog. And he wrote them down for us:

Domain Driven Design starts with the domain expert: every business rule in his notebook becomes a DDD building block in our Symfony code.

The blue notes are ours. Notice something else: there is no Symfony in his notebook, no database, no framework. Just the business. We’ll keep coming back to this page on our journey.

Speak His Language: Ubiquitous Language

Look at the circled note: “Please use MY words in the code!” That’s the heart of DDD.

When our Catalog Manager says “publish the product”, our code should say:

// ❌ Developer language
$product->setStatus(2);

// ✅ Business language
$product->publish();

In DDD, this shared language between developers and business people is called the Ubiquitous Language. Same words in meetings, in tickets and in the code. When he reads $product->addReview(), he understands it. No translation needed.

Plan the Shop: Split it into Bounded Contexts

Our Catalog Manager doesn’t see one big shop. He sees four teams. We split our code the same way:

  • Catalog: products, prices, reviews
  • Inventory: stock, warehouses
  • Orders: carts, orders
  • Users: customers, addresses

Why split? He said it best: “My team decides price. Inventory decides stock. Don’t mix!” A t-shirt in Catalog has a title, a price and reviews. The same t-shirt in Inventory has a stock level and a warehouse shelf. One model for both would be a mess.

Each part owns its own model and its own language. In DDD, we call such a part a bounded context.

Now look at the small sketch on his page. Catalog tells Inventory when a product is published. Catalog gives Orders the price. Users give Orders the buyer. This drawing of who talks to whom is called a context map.

In Symfony, each bounded context gets its own folder under src/, and each one is split into three layers:

src/
├── Shared/
└── Catalog/
├── Domain/ the business rules, pure PHP
├── Application/ the use cases
└── Infrastructure/ Symfony, Doctrine, controllers

We’ll fill these folders one by one. Our journey starts in Catalog, and at the end, we’ll send a message to Inventory.

Let’s start in the heart of the app: the Domain.

Domain

The Domain is where our business rules live. Pure PHP. No Symfony, no Doctrine. If you delete Symfony tomorrow, this code still works.

Create the Product Model

He told us how a product lives: a new product is always a draft, then it gets published, and a cancelled product is dead. Let’s turn that into code. A Product is an Entity. It has an identity that never changes, even when its title or price changes.

He needs the product id right away, for the photo upload. So we generate the id ourselves, as a UUID. Not with database auto-increment. We know the id before we save the product. You’ll see another reason later.

namespace App\Catalog\Domain\Model\Product;

final readonly class ProductId
{
public function __construct(public string $value) {}

public function __toString(): string
{
return $this->value;
}
}

enum ProductStatus: string
{
case Draft = 'draft';
case Published = 'published';
case Cancelled = 'cancelled';
}
namespace App\Catalog\Domain\Model\Product;

final class Product
{
public private(set) ProductStatus $status;
public private(set) \DateTimeImmutable $createdAt;
public private(set) ?\DateTimeImmutable $publishedAt = null;

public function __construct(
public private(set) ProductId $id,
public private(set) string $title,
public private(set) string $description,
public private(set) int $price,
public private(set) string $image,
) {
$this->status = ProductStatus::Draft;
$this->createdAt = new \DateTimeImmutable();
}
}

That’s it! A few things to notice:

  • public private(set) is a PHP 8.4 feature. Everyone can read $product->title, but only the Product can change it. No getters, no setters.
  • A new product is always a Draft. The caller can't decide this. The model does.
  • No use Symfony…, no use Doctrine\ORM…. Just plain PHP.
  • The class is final, and Doctrine is fine with it. Since PHP 8.4, Doctrine uses native lazy objects instead of generated proxy classes, so it doesn't need to extend our entity.

Note: You might be thinking, “Wait, int $price? What about the currency? What if it's negative?" Good question! That's our next step.

Protect the Price using a Value Object

Our Catalog Manager is clear: a price is not just a number. It is an amount plus a currency, and it can never be negative. If we use int $price, every service must remember these rules.

A Value Object keeps them in one place. It has no identity, and it never changes after it is created.

namespace App\Catalog\Domain\Model\Product;

enum Currency: string
{
case USD = 'USD';
case EUR = 'EUR';
case BDT = 'BDT';

public static function fromCode(string $code): self
{
return self::tryFrom(strtoupper($code))
?? throw new \InvalidArgumentException("Currency $code is not supported.");
}
}

final readonly class ProductPrice
{
public function __construct(
public int $amount, // in cents, never float for money
public Currency $currency,
) {
if ($amount < 0) {
throw new \InvalidArgumentException('Price can not be negative.');
}
}

#[\NoDiscard]
public function withAmount(int $amount): self
{
return new self($amount, $this->currency);
}
}

Now our Product uses it:

// ❌ Before
public private(set) int $price,

// ✅ After
public private(set) ProductPrice $price,

Why this is better:

  • Always valid: a negative price can’t even be created.
  • One place: the rules live in ProductPrice, not in every service.
  • Safe to share: it never changes. withAmount() returns a new price, and #[\NoDiscard] (PHP 8.5) warns you if you forget to use it.

Stop Bad Data using Validation

The price protects itself. He has rules for the listing too:

  • Title must not be empty and must be at most 100 characters.
  • Description must not be empty.
  • Image must be a URL or a path.

We add small checks in the constructor:

public function __construct(/* ... */)
{
$this->assertValidTitle($title);
// assertValidDescription(), assertValidImage() ...

$this->status = ProductStatus::Draft;
$this->createdAt = new \DateTimeImmutable();
}

private function assertValidTitle(string $title): void
{
if (trim($title) === '' || mb_strlen($title) > 100) {
throw new \InvalidArgumentException('Title must be 1 to 100 characters.');
}
}

Symfony Validator is still great for checking form input. But only the Domain can guarantee that a Product is always valid, wherever it is created from: an API, a form, a console command or an import script.

Our product is valid. Now let’s give it real behavior.

Publish and Cancel a Product using the Aggregate Root

Let’s look at a typical service from a well-written Symfony app:

// ❌ Business rules live in the service
class PublishProductService
{
public function publish(Product $product): void
{
if ($product->getStatus() !== 'draft') {
throw new \DomainException('Only draft products can be published.');
}

$product->setStatus('published');
$product->setPublishedAt(new \DateTimeImmutable());
$this->repository->save($product);
}
}

Nothing is wrong with this code. But the Product is just a bag of setters. Tomorrow, an ImportProductService calls setStatus('published') and forgets the check. Now the rule is broken, and nobody noticed.

Remember the status sketch on his page: a cancelled product can never be published again. The red cross says it all. Let’s move that rule where it belongs: inside the Product.

// ✅ The Product protects its own rules
public function publish(): void
{
if ($this->status !== ProductStatus::Draft) {
throw new \DomainException('Only a draft product can be published.');
}

$this->status = ProductStatus::Published;
$this->publishedAt = new \DateTimeImmutable();
}

public function cancel(): void
{
if ($this->status === ProductStatus::Cancelled) {
throw new \DomainException('Product is already cancelled.');
}

$this->status = ProductStatus::Cancelled;
}

That’s it! Now there is only one way to publish a product, and it always checks the rule. No service can forget it.

This is called a Rich Domain Model. The opposite, a class with only getters and setters, is an Anemic Domain Model.

Our Product is also an Aggregate Root. An aggregate is a group of objects that change and are saved together. The root is the only door into the group. Let’s see what is inside our group.

Add a Review and Calculate the Average Rating

Customers can review only a published product, and the average rating must be fast. He even told us how: keep a count and a total. A review only exists inside a product, so the Product creates it. Nobody else calls new Review().

First, a tiny Value Object for the rating:

final readonly class Rating
{
public function __construct(public int $value)
{
if ($value < 1 || $value > 5) {
throw new \InvalidArgumentException('Rating must be between 1 and 5.');
}
}
}

Now the Product:

public private(set) int $reviewCount = 0;
public private(set) int $ratingSum = 0;
private Collection $reviews;
private int $version = 1;

public function addReview(string $customerId, Rating $rating, string $comment): void
{
if ($this->status !== ProductStatus::Published) {
throw new \DomainException('Only a published product can be reviewed.');
}

$this->reviews->add(new Review($this, $customerId, $rating->value, $comment));
$this->reviewCount++;
$this->ratingSum += $rating->value;
}

public function averageRating(): float
{
return $this->reviewCount === 0 ? 0.0 : round($this->ratingSum / $this->reviewCount, 1);
}

Why this is better:

  • Fast: we never loop over 10,000 reviews. We keep reviewCount and ratingSum up to date.
  • Always correct: the average can’t get out of sync, because only addReview() changes these numbers.
  • Customer by id: $customerId is just a string. Customers live in the Users context, so we only keep their id.

Note: You might be thinking, “What about Black Friday? Two reviews in the same second?” Good question! That is what $version is for. Doctrine uses it for optimistic locking: if someone else changed the product first, Doctrine throws an OptimisticLockException, and the second request can simply try again.

Save the Product using a Repository

Our Product is ready. But where do we save it? The Domain can’t use Doctrine. So we only define an interface here:

namespace App\Catalog\Domain\Model\Product;

interface ProductRepository
{
public function nextIdentity(): ProductId;
public function add(Product $product): void;
public function productOfId(ProductId $id): ?Product;
public function existsWithTitle(string $title): bool;
}

Why this is better:

  • One repository per aggregate: ProductRepository only. No ReviewRepository, because reviews live inside the product.
  • No flush(): the repository works like a collection. The transaction is handled one layer up.
  • Easy to test: you can write an InMemoryProductRepository in a few lines.

Who implements it? Not the Domain. We’ll come back to this in Infrastructure.

Never the Same Title Twice using a Domain Service

Look at the poster again. One rule is different from the others: “Never the same title twice!”

Every rule so far fit inside one Product. A product can check its own price, its own title length and its own status. But can a product know the titles of all the other products? No. It only knows itself.

When a business rule doesn’t belong to one entity or value object, we use a Domain Service:

namespace App\Catalog\Domain\Model\Product;

final readonly class UniqueProductTitle
{
public function __construct(private ProductRepository $productRepository) {}

public function check(string $title): void
{
if ($this->productRepository->existsWithTitle($title)) {
throw new ProductTitleAlreadyUsed("A product named \"$title\" already exists.");
}
}
}

That’s it! It’s still pure Domain code. It talks to the ProductRepository interface, not to Doctrine. And its name comes from his words, not from ours. No ProductDomainService, no ProductHelper.

Note: You might be thinking, “Great! Then let’s put all the rules in Domain Services!” Please don’t. Always ask first: can the entity do it? Most of the time, the answer is yes. Too many Domain Services give you an anemic Product with fat services again. The same old problem with a new name.

Our Domain is complete. Look back at every class: no use Symfony…, no use Doctrine\ORM…. Only plain PHP and his rules.

But wait. Who creates a Product? Who calls publish()? Where do we call all of this?

Application

The Application layer holds our use cases: “create a product”, “publish a product”, “add a review”. It takes input from the outside world and tells the Domain what to do. Still no framework code here.

Send Input using a Command

A Command is a simple data object that carries the input for one use case. It doesn’t care if the data came from an API, a form or the console.

namespace App\Catalog\Application\Service\Product;

final readonly class CreateProductCommand
{
public function __construct(
public string $title,
public string $description,
public int $priceAmount,
public string $currency,
public string $image,
) {}
}

Three simple rules:

  • Primitive types only. The service builds the Value Objects.
  • No logic, no validation. The Domain validates.
  • No #[Assert] attributes. Commands stay framework-free.

Note: You might be thinking, “Command for writing, and for reading?” Good question! We use a Query for reading, like ProductQuery, and it returns a ProductResponse DTO. Separate models for writing and reading: that's CQRS in its simplest form. Our CreateProductService returns only the new product id. That's a small, practical compromise: the client needs to know which product was created.

Create a Product using an Application Service

An Application Service runs one use case from start to end. Here is ours:

namespace App\Catalog\Application\Service\Product;

final readonly class CreateProductService
{
public function __construct(
private ProductRepository $productRepository,
private UniqueProductTitle $uniqueProductTitle,
private TransactionalSession $session,
) {}

public function execute(CreateProductCommand $command): string
{
return $this->session->executeAtomically(function () use ($command) {
$this->uniqueProductTitle->check($command->title);

$product = new Product(
id: $this->productRepository->nextIdentity(),
title: $command->title,
description: $command->description,
price: new ProductPrice($command->priceAmount, Currency::fromCode($command->currency)),
image: $command->image,
);

$this->productRepository->add($product);

return (string) $product->id;
});
}
}

Look how short it is. Check the title, create the product, save it, return the id. Every real rule stays in the Domain.

TransactionalSession is a small interface in our Application layer:

namespace App\Shared\Application;

interface TransactionalSession
{
public function executeAtomically(callable $operation): mixed;
}

Remember, our repository never calls flush(). The session wraps the whole use case in one transaction. All or nothing.

The other use cases follow the same shape. Here is the important part of AddProductReviewService:

// ❌ The service checks a business rule
if ($product->status !== ProductStatus::Published) {
throw new \DomainException('Only a published product can be reviewed.');
}

// ✅ The service only asks the Product
$product->addReview($command->customerId, new Rating($command->rating), $command->comment);

If you see a business if in an Application Service, it's a sign. That rule wants to move into the Domain.

Note: You might be thinking, “Application Service or Domain Service? They’re both services!” Good question! Here is the difference:

  • Application Service: runs one use case. Loads, calls the Domain, saves. Knows the transaction. No business rules. Example: CreateProductService.
  • Domain Service: holds a business rule that doesn’t fit one entity. Knows only the Domain. Example: UniqueProductTitle.
  • Infrastructure Service: does technical work, like sending an email or talking to Doctrine. Example: DoctrineProductRepository, coming soon.

Golden Rules:

  1. The constructor accepts only Domain and Application classes and interfaces.
  2. One service, one job, one public method: execute().
  3. execute() receives only a Command or Query.
  4. execute() returns a Response DTO, a simple id, or nothing. Never the entity.
  5. If a controller needs more than two services, create one container service that calls them. (This one is from my own practice.)

PublishProductService and AddProductReviewService are in the repo.

Our use case is ready. But who calls execute()? Where do we call this service?

Look at the Dependencies

Before we answer, let’s stop for a second and look at what we built:

src/Catalog/
├── Domain/ Product, ProductPrice, Rating, ProductRepository, UniqueProductTitle
├── Application/ CreateProductCommand, CreateProductService
└── Infrastructure/ (empty, for now)
  • The Domain has no dependencies. It doesn’t know the Application exists.
  • The Application uses the Domain.
  • The Infrastructure will use the Application.

Three layers, one on top of the other. It looks like the classic Layered Architecture.

But notice one small detail. ProductRepository is an interface, and it lives in the Domain. TransactionalSession is an interface too, and it lives in the Application. Their real implementations will live outside, in Infrastructure. Keep this in mind. It's the key to the next section.

Infrastructure

Infrastructure is where Symfony and Doctrine finally appear. It has two jobs: save our data and deliver our use cases to the outside world.

Save the Product using a Doctrine Adapter

Step 1. Implement the repository

namespace App\Catalog\Infrastructure\Persistence\Doctrine;

final readonly class DoctrineProductRepository implements ProductRepository
{
public function __construct(private EntityManagerInterface $entityManager) {}

public function nextIdentity(): ProductId
{
return new ProductId(Uuid::v7()->toRfc4122());
}

public function add(Product $product): void
{
$this->entityManager->persist($product); // no flush here
}

public function productOfId(ProductId $id): ?Product
{
return $this->entityManager->find(Product::class, $id);
}

public function existsWithTitle(string $title): bool
{
return $this->entityManager->getRepository(Product::class)->count(['title' => $title]) > 0;
}
}

Step 2. Implement the transaction

namespace App\Shared\Infrastructure\Persistence;

final readonly class DoctrineTransactionalSession implements TransactionalSession
{
public function __construct(private EntityManagerInterface $entityManager) {}

public function executeAtomically(callable $operation): mixed
{
// begins, flushes and commits; rolls back on error
return $this->entityManager->wrapInTransaction(fn () => $operation());
}
}

Step 3. Tell Symfony which adapter to use

Remember #[AsAlias] from my Service Container article? It's perfect here:

#[AsAlias(ProductRepository::class)]
final readonly class DoctrineProductRepository implements ProductRepository

#[AsAlias(TransactionalSession::class)]
final readonly class DoctrineTransactionalSession implements TransactionalSession

That’s it! No configuration needed. Our CreateProductService asks for ProductRepository, and Symfony gives it DoctrineProductRepository.

Now look at the direction of the arrows. In the classic Layered Architecture, the business layer calls the database layer. Here it’s the opposite. Doctrine depends on our Domain, because it implements our interface. Our Domain knows nothing about Doctrine.

This is Hexagonal Architecture, also called Ports and Adapters:

  • A port is an interface that belongs to our core: ProductRepository, TransactionalSession.
  • An adapter is an implementation that lives outside: DoctrineProductRepository, DoctrineTransactionalSession.

Our core is in the middle. Doctrine is just one adapter plugged into one port. Tomorrow, you could plug in a different one, and the Domain wouldn’t change a single line.

Note: You might be thinking, “We had the interface from the beginning. So was it hexagonal all along?” Good question! Yes, it was. We just didn’t give it a name until we could see both sides.

Map the Product without Touching the Domain

Our Product has no Doctrine attributes. So how does Doctrine know the table? We keep the mapping in Infrastructure.

Step 1. Register the mapping path

# config/packages/doctrine.yaml
doctrine:
orm:
mappings:
Catalog:
type: php
dir: '%kernel.project_dir%/src/Catalog/Infrastructure/Persistence/Doctrine/Mapping'
prefix: 'App\Catalog\Domain\Model'

We tell Doctrine: “The classes are in the Domain, but their mapping is in Infrastructure.” We use the PHP mapping driver.

Step 2. Write the mapping

// Infrastructure/Persistence/Doctrine/Mapping/App.Catalog.Domain.Model.Product.Product.php

return static function (ClassMetadata $metadata): void {
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('catalog_products');

$builder->createField('id', 'product_id')->makePrimaryKey()->build();
$builder->createField('title', 'string')->length(100)->unique()->build();
$builder->addEmbedded('price', ProductPrice::class, 'price_');
$metadata->mapField(['fieldName' => 'status', 'type' => 'string', 'enumType' => ProductStatus::class]);
$builder->createField('version', 'integer')->isVersionField()->build();

$builder->createOneToMany('reviews', Review::class)
->mappedBy('product')
->cascadePersist()
->orphanRemoval()
->build();
};

A few things to notice:

  • The file name matters! It must be the full class name with dots. If you name it Product.php, Doctrine can't find it.
  • The file returns a closure. Older tutorials write into a global $metadata variable instead. That style is deprecated since doctrine/persistence 4.2.
  • addEmbedded() saves our ProductPrice as two columns: price_amount and price_currency. ProductPrice gets its own tiny mapping file as an embeddable. See the repo.
  • isVersionField() turns on the optimistic locking we talked about.
  • product_id is a small custom Doctrine type that converts our ProductId to a string and back. One attribute registers it: #[AsDbalType('product_id')] on the type class. See the repo.

Note: You might be thinking, “Why unique() on the title? We already have UniqueProductTitle!" Good question! Two requests can check the same title at the same second, and both pass. The Domain Service gives a clear business message. The database index is the safety net.

So, where do we call CreateProductService? From any door to the outside world: an API controller, a console command or a Messenger handler only turns its input into a CreateProductCommand and calls execute(), while the Domain and the Application stay exactly the same.

Our product is saved. But the Inventory team doesn’t know about it yet.

Back to the Domain: Domain Events

Tell the System What Happened using Domain Events

When a product is published, the Inventory context needs to create a stock item for it. But our Product must not know about Inventory. So the Product just announces: “I was published.” Whoever cares can listen.

This is a Domain Event: something important that happened in the business, named in the past tense.

Step 1. Create the events

namespace App\Shared\Domain\Event;

abstract readonly class CommonEvent
{
public \DateTimeImmutable $occurredOn;

public function __construct(?\DateTimeImmutable $occurredOn = null)
{
$this->occurredOn = $occurredOn ?? new \DateTimeImmutable();
}
}
namespace App\Catalog\Domain\Model\Product;

final readonly class ProductPublished extends CommonEvent
{
public function __construct(
public string $productId,
public string $title,
public int $priceAmount,
public string $currency,
?\DateTimeImmutable $occurredOn = null,
) {
parent::__construct($occurredOn);
}
}

Step 2. Create a publisher

DomainEventPublisher is a small singleton. Anyone who wants to listen implements DomainEventSubscriber:

interface DomainEventSubscriber
{
public function handle(CommonEvent $event): void;
public function isSubscribedTo(CommonEvent $event): bool;
}

final class DomainEventPublisher
{
// instance(), subscribe(), unsubscribe() ... see the repo

public function publish(CommonEvent $event): void
{
foreach ($this->subscribers as $subscriber) {
if ($subscriber->isSubscribedTo($event)) {
$subscriber->handle($event);
}
}
}
}

Step 3. Publish from the Product

public function publish(): void
{
// ... check the rule, change the status

DomainEventPublisher::instance()->publish(new ProductPublished(
productId: (string) $this->id,
title: $this->title,
priceAmount: $this->price->amount,
currency: $this->price->currency->value,
));
}

Remember why we generated the id ourselves? This is why. The event needs the product id before the product is saved. addReview() publishes a ProductReviewed event in the same way.

Golden Rules:

  1. Do not publish events from Infrastructure, like a Controller.
  2. Events must be serializable: only primitive values in the constructor.
  3. Use readonly classes for events.
  4. Use named arguments when creating an event.
  5. Publish events inside the Domain Model.

Our Product now tells the world what happened. Let’s make sure the world can hear it.

Talk Between Bounded Contexts

Tell Inventory about Published Products using Messenger

Each bounded context is an island with its own model. They only send messages to each other, like paper boats.

All our contexts live in one Symfony app, in separate folders: src/Catalog, src/Inventory and so on. But they never change each other's data. They only send messages. Symfony Messenger is perfect for this.

Remember his note: “Published? Tell Inventory, only after it’s saved.” Inventory must hear about a product only after it is really saved. If the transaction fails, no message should go out.

Step 1. Collect events during the use case

namespace App\Shared\Infrastructure\Messaging;

final class DomainEventCollector implements DomainEventSubscriber
{
private array $events = [];

public function isSubscribedTo(CommonEvent $event): bool
{
return true;
}

public function handle(CommonEvent $event): void
{
$this->events[] = $event;
}

public function release(): array
{
[$events, $this->events] = [$this->events, []];

return $events;
}
}

Step 2. Dispatch them after the commit

We update our DoctrineTransactionalSession:

public function executeAtomically(callable $operation): mixed
{
$collector = new DomainEventCollector();
$subscriberId = DomainEventPublisher::instance()->subscribe($collector);

try {
$result = $this->entityManager->wrapInTransaction(fn () => $operation());
} finally {
DomainEventPublisher::instance()->unsubscribe($subscriberId);
}

foreach ($collector->release() as $event) {
$this->messageBus->dispatch($event); // only after a successful commit
}

return $result;
}

If the transaction fails, wrapInTransaction() throws, and we never reach the foreach. No message for a product that was never saved.

Step 3. Route events to Redis

# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%' # redis://localhost:6379/messages
failed: 'doctrine://default?queue_name=failed'
routing:
App\Shared\Domain\Event\CommonEvent: async

Step 4. Handle it in Inventory

namespace App\Inventory\Infrastructure\Messaging;

#[AsMessageHandler]
final readonly class WhenProductPublishedCreateStockItem
{
public function __construct(private CreateStockItemService $service) {}

public function __invoke(ProductPublished $event): void
{
$this->service->execute(new CreateStockItemCommand(productId: $event->productId));
}
}
php bin/console messenger:consume async

That’s it! A product is published in Catalog, and a stock item appears in Inventory. Neither context knows how the other one works. Inventory only knows the event.

Why this is better:

  • Safe: no message for a product that was never saved.
  • Independent: if Inventory is down, Catalog still works. The message waits in Redis, and Inventory catches up later.
  • Easy to grow: the Orders context can listen to the same event with its own handler.

Note: You might be thinking, “What if Redis is down right after the commit?” Good question! Then the message is lost. For production, use the Outbox pattern: save the events in a table of your own database, in the same transaction, and let a worker forward them to Redis. That’s a topic for another article.

Our journey is complete.

Conclusion

We started with one notebook page full of business rules from our Catalog Manager. Now every rule lives in the code, in his words:

  • $product->publish() replaced setStatus() scattered across services.
  • ProductPrice replaced int $price and repeated checks.
  • addReview() replaced copy-pasted average-rating code.
  • UniqueProductTitle replaced a uniqueness check repeated in every form and import script.
  • Application Services gave every use case one home, for the API and the console.
  • Ports and adapters replaced Doctrine and Symfony inside our business code.
  • Domain Events and Messenger replaced direct calls between contexts.

And remember the promise at the start? Let’s prove it with one command:

grep -rE 'use (Symfony|Doctrine\\ORM)' src/Catalog/Domain
# (no output)

Zero framework imports. His notebook never mentions Symfony, and neither does our Domain. (Our Product uses Doctrine\Common\Collections for its reviews. That is a small standalone library, not the ORM.)

A coding agent can write all these classes for you in minutes. But it can’t talk to your Catalog Manager, find the bounded contexts, or decide where a rule belongs. That part is still our job. And DDD gives us the map.

Business lasts for years. Frameworks come and go. If Symfony disappeared tomorrow, our Product would not even notice.