laravel-ddd maintained by ahmedsaed
Laravel DDD
Laravel DDD adds bounded-Context generators, architecture checks, and Context-aware database commands to a normal Laravel 11–13 application. It keeps Laravel’s container, events, queues, mail, routes, views, Eloquent, Pest, and PHPUnit as the integration APIs; it does not add wrapper frameworks around them.
Install
composer require ahmedsaed/laravel-ddd
New applications use project-root contexts/ and the Contexts namespace.
Add the namespace to the application’s composer.json:
{
"autoload": {
"psr-4": {
"App\\": "app/",
"Contexts\\": "contexts/"
}
}
}
Then rebuild the autoloader:
composer dump-autoload
The path and namespace remain configurable in config/laravel-ddd.php for
applications with an existing convention.
The generated Context
php artisan ddd:make-context Order
creates this case-sensitive structure:
contexts/Order/
├── Config/
│ └── config.php
├── Providers/
│ └── OrderServiceProvider.php
├── Domain/
│ ├── Aggregates/
│ ├── Entities/
│ ├── Events/
│ ├── Repositories/
│ └── ValueObjects/
├── Application/
│ ├── DTOs/
│ ├── Queries/
│ └── UseCases/
├── Infrastructure/
│ ├── Listeners/
│ ├── Jobs/
│ ├── Mail/
│ ├── Integrations/
│ ├── Persistence/
│ │ └── Eloquent/
│ │ ├── Models/
│ │ ├── Migrations/
│ │ ├── Seeders/
│ │ └── Factories/
│ └── Search/
├── Presentation/
│ ├── Console/
│ │ └── Commands/
│ └── Http/
│ ├── Controllers/
│ ├── Middleware/
│ ├── Requests/
│ ├── Resources/
│ └── Routes/
│ ├── api.php
│ └── web.php
├── resources/
│ ├── assets/
│ └── views/
└── tests/
├── Feature/
└── Unit/
Config and Providers are uppercase. resources and tests are lowercase.
This matters on Linux.
The command creates directories and framework bootstrap files, not fictional
business classes or frontend entry files. It also creates the conventional
empty OrderDatabaseSeeder.
Optional Aggregate and Vite setup
php artisan ddd:make-context Order --with-aggregate
php artisan ddd:make-context Order --with-vite
php artisan ddd:make-context Order --with-aggregate --with-vite
--with-aggregate adds only:
Domain/Aggregates/Order.php
Domain/Repositories/OrderRepository.php
Both classes are intentionally empty. --with-vite adds only
contexts/Order/vite.config.js. It uses the root application’s Node
dependencies, a Context-specific hot file, and a Context-specific build
directory. It does not create a Context package.json or pretend that
nonexistent JavaScript/CSS entry files exist. Add real entries to the
generated empty input array when the Context gains frontend assets.
--force repairs framework scaffolding and missing directories. It preserves
the entry seeder and optional Aggregate/Repository business files.
A Laravel-oriented DDD vocabulary
- An Aggregate protects a transactional business invariant.
- An Entity has identity and lifecycle inside a business model.
- A Value Object represents an immutable value such as money.
- A Domain Event records a business fact in
Domain/Events. - A Repository interface describes Aggregate persistence in Domain.
- A Use Case coordinates one application action in
Application/UseCases. - A DTO carries application input or output in
Application/DTOs. - A Laravel Listener, Job, or Mailable is an Infrastructure adapter.
- A Form Request, controller, middleware, or Resource is a Presentation adapter.
- The root Context Service Provider is the composition root.
Dependencies point inward:
Presentation → Application → Domain
Infrastructure → Application and Domain
Domain contains business rules and no Laravel/Symfony dependency, with one
deliberate exception: a class in Domain/Events may use
Illuminate\Foundation\Events\Dispatchable. This narrow compromise makes a
Domain Event feel native in Laravel without allowing facades, helpers,
Eloquent, queues, mail, HTTP, or the container into the rest of Domain.
Application owns DTOs, Queries, and Use Cases. It may directly dispatch an Aggregate’s recorded events after a successful save, but it does not use the database, Eloquent, cache, HTTP, mail, or queue adapters.
Infrastructure and Presentation are normal Laravel code. They may use the container, facades, and helpers appropriate to their boundaries.
Native Laravel events
Generate a Domain Event and a typed Listener:
php artisan ddd:make-event Order:OrderPlaced
php artisan ddd:make-listener Order:SendOrderReceipt \
--event='Contexts\Order\Domain\Events\OrderPlaced'
The event stub contains Laravel’s event Dispatchable trait, so all standard
styles work:
event(new OrderPlaced($orderId));
Event::dispatch(new OrderPlaced($orderId));
OrderPlaced::dispatch($orderId);
Listeners live directly in Infrastructure/Listeners. The package adds
contexts/*/Infrastructure/Listeners to Laravel’s supported event-discovery
paths. Laravel infers the event from the first argument:
final readonly class SendOrderReceipt
{
public function __construct(
private PrepareOrderReceipt $prepareReceipt,
) {}
public function handle(OrderPlaced $event): void
{
SendOrderReceiptJob::dispatch(
$this->prepareReceipt->execute($event),
);
}
}
There is no provider event map. Do not bind the Listener or the concrete Use Case: Laravel resolves both through constructor injection. Repository interfaces and other replaceable ports still need explicit bindings.
Laravel’s operational commands work with Context listeners:
php artisan event:list
php artisan event:cache
php artisan event:clear
When dispatch happens after persistence, record events in the Aggregate, save it, then release and dispatch them:
$this->orders->save($order);
foreach ($order->releaseEvents() as $event) {
event($event);
}
Production systems that need atomic database/event delivery should add an outbox rather than dispatch before the transaction commits.
Native Laravel helpers
Context classes do not need package wrappers. In Infrastructure and Presentation, normal Laravel calls work:
event($event);
Event::dispatch($event);
OrderPlaced::dispatch($orderId);
dispatch(new SendOrderReceiptJob($orderId));
SendOrderReceiptJob::dispatch($orderId);
dispatch_sync(new RebuildOrderReadModel($orderId));
config('order.notification_recipient');
view('order::orders.show', ['order' => $order]);
route('order.orders.show', $order);
app(PrepareOrderReceipt::class);
cache()->put('order:last', $order->id);
logger()->info('Order placed');
response()->json($payload);
now();
Jobs may use the sync queue connection in local and test environments. The
same dispatch APIs remain valid.
Context Service Provider
Providers/OrderServiceProvider.php:
- merges
Config/config.phpunder the snake-caseorderkey; - loads
Presentation/Http/Routes/api.phpandweb.php; - loads Context migrations;
- registers
resources/viewsas theorderview namespace.
For example:
view('order::orders.show');
The provider’s register() method is where repository interfaces and other
replaceable ports are bound:
$this->app->bind(
OrderRepository::class,
EloquentOrderRepository::class,
);
Providers are discovered from each direct child of contexts/. Explicit
registration in bootstrap/providers.php is also supported. The former
Infrastructure/Providers location remains a discovery-only compatibility
fallback; if both files exist, only the root provider is registered.
Component commands
All class commands use Context:ClassName, print their target, protect
existing files unless --force is supplied, and reuse the same configured
path/namespace system.
| Command | Generated location |
|---|---|
ddd:make-aggregate |
Domain/Aggregates |
ddd:make-entity |
Domain/Entities |
ddd:make-value-object |
Domain/ValueObjects |
ddd:make-event |
Domain/Events |
ddd:make-repository |
Domain/Repositories |
ddd:make-dto |
Application/DTOs |
ddd:make-use-case |
Application/UseCases |
ddd:make-model |
Infrastructure/Persistence/Eloquent/Models |
ddd:make-migration |
Infrastructure/Persistence/Eloquent/Migrations |
ddd:make-seeder |
Infrastructure/Persistence/Eloquent/Seeders |
ddd:make-factory |
Infrastructure/Persistence/Eloquent/Factories |
ddd:make-listener |
Infrastructure/Listeners |
ddd:make-job |
Infrastructure/Jobs |
ddd:make-mail |
Infrastructure/Mail |
ddd:make-controller |
Presentation/Http/Controllers |
ddd:make-request |
Presentation/Http/Requests |
ddd:make-resource |
Presentation/Http/Resources |
ddd:make-middleware |
Presentation/Http/Middleware |
ddd:make-provider |
Providers |
ddd:make-config |
Config/config.php |
Repository generation can add an Eloquent adapter with --eloquent and a
mapper with --mapper. Jobs are queued by default; --sync creates a
synchronous dispatchable Job. Mailables accept either --view or
--markdown. Listener --event values must be fully qualified, and
--queued applies Laravel’s queued-listener convention.
Migration inference is conservative:
create_orders_tableusesSchema::createand drops the table indown;- safely inferred add/change names use
Schema::table; - ambiguous names produce a valid neutral migration with no database work and no unused schema imports.
No generator invents fields, relationships, event properties, routes, or business methods.
Context databases
The package provides Context-aware migration and seeding commands:
php artisan ddd:migrate [Order]
php artisan ddd:migrate-status [Order]
php artisan ddd:migrate-rollback [Order]
php artisan ddd:migrate-reset [Order]
php artisan ddd:migrate-refresh [Order]
php artisan ddd:migrate-fresh [Order]
php artisan ddd:seed [Order]
Connection precedence is CLI option, Context config, then Laravel default.
Rollback/reset select only migration rows owned by the chosen Context.
ddd:migrate-fresh refuses a shared/default physical database and requires
database.dedicated=true. Standard php artisan migrate and
php artisan db:seed remain available.
See Context databases for the complete option and safety rules.
Context tests
The generator creates lowercase tests/Feature and tests/Unit directories.
PHPUnit only discovers directories declared in a test suite, so add the
Context root explicitly instead of rewriting phpunit.xml from a generator:
<testsuite name="Context tests">
<directory suffix="Test.php">contexts</directory>
</testsuite>
For Pest, apply the application Test Case only to Context Feature tests:
$contextFeatureTests = glob(
dirname(__DIR__).'/contexts/*/tests/Feature',
) ?: [];
uses(Tests\TestCase::class)->in(...$contextFeatureTests);
The example includes an additive phpunit.contexts.xml, Pest setup, and Unit
and Feature tests. This keeps application test configuration an explicit
developer choice and avoids fragile XML string editing.
Architecture checks
php artisan ddd:check
The checker reports namespace/layer violations, outward Domain dependencies, database/cache/HTTP/mail/queue dependencies in Application, direct cross-Context Aggregate/Entity imports, and obvious misplaced Laravel adapters. Strict mode fails; non-strict mode warns.
It tokenizes PHP namespaces and imports. It cannot prove every dynamic container lookup, helper call, string class name, or runtime dependency, so the documented policy still matters.
Example
examples/laravel-app contains a readable
Order/Catalog/Search application slice:
PlaceOrder Use Case
→ Order Aggregate records OrderPlaced
→ OrderRepository saves
→ event(...) dispatches
→ automatically discovered Laravel Listener
→ concrete notification preparation Use Case
→ Laravel Job
→ Mailable and Context view
Catalog owns SQL product writes. Search owns the eventually consistent product
read model. ProductCreated is handled by Search’s automatically discovered
Listener, which dispatches an indexing Job and delegates to the Search
Application layer.