geekcell/ddd-bundle
GeekCell DDD Bundle is a Laravel/PHP package for applying Domain-Driven Design with reusable building blocks and conventions that help organize domain, application, and infrastructure layers into cleaner, more maintainable code.
This is a small, low-adoption bundle (13 stars) from GeekCell that ships the usual DDD primitives for Symfony: base classes for entities/aggregate roots, value objects, domain events, and a publisher that hooks into the Symfony event dispatcher. The repository is archived with a last release in Feb 2025, so treat it as source code you are adopting, not a maintained dependency.
Minimal steps:
composer require geekcell/ddd-bundle
Register it in config/bundles.php (or config/bundles.php on Symfony 4+):
return [
// ...
GeekCell\DddBundle\DddBundle::class => ['all' => true],
];
Then read the package source before writing a line of domain code — it's small enough to audit in one sitting:
vendor/geekcell/ddd-bundle/src/
├── DddBundle.php
├── Domain/ # Entity, AggregateRoot, ValueObject, DomainEvent contracts
├── Event/ # publisher / dispatcher wiring
└── Resources/config/ # service definitions and tags
Also run php bin/console debug:container --tag=… and debug:event-dispatcher to see what the bundle actually registers in your app; auto-configuration is the main thing that will surprise you.
First use case: model one aggregate root with one value object and one domain event, and get the event to the Symfony dispatcher after a Doctrine flush. That single loop exercises ~90% of what the bundle gives you. Everything else (repositories, messaging) is conventional Symfony wiring you can add later.
Aggregate root that records events, dispatcher releases them after commit. The typical shape is a base AggregateRoot that collects domain events internally, plus a subscriber or middleware that drains them at the right moment:
final class Order extends AggregateRoot
{
/** @var OrderLine[] */
private array $lines = [];
public static function place(OrderId $id, CustomerId $customer): self
{
$order = new self($id, $customer);
$order->recordThat(new OrderPlaced($id, $customer));
return $order;
}
public function addLine(ProductId $product, int $qty): void
{
$this->lines[] = new OrderLine($product, $qty);
$this->recordThat(new LineAdded($this->id, $product, $qty));
}
}
Then release only after the transaction is durable. If the bundle's publisher is just an event-dispatcher bridge, prefer an explicit release in your command handler over relying on a Doctrine post-flush hook, otherwise a rolled-back transaction still emits events:
$this->em->wrapInTransaction(function () use ($order) {
$this->orders->save($order);
$this->publisher->publish(...$order->releaseEvents());
});
If you use Symfony Messenger for side effects, publish to a transport instead of dispatching in-process — that gives you retries and a natural transactional-outbox seam.
Value objects as the unit of validation. Use the base ValueObject for equality/immutability, but keep named constructors (OrderId::fromString(), Money::of()) as the only public entry points, and throw domain-specific exceptions rather than InvalidArgumentException. Private constructors + self:: factories also make Doctrine embedding and test fixtures much easier to reason about.
Repository interface in Domain, implementation in Infrastructure. The bundle doesn't generate repositories; write the interface yourself and bind it in config/services.yaml:
App\Order\Domain\OrderRepository:
alias: App\Order\Infrastructure\DoctrineOrderRepository
This keeps the domain layer free of EntityManagerInterface and is the single highest-value DDD practice regardless of what the bundle provides.
Layer your tests. Aggregate and value-object tests should run without a kernel (new Order(...), assert recorded events). Only repository tests need KernelTestCase + Doctrine\Bundle\DoctrineBundle\.... Because the base classes are plain PHP, this split works well and is fast.
Integration tip — keep the domain events small and serializable. If events cross Messenger, they need to be plain-data or have Symfony Serializer normalizers. Value objects (IDs, Money) should get their own normalizers rather than leaking into the transport as opaque objects.
composer.json, and budget a half-day to vendor the base classes into src/Domain/Shared/ if you plan to keep the codebase past a year. The abstractions are ~a few hundred lines; forking is cheap and removes the supply-chain question.AggregateRoot subclasses or tag services can pick up test fixtures, DTOs, or classes in tests/. Check debug:container and scope the compiler pass with an explicit namespace in your config if it offers one.postFlush/kernel.terminate publisher will re-emit events if the same aggregate is flushed again without clearing its event list. Always releaseEvents() (clear-then-return) rather than getEvents(), and clear in a finally.id property that fights with Doctrine's reflection-based hydration (private props on parents, __construct signatures). If you see "cannot access private property" or a null ID after find(), the base class is the culprit — either mark properties protected or use your own base.flush() means listeners run before the DB transaction commits; a listener that queries the same aggregate may see stale state. Prefer after-commit dispatch or Messenger.Normalizer/Denormalizer registration for Messenger and for API responses; don't assume the bundle's Doctrine type covers transport serialization.php bin/console debug:event-dispatcher <EventClass> to confirm your domain event actually reaches listeners; debug:container --tag=… to see tagged handlers; enable doctrine.dbal.logging to catch flush ordering issues.decorates:) to add logging/outbox/after-commit semantics rather than editing vendor code; alias repository interfaces in services.yaml; if the bundle exposes compiler-pass options, set them explicitly so upgrades/forking are predictable.How can I help you explore Laravel packages today?