netteam/ddd-bundle
Netteam DDD Bundle provides a domain-driven design foundation for Laravel/PHP applications, offering shared building blocks and conventions to organize domain, application, and infrastructure layers cleanly.
Reality check before you type composer require. netteam/ddd-bundle is a Symfony bundle — a Bundle subclass plus service definitions under Resources/config. There is no ServiceProvider, no publishable config/ddd.php, and no Laravel package discovery entry. Installing it in a Laravel app drags in symfony/framework-bundle and friends and gives you nothing usable. Decide up front: are you (a) in a Symfony app, or (b) mining the repo for its DDD primitives and re-implementing them as Laravel-friendly base classes in app/Domain? For a Laravel dev, (b) is the realistic path.
Minimal steps in a Symfony app:
composer require netteam/ddd-bundle, then register it in config/bundles.php.src/ before the README. With zero stars and zero dependents, the base classes are the documentation. Expect an Entity base or trait, an aggregate root that buffers domain events, a DomainEvent marker interface, a bus abstraction, and a Doctrine-flavoured repository base.Resources/config/ to see what is auto-wired, which interfaces are aliased, and what you must bind yourself (usually the bus implementation and your repository interfaces).composer.json for the Doctrine requirement — whether it is require or suggest determines how much of the bundle you can use at all.First use case worth attempting: one aggregate, one invariant, one event, one round trip. Write the entity extending the provided base, add a single method that mutates state and records a domain event through the aggregate's record()/raise() helper, persist via a repository extending the bundle's Doctrine base, and confirm the event reaches the bus. Once that works you understand the whole package; everything else is repetition.
Confine the bundle to the Domain layer. Let it own entities, value objects, aggregates, and domain events. Application layer holds command/query handlers that fetch an aggregate, call exactly one method, and persist. Infrastructure holds the Doctrine repository implementations, the bus transport, and the container wiring. Never let bundle base classes leak into controllers, jobs, or listeners — the moment they do, swapping or dropping the bundle becomes a rewrite instead of a delete.
Aggregate-owns-invariants. Every state change goes through a method on the aggregate; no setters, no public properties. The aggregate records events as a side effect of those methods. A handler should be three lines — fetch, call one method, persist. If a handler calls four methods on an aggregate, the model is anaemic and the base classes are buying you nothing.
Collect-then-flush event dispatch, in that order. The aggregate root pattern almost certainly buffers events on the instance rather than dispatching inline. Persist first, then pull releaseEvents() and hand them to the bus. Doing it the other way emits phantom events when the DB write fails.
$order = $this->orders->get($command->orderId);
$order->confirm(); // records OrderConfirmed
$this->orders->save($order); // flush happens here
$this->bus->dispatch($order->releaseEvents());
Repository as collection, not as query builder. The domain-facing interface should look like get(), add(), remove(), nextIdentity(). Keep findBy... criteria objects or specification methods in the domain contract; keep DQL/QueryBuilder entirely inside the infrastructure implementation.
Command/query bus wiring via tags. In Symfony, handler registration is tag-driven — one handler per command, constructor-injected into a handler locator. Resist registering multiple handlers per command class "just in case"; the locator will silently resolve the wrong one or throw at dispatch time, and the error message points at the container, not your handler.
Value objects: immutable, self-validating, static factories. Guard in the constructor, expose fromString()/::fromNative(), and no setters. Keep them final so nobody extends your Money and breaks equality.
Laravel translation of the pattern. Vendor the primitives into app/Domain/Shared with attribution, then:
private array $events = []; protected function record(object $e): void { $this->events[] = $e; } public function releaseEvents(): array { $e = $this->events; $this->events = []; return $e; }Dispatcher, or a CommandBus interface with a container-resolved handler map.DB::afterCommit(fn () => event($events)) — or dispatch in the handler immediately after save() returns.Testing split. Aggregates and value objects are pure — unit test them with zero container, zero DB, zero framework. Repositories and bus wiring get integration tests. If you cannot unit test an aggregate without booting the kernel, the bundle has coupled your domain to the framework and the DDD purity argument collapses.
Assume you own the fork on day one. Zero stars, zero dependents, no release cadence, no CVE history. Pin the exact version in composer.json, vendor if you must, and record the commit SHA you extracted from. Check the license before redistributing internally.
Verify framework coupling early. The decisive question is whether base classes force you to extend a framework class or implement a framework interface. If AggregateRoot extends a Symfony or Doctrine class, your domain is transitively tied to that framework and every major Symfony upgrade becomes a domain-layer regression test.
No transactional outbox. The buffered-events pattern guarantees ordering within a request, not delivery. If a crash happens between the DB commit and the bus dispatch, the event is lost. If you need at-least-once, build an outbox table yourself — do not assume the bundle provides one just because it has an event bus.
Doctrine boundaries. Keep aggregate identity explicit (OrderId value object, not an auto-increment int leaking into the domain). Avoid lazy-loaded collections crossing aggregate boundaries inside a single transaction; load the aggregate whole, or you will end up with an aggregate whose invariants were never enforced.
Serializability of events. If the bus is Symfony Messenger, every domain event becomes a message that gets serialized onto a transport. Events carrying entities or value objects that lack a stable serialization form will fail at dispatch, not at construction — a maddening error. Keep events primitive-only or add explicit normalizers.
Debugging tools that actually help.
bin/console debug:container --tag=<handler-tag> to confirm your handlers are registered, debug:event-dispatcher for listener wiring, and debug:container <YourRepositoryInterface> to verify the alias points at your Doctrine implementation rather than a default.php artisan tinker plus a dd() inside the handler map is faster than chasing container caching.Extension points to prefer. Override individual service definitions rather than replacing the whole bundle config; decorate the bus rather than subclass it (middleware for logging, transactions, and retries composes cleanly, inheritance does not). If you need to swap the event dispatcher, do it through the interface alias — that is the seam the bundle is designed around.
Do not adopt it for a critical path. If a two-to-three day spike needs more adapter code than domain code, stop and build the ~few-hundred-line internal layer instead. Treat the upstream repo as reference material, not a versioned dependency — the maintenance tax is a standing allocation, not a one-time cost.
How can I help you explore Laravel packages today?