harryes / laravel-ddd-kit
Scaffold a Domain-Driven Design architecture into Laravel applications via artisan commands.
Requires
- php: ^8.3
- illuminate/console: ^13.0
- illuminate/support: ^13.0
- laravel/prompts: ^0.3.24
- symfony/process: ^7.0|^8.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.4
- pestphp/pest-plugin-laravel: ^4.1
- phpstan/phpstan: ^2.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Scaffold a real Domain-Driven Design architecture into your Laravel application —
Domain / Application / Infrastructure layers, aggregate roots, value objects, and
domain events — via ddd:* artisan commands. Every generated stub enforces the
same discipline: no public setters, mandatory value objects, aggregate-only
repositories, and use cases as the one transaction boundary. ddd:doctor then
checks that discipline holds even after you've hand-edited the generated code.
Contents
Requirements
- PHP ^8.3
- Laravel ^13.0
Installation
composer require harryes/laravel-ddd-kit
The service provider is auto-discovered. Publish the config if you want to customize the base namespace, base path, or stub overrides:
php artisan vendor:publish --tag=ddd-config
Quick start
A full, wired module in nine commands:
php artisan ddd:domain Contact php artisan ddd:domain Billing php artisan ddd:entity Contact/Lead --aggregate php artisan ddd:value-object Contact/Email php artisan ddd:repository Contact/Lead php artisan ddd:usecase Contact/CreateLead php artisan ddd:event Contact/LeadWasCreated php artisan ddd:listener Billing/CreateInvoiceOnLeadWasCreated --event=Contact/LeadWasCreated php artisan ddd:query Contact/ListActiveLeads php artisan ddd:doctor
That's two modules — Contact and Billing — with a bound repository, a
transactional use case, a listener in Billing reacting to an event owned by
Contact (without importing anything from it), and a CQRS read query, all
with ddd:doctor reporting zero violations at the end. Every command after
the first two requires a domain that already exists (an entity needs its own
domain, a repository needs its aggregate, a listener needs both domains
involved); each section below says exactly what it needs and why. If you'd
rather explore interactively, jump to
ddd:domain's --interactive mode instead.
Commands
ddd:domain
Scaffolds a complete, self-contained DDD module: Domain/, Application/, and
Infrastructure/ layers, a routes.php, and a database/migrations/ directory.
See config/ddd.php for the exact folder shape.
php artisan ddd:domain Contact
INFO Domain [Contact] scaffolded at app/Domains/Contact.
INFO Registered App\Domains\Contact\Infrastructure\Providers\ContactServiceProvider in bootstrap/providers.php.
This creates:
app/Domains/Contact/
├── Domain/{Entities,ValueObjects,Events,Repositories,Exceptions}/
├── Application/{UseCases,DTOs,Queries}/
├── Infrastructure/
│ ├── Persistence/{Eloquent,Repositories}/
│ ├── Http/{Controllers,Requests}/
│ └── Providers/ContactServiceProvider.php
├── routes.php
└── database/migrations/
When ddd.auto_register_providers is enabled (the default), the generated
ContactServiceProvider is appended to bootstrap/providers.php automatically.
If that file doesn't exist, the command fails with a clear error instead of
registering nothing silently.
When ddd.auto_dump_autoload is enabled (the default), the command also
refreshes Composer's autoloader afterward. This only matters if your app uses
an optimized/classmap autoloader — plain PSR-4 autoloading already finds new
files without it — and it silently does nothing if there's no composer.json
or no composer binary on the PATH.
Interactive mode
Pass --interactive to immediately generate a first building block after the
module scaffold, without leaving the terminal:
php artisan ddd:domain Contact --interactive
You'll be asked which building blocks to add (aggregate root, value object,
use case, repository — multiple selection allowed) and then for each one's
name, exactly as if you'd run ddd:entity --aggregate, ddd:value-object,
ddd:usecase, and ddd:repository yourself afterward. Selecting nothing
just leaves you with the plain module scaffold.
ddd:entity
Generates an entity — or, with --aggregate, an aggregate root extending the
package's framework-agnostic AggregateRoot base class — inside an existing
domain's Domain/Entities directory. Requires the domain to already exist
(run ddd:domain first).
php artisan ddd:entity Contact/Lead --aggregate
INFO Generated Lead aggregate root at app/Domains/Contact/Domain/Entities/Lead.php.
final class Lead extends AggregateRoot { private function __construct( private readonly string $id, // TODO: add constructor-promoted properties for this aggregate's state. ) { // TODO: validate invariants here and throw a domain exception on violation. } public static function create(string $id /* , ...state */): self { $aggregate = new self($id); // TODO: record a domain event once you've generated one, e.g.: // $aggregate->record(new LeadWasCreated($id)); return $aggregate; } public static function reconstitute(string $id /* , ...state */): self { return new self($id); } // ... }
The constructor stays private — state is only ever set through create(),
reconstitute(), and named intent methods you add yourself. There are no
generated setters, and none should be added by hand.
ddd:value-object
Generates an immutable, equality-by-value object inside a domain's
Domain/ValueObjects directory. Requires the domain to already exist.
php artisan ddd:value-object Contact/Email
INFO Generated Email value object at app/Domains/Contact/Domain/ValueObjects/Email.php.
final readonly class Email { public function __construct( private string $value, ) { // TODO: validate $value here and throw a domain exception on violation. } public function value(): string { return $this->value; } public function equals(self $other): bool { return $this->value === $other->value; } public function __toString(): string { return $this->value; } }
Fill in the constructor's TODO with the concept's validation rules (format,
range, allowed values, ...) and throw a domain exception on violation.
ddd:usecase
Generates a use case and its matching DTO. The use case is the transaction
boundary — its body is always wrapped in DB::transaction(), and domain
events are dispatched only after that transaction commits, never from the
Domain layer itself.
php artisan ddd:usecase Contact/CreateLead
INFO Generated CreateLead use case at app/Domains/Contact/Application/UseCases/CreateLead.php.
INFO Generated CreateLeadData DTO at app/Domains/Contact/Application/DTOs/CreateLeadData.php.
final class CreateLead { public function __construct( // TODO: inject the domain repository interface(s) this use case needs. ) {} public function handle(CreateLeadData $data): void { DB::transaction(function () use ($data): void { // TODO: load or create the aggregate and invoke its intent method(s). // TODO: persist the aggregate via its repository interface. // Dispatch events recorded on the aggregate — never from the Domain layer. // foreach ($aggregate->pullDomainEvents() as $event) { // Event::dispatch($event); // } }); } }
final readonly class CreateLeadData { public function __construct( // TODO: add the primitive/value-object properties this use case needs. ) {} }
Companion Pest tests
ddd:entity, ddd:value-object, and ddd:usecase each also generate a
matching Pest test under tests/Unit/Domains/..., mirroring the source path.
These are plain unit tests with no framework bootstrap — no uses(TestCase::class).
Requires Pest in your app. A stock
laravel newapp ships PHPUnit only, so these generatedit(...)/expect(...)files won't run until you add Pest yourself:composer require pestphp/pest pestphp/pest-plugin-laravel --dev vendor/bin/pest --initAlready have Pest? Nothing to do — the files just work.
INFO Generated Lead aggregate root at app/Domains/Contact/Domain/Entities/Lead.php.
INFO Generated test at tests/Unit/Domains/Contact/Domain/Entities/LeadTest.php.
use App\Domains\Contact\Domain\Entities\Lead; it('creates a new Lead and exposes its id', function (): void { $aggregate = Lead::create('1'); expect($aggregate->id())->toBe('1'); }); it('reconstitutes a Lead from persisted state without recording new events', function (): void { $aggregate = Lead::reconstitute('1'); expect($aggregate->pullDomainEvents())->toBe([]); });
Use case tests are different: since a use case's dependencies are unknown at
generation time (no repository has necessarily been wired into its
constructor yet), the companion test is generated as a ->todo() with
instructions to replace it with an in-memory fake of the repository
interface(s) you inject — a real assertion isn't possible until then.
Disable this entirely with ddd.generate_tests => false, or redirect the
output location with ddd.tests_path.
ddd:repository
Generates a repository interface (Domain/Repositories/), a minimal Eloquent
model (Infrastructure/Persistence/Eloquent/), and the Eloquent
implementation (Infrastructure/Persistence/Repositories/) for an existing
aggregate — then binds the interface to the implementation in the domain's
ServiceProvider automatically. Requires the aggregate to already exist
(ddd:entity {domain}/{name} --aggregate); warns (without failing) if the
target entity doesn't extend AggregateRoot, since repositories should only
expose aggregate roots. Naming follows the aggregate: {Name}Repository for
the interface, {Name}Model for the Eloquent model, Eloquent{Name}Repository
for the implementation.
php artisan ddd:repository Contact/Lead
INFO Generated LeadRepository interface at app/Domains/Contact/Domain/Repositories/LeadRepository.php.
INFO Generated LeadModel at app/Domains/Contact/Infrastructure/Persistence/Eloquent/LeadModel.php.
INFO Generated EloquentLeadRepository at app/Domains/Contact/Infrastructure/Persistence/Repositories/EloquentLeadRepository.php.
INFO Bound App\Domains\Contact\Domain\Repositories\LeadRepository to App\Domains\Contact\Infrastructure\Persistence\Repositories\EloquentLeadRepository in ContactServiceProvider.
interface LeadRepository { public function find(string $id): ?Lead; public function save(Lead $lead): void; }
final class EloquentLeadRepository implements LeadRepository { public function find(string $id): ?Lead { $model = LeadModel::find($id); if ($model === null) { return null; } // TODO: map $model's attributes onto Lead::reconstitute(...). return Lead::reconstitute($model->getKey()); } public function save(Lead $lead): void { // TODO: map $lead's state onto a LeadModel and persist it. } }
ddd:event and ddd:listener
ddd:event generates a plain PHP domain event (Domain/Events/) — no
Laravel dependency, no framework event base class.
php artisan ddd:event Contact/LeadWasCreated
final readonly class LeadWasCreated { public function __construct( public string $aggregateId, public DateTimeImmutable $occurredAt = new DateTimeImmutable(), // TODO: add any other data this event's listeners need. ) {} }
ddd:listener generates a listener in the target domain's
Application/Listeners/ directory. Pass --event=domain/event to wire it to
a specific event — the domain can differ from the listener's own domain,
since reacting to another context's event (never importing its entities
directly) is exactly how bounded contexts are meant to communicate:
php artisan ddd:listener Billing/CreateInvoiceOnLeadWasCreated --event=Contact/LeadWasCreated
final class CreateInvoiceOnLeadWasCreated { public function handle(LeadWasCreated $event): void { // TODO: react to the event. Keep this a thin orchestration step — // delegate real work to a use case if it needs a transaction. } }
Omit --event to generate a generic stub with a handle(object $event)
placeholder instead. Either way, the command does not auto-register the
listener — wire it up yourself, e.g. in the owning domain's
ServiceProvider::boot():
Event::listen(LeadWasCreated::class, CreateInvoiceOnLeadWasCreated::class);
Note:
Application/Listeners/is not created byddd:domain— it's added on demand the first time a domain gets a listener.
ddd:query
Generates a CQRS-lite read query in a domain's Application/Queries/
directory. Queries are the one sanctioned shortcut around the domain layer —
they may hit Eloquent or the query builder directly for performance. Writes
never go through a query; they always go through a use case and its
aggregate.
php artisan ddd:query Contact/ListActiveLeads
INFO Generated ListActiveLeads query at app/Domains/Contact/Application/Queries/ListActiveLeads.php.
final readonly class ListActiveLeads { public function handle(): mixed { // TODO: query read models directly here — bypassing the domain layer // is fine for reads, but never write through this class. Writes // still go through a use case and its aggregate. return DB::table('table_name')->get(); } }
ddd:doctor
Statically scans app/Domains/** for violations of the non-negotiables that
generated code alone can't guarantee — useful after hand-editing generated
stubs, or as a CI gate. It exits non-zero when it finds violations, so it's
safe to run in a pipeline.
php artisan ddd:doctor
+---------------------------------------------+-----------------------------------------------------------+
| File:Line | Rule violated |
+---------------------------------------------+-----------------------------------------------------------+
| Contact/Domain/Entities/BadEntity.php:7 | Domain layer must not import Illuminate/Eloquent classes. |
| Contact/Domain/Entities/BadEntity.php:13 | Entities must not expose public setters. |
| Contact/Domain/Entities/BadEntity.php:11 | Entities must not expose public properties. |
| Contact/Application/UseCases/BadUseCase.php | Use cases must wrap writes in DB::transaction(). |
+---------------------------------------------+-----------------------------------------------------------+
It checks:
- No
use Illuminate\...imports anywhere under a domain'sDomain/layer. - No public setters (
setX()) or public properties on classes inDomain/Entities/. - Every class in
Application/UseCases/contains aDB::transaction(call. - No repository under
Infrastructure/.../Repositories/returns a type ending inModelfrom a public method.
Heuristic, not an AST parser. These checks are line-based regex scans, not a full PHP parser — deliberately avoiding a parser dependency at the cost of being fooled by unusual formatting. Treat findings as a strong signal, not a guarantee.
Configuration
| Key | Default | Description |
|---|---|---|
base_namespace |
App\Domains |
Root namespace for generated domain code. |
base_path |
app_path('Domains') |
Root directory for generated domain code. |
stubs_path |
null |
Override with a published stub path to customize generated file templates. |
auto_register_providers |
true |
Append generated {Domain}ServiceProvider classes to bootstrap/providers.php. |
providers_file |
null |
Override the target bootstrap/providers.php path (non-standard app structures). |
auto_dump_autoload |
true |
Refresh the Composer autoloader after ddd:domain scaffolds a module. |
generate_tests |
true |
Generate a companion Pest test alongside ddd:entity, ddd:value-object, and ddd:usecase stubs. |
tests_path |
null |
Override the root for generated companion tests. Null uses base_path('tests'). |
Testing
These run the package's own test suite — for testing code ddd:* generates
in your app, see Companion Pest tests.
composer test # Pest composer lint # Pint composer analyse # PHPStan, level max
License
MIT.