Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ddd Laravel Package

geekcell/ddd

A Laravel package that brings Domain-Driven Design building blocks to PHP applications, providing ready-made abstractions for entities, value objects, aggregates, repositories, and domain events so business logic stays clean and framework-independent.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and verify the surface first.

composer require geekcell/ddd

The package auto-registers its service provider through Laravel's package discovery, so there is nothing to add to config/app.php. Before writing any domain code, open vendor/geekcell/ddd/src and read the base classes directly — that is your real documentation. You are looking for four things in that order:

  1. Entity — how identity is modeled (id type, equality semantics).
  2. AggregateRoot — how domain events are recorded and released.
  3. ValueObject — what immutability and equality helpers it gives you, and what it expects from __construct.
  4. Repository contract and how the service provider binds implementations.

First use case — model one aggregate with one invariant. Pick the smallest real thing in your app that has a business rule (e.g. a Subscription that cannot be cancelled while a payment is pending). Build it as:

final class Subscription extends AggregateRoot
{
    public function cancel(): void
    {
        if ($this->hasPendingPayment) {
            throw new DomainException('Cannot cancel with pending payment.');
        }
        $this->cancelledAt = new \DateTimeImmutable();
        $this->recordThat(new SubscriptionCancelled($this->id));
    }
}

Add a SubscriptionId value object and a SubscriptionRepository interface. Wire the Eloquent adapter in a DomainServiceProvider bound to the interface. Persist through a small application service (not a controller) that calls $repo->save($subscription) and then releases events. That single vertical slice — aggregate + value object + repository + one event — is the entire package in miniature; everything else is repetition of this shape.

Do not attempt to retrofit existing models yet. Prove the slice in a sandbox folder like app/Domain/Subscriptions/ and only then decide whether to widen it.

Implementation Patterns

Bounded-context-first folder layout. Put the package's primitives behind a per-context namespace so nothing couples to the package globally:

app/Domain/Billing/
  Model/           # Aggregates, Entities, ValueObjects (package base classes)
  Repository/      # Interfaces extending GeekCell\Ddd\Repository
  Event/           # Domain events
  Service/         # Application services that orchestrate
app/Infrastructure/Billing/
  Eloquent/        # Models + repository adapters
  Providers/       # Interface bindings

The domain folder imports nothing from App\Models and nothing from Illuminate\*. That is the rule the package exists to make cheap to enforce.

Value objects for every ID and quantity. Replace int $id, string $status, float $amount with typed wrappers. The equality helper is what makes $order->id()->equals($other->id()) safe and prevents the classic 1 == '1' bug when comparing across HTTP and DB boundaries.

final class OrderId extends ValueObject
{
    public function __construct(public readonly string $value) {}
}

Aggregate roots record; application services release. Do not call releaseEvents() (or the package's equivalent) inside the aggregate. The pattern is:

// Application service
$order->place();                 // records OrderPlaced
$this->orders->save($order);    // persistence must succeed first
foreach ($order->releaseEvents() as $event) {
    event($event);              // hand to Laravel's bus
}

Keeping recording and release separate is what gives you post-persistence dispatch — the whole point of the package's event mechanism. If you dispatch before save(), a failed transaction leaves subscribers reacting to a state that never existed.

Repository interface + thin Eloquent adapter. The adapter's only job is hydration and persistence; no business logic, no invariants, no event dispatch. Enforce this by keeping the adapter in Infrastructure/, not Domain/. If your save() method starts containing if-branches on business state, you have leaked aggregate logic into infrastructure.

Events as plain read-only objects. Use small final classes (SubscriptionCancelled with the id and a timestamp). Subscribers live outside the domain and are registered as normal Laravel listeners. Because events are dispatched after persistence, listeners can safely assume the aggregate is committed — that is the contract that makes side effects (emails, projections, outbox writes) consistent.

Tests follow given/when/then at the aggregate boundary. For each aggregate, write:

  • Given an aggregate built in some state,
  • When a command method is called,
  • Then assert either the thrown domain exception or the recorded events.

You can assert on recorded events without touching a DB — that is the fastest feedback loop you will get out of this package and the one most worth standardizing.

Enforce the layering in CI. Pair this package with architecture tests (Pest arch, Deptrac, or PHPArkitect) that fail the build if App\Domain\* depends on Illuminate\Database\* or on App\Infrastructure\*. The base classes give you consistent primitives; the arch tests are what turn "guard rails" from a convention into a gate.

Gotchas and Tips

Zero public adoption — treat it as a fork. Zero stars, zero dependents, no issue tracker to learn from. Pin the exact version in composer.json, mirror the repo internally, and note the license before the first sprint. Assume you will own patches yourself and budget one to two weeks per year for that. If nobody will own the fork, do not adopt.

Eloquent will bleed into your aggregates if you let it. Eloquent's active-record model directly contradicts the persistence-ignorance the package assumes. The single most common failure is an aggregate slowly acquiring HasMany relationships, $casts, and ->save() calls because it was convenient under deadline. Keep a hard rule: aggregates are plain PHP; only adapters touch Eloquent. Verify by grepping App\Domain for Illuminate in CI.

Release events once, on success. If save() throws, do not release — the aggregate's recorded events should be discarded with the transaction. Conversely, guard against double-release: hold released events in a local variable and clear the buffer, or wrap release-and-dispatch in a helper method on your application service so no controller can call it twice.

Do not make every entity an aggregate root. An aggregate root is a transactional consistency boundary, not a synonym for "class I like." Extending AggregateRoot for child entities yields nested event recording and confusing release semantics. Only the root records events; children are plain Entity instances reachable from the root.

Value object equality is structural, not referential. Two Money instances with the same amount and currency should be equals(), but PHP == will be wrong if you forget to use the helper. Make equals() the only way you compare value objects in your codebase, and let static analysis catch stray == if you can.

Hydration from the DB must not fire domain events. When your repository adapter reconstitutes an aggregate from a row, it must not go through command methods — those record events. Rehydrate via constructor or a dedicated factory that bypasses the recording path. If you have to, follow the package's documented reconstitution path rather than calling place() on load.

Transaction boundaries belong to the application service. DB::transaction(fn () => $this->orders->save($order)); followed by dispatch outside the closure is the safe shape. If you dispatch inside the transaction, a failed commit leaves listeners having acted on phantom state; if you dispatch outside and the commit fails, you never get there — which is the correct behavior.

Value object readonly + PHP version. The package's immutability helpers rely on readonly properties (PHP 8.1+). Verify the supported PHP/Laravel matrix in composer.json against your app's minimum before committing — a mismatch here is the most likely source of a future upgrade conflict if upstream goes dormant.

Test aggregates without the DB. The recording mechanism is what makes this pleasant: build an aggregate, invoke a command, assert on releaseEvents(). Save the DB for repository integration tests. If your aggregate tests need a database, you have coupled the aggregate to persistence and should refactor before writing more.

Extension points are the base classes themselves. If a base class almost fits but not quite, subclass it in your own namespace (App\Domain\Shared\Aggregate) so upgrades to the package do not touch every aggregate in the codebase. This one indirection is what makes the fork survivable.

Enforcement decays without CI. If the "domain imports nothing from Laravel" rule lives only in a wiki page, it will be violated within two quarters, usually under a deadline. Wire the architecture tests into PR checks on day one — this, more than the base classes, is the value you are buying.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console