php-ddd/domain-driven-design
php-ddd/domain-driven-design helps Laravel/PHP developers structure applications around bounded contexts and domain logic, with reusable DDD building blocks and conventions for entities, value objects, aggregates, repositories, services, and domain events.
This is a building-blocks library, not a framework — it gives you base classes/interfaces for Entities, Value Objects, Aggregates, Domain Events, Repositories, and Specifications, and you wire them into your own app. Treat it as reference material more than a dependency you build on.
Minimal steps:
composer.json almost certainly pins PHP 5.x-era constraints. Try composer require php-ddd/domain-driven-design in a scratch project; if the constraint solver refuses, fork it, relax the php and phpunit constraints, and require it as a VCS/path repository. Do this before designing anything around it.src/ before the README. Docs from that era are thin. The namespace layout is the real documentation: look for Entity, ValueObject, Aggregate/AggregateRoot, DomainEvent, Repository, Specification, and any Exception folders. Each base class tells you what the author considered mandatory (usually identity comparison and equality semantics).equals() behavior on value objects and event collection on aggregates.First use case: model a single aggregate end-to-end rather than adopting the whole package at once.
final class Money implements ValueObject
{
private function __construct(
private int $amount,
private string $currency
) {}
public static function fromInt(int $amount, string $currency): self
{
return new self($amount, $currency);
}
public function equals($other): bool
{
return $other instanceof self
&& $this->amount === $other->amount
&& $this->currency === $other->currency;
}
}
Then an aggregate root that records a domain event, and a repository interface in your domain layer. That triangle (Value Object → Aggregate + Event → Repository interface) is the fastest way to judge whether the package's opinions match yours. If they don't, extract the ideas and drop the dependency — you'll lose very little.
Value Objects for money, IDs, and email. Extend the package's value object base (or follow its contract) so equals() is centralized. Prefer named constructors (fromString, fromInt) over public constructors, and keep them immutable — no setters, no public properties.
Entities compared by identity, not attributes. The package's entity base typically exposes identity-based equality. Keep your equals()/sameIdentityAs() on the base class semantics and never override it with attribute comparison for persisted entities.
Aggregate roots as the only mutation entry point. Children (order lines, etc.) get mutated only through root methods. The root validates invariants and records domain events; children never dispatch their own events. This is where a building-blocks library earns its keep — it's a checklist, not machinery.
Collect domain events on the aggregate, dispatch them at the boundary. The library gives you the event objects and probably a recorder on the aggregate root; it does not give you a dispatcher. In Laravel:
DB::transaction(function () use ($order) {
$this->orders->save($order); // repository impl
$events = $order->releaseEvents(); // drain the recorder
});
foreach ($events as $event) {
event($event); // or dispatch to a bus
}
Draining after commit is the single most important integration decision. Dispatching inside the transaction means listeners can observe state that later rolls back.
Repository interface in the domain, Eloquent implementation in infrastructure. Bind in a service provider:
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
The implementation maps between your aggregate and Eloquent models. If your aggregate is an Eloquent model, you've collapsed the layers and the package buys you nothing — pick one or the other.
Specifications for query composition. Use them for reusable domain rules (ActiveCustomerSpecification, OverdueInvoiceSpecification). In practice, translate a specification into a query builder in the repository implementation rather than filtering in memory; keep the specification object as the named, testable rule.
Factories when construction is non-trivial. If an aggregate needs several collaborators or a multi-step invariant check, put a named factory in the domain layer rather than a fat static constructor on the entity.
strict_types, and nullable syntax; it may also use each(), create_function(), or other removed functions. Expect deprecation noise and possible fatals on PHP 8. Run the suite on your target version before committing to it.composer.lock safety net. Unmaintained packages drift against your other dependencies. Pin an exact version (or a commit hash from your fork) and record why you're carrying an abandoned dependency.__serialize/__unserialize can break queue payloads and cache round-trips. Test serialization explicitly before pushing an aggregate onto a queue.Entity couples your domain to the package forever. If you only need equals() semantics, copy the ten lines into your own base class — an archived dependency is not worth a permanent inheritance chain.equals() returns false unexpectedly, dump both sides' raw properties. Era-appropriate implementations often use loose == or serialize() comparison, which quietly treats 1 and "1" — or two objects with different class order — inconsistently.php-ddd/domain-driven-design later is a mechanical change in one directory instead of a rewrite across every aggregate.How can I help you explore Laravel packages today?