elber / laravel-clean-architecture
DDD + Clean Architecture scaffolding for Laravel
Package info
github.com/ElberCanoles/laravel-clean-architecture
pkg:composer/elber/laravel-clean-architecture
Requires
- php: ^8.2
- illuminate/console: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.8|^4.1|^5.0
- pestphp/pest-plugin-arch: ^3.1|^4.0|^5.0
- phpstan/phpstan: ^2.0
README
A Laravel package that provides scaffolding for Domain-Driven Design (DDD), Clean Architecture, and CQRS. It generates bounded contexts with separated read/write repositories, domain events, mappers, sanitizers, and architecture tests — enforcing clean dependency rules from day one.
Table of Contents
- Why this package
- Architecture Overview
- Installation
- Quick Start
- Commands Reference
- Configuration
- Auto-discovery and Autoloading
- Architecture Tests
- Customizing Stubs
- Requirements
- Upgrading
- Contributing
- Security
- License
Why this package
Module packages give you boundaries; this one also gives you an opinion about what goes inside them — and tests that enforce it.
| nwidart/laravel-modules · internachi/modular | laravel-clean-architecture | |
|---|---|---|
| Module boundaries | ✅ | ✅ Bounded contexts with auto-discovery |
| Internal structure | Unopinionated (MVC by default) | DDD layers + CQRS, generated wired end to end |
| Dependency rules | — | 9 generated Pest architecture tests per context, incl. "Application never imports Illuminate\*" |
| Read/write split | — | Write repositories (Domain) + read repositories with PaginatedResult (Application) |
| Domain events | — | Recorded in entities, dispatched on persist |
| composer.json edits | Required or plugin-based autoloading | None — PSR-4 registered automatically (cacheable via clean:cache) |
If you want generic modules with full freedom inside, those packages are excellent. If you want each module to be a clean architecture, this is the one.
Architecture Overview
Each bounded context is divided into four layers with strict dependency rules — the inner layers know nothing about the outer ones:
| Layer | Contains | May depend on |
|---|---|---|
| Domain | Entities, value objects, write-repository interfaces, specifications, events, exceptions | Nothing (pure PHP) |
| Application | Commands, queries, handlers, read models, read-repository contracts, sanitizers | Domain |
| Infrastructure | Eloquent models, repository implementations, mappers, the context ServiceProvider | Domain + Application |
| Presentation | Controllers, form requests, API resources, routes | Application |
The generated architecture tests enforce these rules on every CI run — including that the Application layer never imports Illuminate\*.
→ Read the full Architecture Guide for the C4 diagrams, layer-by-layer code walkthroughs, and the Dependency Rule in depth.
Installation
composer require elber/laravel-clean-architecture
The ServiceProvider is auto-discovered by Laravel. No manual registration needed.
The generated architecture tests run on Pest, so your application needs it as a dev dependency:
composer require --dev pestphp/pest pestphp/pest-plugin-arch
Optional publishing:
php artisan vendor:publish --tag=clean-architecture-config # config file php artisan vendor:publish --tag=clean-architecture-stubs # customizable stubs
Quick Start
1. Scaffold a context and an entity
php artisan clean:context Billing
php artisan clean:scaffold Billing Invoice # add --id-type=ulid for ULID keys
clean:scaffold generates 27 fully wired files across all four layers, plus the migration, and wires the ServiceProvider bindings and the resource route automatically:
src/Billing/
├── Domain/
│ ├── Entities/Invoice.php # factory methods + domain events
│ ├── Events/InvoiceCreatedEvent.php # recorded by create(), dispatched after commit
│ ├── Exceptions/InvoiceNotFound.php # renders as HTTP 404
│ └── Repositories/InvoiceWriteRepository.php # ofId() / save() / delete()
├── Application/
│ ├── Commands/
│ │ ├── CreateInvoice/ (Command + Handler) # id travels in the command
│ │ ├── UpdateInvoice/ (Command + Handler) # load → guard → save
│ │ └── DeleteInvoice/ (Command + Handler) # guards missing aggregates
│ ├── Queries/
│ │ ├── GetInvoice/ (Query + Handler) # nullable read model
│ │ └── ListInvoices/ (Query + Handler) # PaginatedResult
│ ├── Contracts/InvoiceReadRepository.php
│ ├── ReadModels/InvoiceReadModel.php
│ └── Sanitizers/InvoiceSanitizer.php
├── Infrastructure/
│ ├── BillingServiceProvider.php # bindings auto-wired
│ ├── Models/InvoiceModel.php # HasUuids / HasUlids
│ ├── InvoiceWriteEloquentRepository.php # dispatches domain events
│ ├── InvoiceReadEloquentRepository.php # deterministic pagination
│ ├── InMemoryInvoiceWriteRepository.php # test double — no DB needed
│ ├── InMemoryInvoiceReadRepository.php # test double — no DB needed
│ └── InvoiceMapper.php # Entity ↔ Model bridge
└── Presentation/
├── Controllers/InvoiceController.php # all 5 CQRS handlers wired
├── Requests/StoreInvoiceRequest.php
├── Requests/UpdateInvoiceRequest.php
├── Resources/InvoiceResource.php
└── Routes/api.php # apiResource auto-wired
database/migrations/2026_..._create_invoices_table.php
tests/Feature/Architecture/BillingArchTest.php # 9 dependency rules (from clean:context)
Prefer composing piece by piece? Every artifact has its own command (clean:entity, clean:repository, clean:command --crud=…, clean:controller --entity=…, …) — see the Commands Reference. Names are normalized for you: clean:entity billing invoice and clean:entity Billing Invoice are equivalent.
2. Fill in what the generator cannot know
The scaffold is wired end to end, but your domain fields are yours. Until you complete these five TODOs, POST persists only the generated id:
| File | What to complete |
|---|---|
Presentation/Requests/InvoiceRequest.php |
Validation rules() — validated() returns [] while empty |
Infrastructure/Models/InvoiceModel.php |
$fillable and casts for your columns |
Infrastructure/InvoiceMapper.php |
toArray() / toEntity() field mapping |
Domain/Entities/Invoice.php |
Your entity's properties and behavior |
Application/Commands/*/…Handler.php |
Apply $command->data where the TODO marks it |
The generated migration also starts with just id + timestamps — add your columns.
3. Serve it
php artisan migrate php artisan route:list --path=billing
curl -X POST http://localhost/api/billing/invoices \
-H "Content-Type: application/json" -H "Accept: application/json" -d '{}'
# → 201 {"id":"0198c5f2-…"} + Location: /api/billing/invoices/0198c5f2-…
curl http://localhost/api/billing/invoices -H "Accept: application/json"
# → 200 {"data":[{"id":"0198c5f2-…"}],"meta":{"total":1,"page":1,"per_page":15}}
curl -X DELETE http://localhost/api/billing/invoices/does-not-exist -H "Accept: application/json"
# → 404 {"message":"Invoice with id [does-not-exist] was not found."}
Route prefixes come from your app's routing setup: context routes register under the kebab-case context prefix (
/billing/...), inside whatever prefix yourbootstrap/app.phpgives theapimiddleware group.
Then run the generated architecture tests — they now guard your dependency rules on every CI run:
vendor/bin/pest tests/Feature/Architecture/
4. Unit test your handlers — no database needed
Every scaffold ships in-memory implementations of both repository ports, so your use cases are testable in microseconds. Generate a ready-made suite for them (and an HTTP feature test) with:
php artisan clean:test Billing Invoice --handler php artisan clean:test Billing Invoice --feature
Or write your own:
use Src\Billing\Application\Commands\CreateInvoice\{CreateInvoiceCommand, CreateInvoiceHandler}; use Src\Billing\Domain\Events\InvoiceCreatedEvent; use Src\Billing\Infrastructure\InMemoryInvoiceWriteRepository; test('creating an invoice persists it and records the creation event', function () { $repository = new InMemoryInvoiceWriteRepository; (new CreateInvoiceHandler($repository))->handle(new CreateInvoiceCommand('inv-1', [])); $invoice = $repository->ofId('inv-1'); expect($invoice)->not->toBeNull() ->and($invoice->releaseEvents()[0])->toBeInstanceOf(InvoiceCreatedEvent::class); });
Commands Reference
All commands support the --force flag to overwrite existing files (for the scaffold-generated migration, --force overwrites the existing migration file in place). Without --force, a command that finds an existing file warns and exits with code 1; invalid input (bad names, PHP reserved words, unknown option values) prints an error and exits with code 2.
Names must be PascalCase (Billing, Invoice) and must not be PHP reserved words.
| Command | Description | Output |
|---|---|---|
clean:context {name} [--routes=] |
Create bounded context with folders, ServiceProvider, routes, arch tests | Full folder structure |
clean:scaffold {context} {name} [--id-type=] [--table=] [--plural=] [--dry-run] |
Scaffold full CRUD entity across all layers (wires controller, SP bindings, routes) | 27 files |
clean:entity {context} {name} |
Domain entity with factory method and event recording | Domain/Entities/{Name}.php |
clean:model {context} {name} [--id-type=] [--table=] |
Eloquent model with HasUuids/HasUlids and auto-computed table name |
Infrastructure/Models/{Name}Model.php |
clean:repository {context} {name} |
CQRS repositories (Write + Read interfaces, Eloquent + in-memory impls, mapper) | 7 files |
clean:read-model {context} {name} |
Standalone readonly read model | Application/ReadModels/{Name}ReadModel.php |
clean:value-object {context} {name} |
Readonly value object with validation | Domain/ValueObjects/{Name}.php |
clean:specification {context} {name} |
Composable specification with and()/or()/not() |
Domain/Specifications/{Name}Specification.php |
clean:domain-event {context} {name} |
Readonly domain event with timestamp | Domain/Events/{Name}Event.php |
clean:exception {context} {name} |
Domain exception extending \DomainException |
Domain/Exceptions/{Name}Exception.php |
clean:command {context} {name} [--entity=] [--crud=] [--id-type=] |
CQRS command + handler (optionally injects WriteRepository with CRUD-specific logic) | Application/Commands/{Name}/ |
clean:query {context} {name} [--entity=] [--collection] |
CQRS query + handler (optionally injects ReadRepository) | Application/Queries/{Name}/ |
clean:mapper {context} {name} |
Entity-Model mapper | Infrastructure/{Name}Mapper.php |
clean:sanitizer {context} {name} |
Input sanitizer | Application/Sanitizers/{Name}Sanitizer.php |
clean:controller {context} {name} [--entity=] |
Controller with full CRUD dispatch pattern (optionally wires all 5 handlers) | Presentation/Controllers/{Name}Controller.php |
clean:request {context} {name} |
Store + Update form requests | Presentation/Requests/Store{Name}Request.php + Update{Name}Request.php |
clean:enum {context} {name} |
Backed enum in the Domain layer | Domain/Enums/{Name}.php |
clean:domain-service {context} {name} |
Pure domain service | Domain/Services/{Name}.php |
clean:policy {context} {name} |
Authorization policy | Presentation/Policies/{Name}Policy.php |
clean:listener {context} {name} [--event=] |
Event listener in the Application layer | Application/Listeners/{Name}Listener.php |
clean:job {context} {name} |
Queued job delegating to an Application handler | Infrastructure/Jobs/{Name}Job.php |
clean:factory {context} {name} |
Model factory | Infrastructure/Database/Factories/{Name}ModelFactory.php |
clean:seeder {context} {name} |
Database seeder using the factory | Infrastructure/Database/Seeders/{Name}Seeder.php |
clean:resource {context} {name} |
API resource with field mapping | Presentation/Resources/{Name}Resource.php |
clean:test {context} {name} [--handler|--feature] |
Pest test: entity unit test, use-case test against the in-memory repos, or HTTP feature test | tests/Unit/Domain|Unit/Application|Feature/{Context}/… |
clean:arch-test {context} |
Architecture dependency tests | tests/Feature/Architecture/{Context}ArchTest.php |
clean:wire {context} {name} |
Wire ServiceProvider bindings + resource route for a piecemeal entity | — |
clean:destroy {context} {name} [--with-migration] |
Delete everything the scaffold generated for an entity (asks for confirmation) | — |
clean:doctor |
Diagnose context discovery, wiring markers, and configuration | — |
clean:cache / clean:clear |
Cache / clear discovered contexts and PSR-4 mappings for faster boots | bootstrap/cache/clean-architecture.php |
The --entity flag
The clean:command, clean:query, and clean:controller commands accept an optional --entity flag that automatically wires the generated code:
# Handler will inject InvoiceWriteRepository php artisan clean:command Billing PayInvoice --entity=Invoice # Handler will inject InvoiceReadRepository php artisan clean:query Billing GetInvoice --entity=Invoice # Controller will inject all 5 handlers (create, update, delete, get, list) php artisan clean:controller Billing Invoice --entity=Invoice
Without --entity, the generated files get TODO placeholders instead. The clean:scaffold command passes --entity automatically.
The --crud flag
The clean:command command accepts a --crud flag that generates CRUD-specific command constructors and handler bodies:
# Constructor: string $id + array $data — Handler: Entity::create($command->id) + repository->save() php artisan clean:command Billing CreateInvoice --entity=Invoice --crud=create # Constructor: string $id + array $data — Handler: ofId() → throw NotFound → save() php artisan clean:command Billing UpdateInvoice --entity=Invoice --crud=update # Constructor: string $id — Handler: ofId() guard → repository->delete($command->id) php artisan clean:command Billing DeleteInvoice --entity=Invoice --crud=delete
--crud value |
Constructor | Handler body |
|---|---|---|
create |
public string $id, public array $data |
Creates entity via factory method + saves |
update |
public string $id, public array $data |
Loads via ofId(), throws {Entity}NotFound, saves |
delete |
public string $id |
Guards via ofId(), throws {Entity}NotFound, deletes |
| (none) | public string $id |
TODO placeholder |
updateanddeletealso generate aDomain/Exceptions/{Entity}NotFoundexception (if missing), which the package renders as an HTTP 404 JSON response out of the box.
The clean:scaffold command passes --crud automatically to each command (create, update, delete).
The --collection flag
The clean:query command accepts a --collection flag to generate a list/collection query with pagination parameters instead of a single-entity query:
# Generates query with $page/$perPage params and handler returning PaginatedResult
php artisan clean:query Billing ListInvoices --entity=Invoice --collection
The clean:scaffold command uses this flag automatically for the ListEntities query.
The --id-type flag
The package supports two identifier strategies for generated models, migrations, and controllers: uuid (default) and ulid. Accepted by clean:scaffold, clean:model, clean:controller, and clean:command.
# UUIDv7 keys — the default php artisan clean:scaffold Billing Invoice # ULID keys php artisan clean:scaffold Billing Invoice --id-type=ulid
--id-type |
Eloquent model | Migration | Controller store() |
|---|---|---|---|
uuid (default) |
use HasUuids; |
$table->uuid('id')->primary() |
$id = (string) Str::uuid7(); |
ulid |
use HasUlids; |
$table->ulid('id')->primary() |
$id = (string) Str::ulid(); |
The id is generated in the controller and travels in the create command, keeping the Application layer framework-free (and letting the arch tests enforce it).
Both are time-ordered, so either choice keeps primary keys index-friendly. ULIDs are shorter (26 chars vs 36) and are lexicographically sortable as strings.
To switch the default for the whole project, publish the config and set id_type:
// config/clean-architecture.php 'id_type' => 'ulid',
The flag takes precedence over the config value, and clean:scaffold resolves it once and forwards it to clean:model and clean:command, so every generated file for an entity shares the same strategy. Accepted by clean:scaffold, clean:model, and clean:command; any other value fails fast with an InvalidArgumentException.
Domain entities are unaffected — they take a plain string $id regardless of strategy, keeping the domain layer free of persistence concerns.
The --routes flag
The clean:context command accepts an optional --routes flag to control which route files are generated:
php artisan clean:context Billing # generates api.php (default) php artisan clean:context Billing --routes=web # generates web.php php artisan clean:context Billing --routes=both # generates api.php + web.php
The ServiceProvider loads both api.php and web.php automatically if they exist, applying the corresponding middleware.
Configuration
| Option | Default | Description |
|---|---|---|
contexts_path |
src |
Directory where bounded contexts live, relative to base_path() |
namespace_prefix |
Src |
Root namespace for contexts (Src\Billing, Src\Inventory, etc.) |
id_type |
uuid |
Identifier strategy for models, migrations and create handlers (uuid or ulid) |
auto_discover |
true |
Auto-register {Context}ServiceProvider from each context |
auto_load |
true |
Auto-register PSR-4 autoloading for all src/ contexts |
render_domain_exceptions |
true |
Render uncaught \DomainExceptions as JSON (422, or the status from ProvidesHttpStatus) on requests expecting JSON |
arch_tests_path |
tests/Feature/Architecture |
Where generated architecture tests are stored |
unit_tests_path |
tests/Unit/Domain |
Where generated domain unit tests are stored |
handler_tests_path |
tests/Unit/Application |
Where clean:test --handler tests are stored |
feature_tests_path |
tests/Feature |
Where clean:test --feature tests are stored |
stubs_path |
stubs/clean-architecture |
Where published (customized) stubs live |
Auto-discovery and Autoloading
ServiceProvider Auto-discovery
When auto_discover is enabled, the package scans each bounded context for a ServiceProvider at:
src/{Context}/Infrastructure/{Context}ServiceProvider.php
These providers are automatically registered with Laravel's service container. The clean:context command generates this ServiceProvider for you.
PSR-4 Autoloading
When auto_load is enabled, the package registers PSR-4 autoloading for every directory in your contexts path:
Src\Billing\ --> src/Billing/
Src\Inventory\ --> src/Inventory/
Src\Shipping\ --> src/Shipping/
No manual composer.json changes needed when you add new bounded contexts.
Architecture Tests
The clean:context and clean:arch-test commands generate Pest architecture tests that enforce DDD dependency rules automatically.
Generated tests for each context (9 rules):
| Test | What it enforces |
|---|---|
| Domain does not depend on Infrastructure | Domain layer has zero external dependencies (allows CleanArchitecture\Support) |
| Domain does not depend on Application | Domain never calls use cases or handlers |
| Application does not depend on Presentation | Use cases never reference controllers or requests |
| Application does not depend on Infrastructure | Use cases never reference Eloquent or providers |
| Application does not depend on the framework | Use cases never import Illuminate\* — the Dependency Rule, enforced |
| Code declares strict types | Every file in the context uses declare(strict_types=1) |
| Entities are final classes | Prevents inheritance that could break invariants |
| Repositories in Domain are interfaces | Domain defines contracts, never implementations |
| Value Objects are readonly | Guarantees immutability |
Note: The Domain layer is allowed to depend on
CleanArchitecture\Support(e.g.HasDomainEventsinterface). This is explicitly allowed in the architecture tests via->ignoring('CleanArchitecture\Support').
Run them with:
vendor/bin/pest tests/Feature/Architecture/
These tests integrate into your CI pipeline and fail the build if anyone introduces a dependency rule violation.
Customizing Stubs
Publish the stubs to your project:
php artisan vendor:publish --tag=clean-architecture-stubs
This copies all stubs to stubs/clean-architecture/. Edit them to match your team's conventions. The generators will use your custom stubs instead of the defaults.
Upgrading to 1.4: stubs published before ULID support hardcode the identifier and lack the
{{IdTrait}}/{{idType}}placeholders, so--id-typehas no effect on them. The generators warn when this happens; re-publish with--force(or add the placeholders by hand) to pick up the new behaviour.
Available stubs:
| Stub | Used by | Placeholders |
|---|---|---|
entity.stub |
clean:entity |
{{Namespace}}, {{Class}} |
model.stub |
clean:model |
{{Namespace}}, {{Class}}, {{table}}, {{IdTrait}} |
write-repository.stub |
clean:repository |
{{Namespace}}, {{Class}} |
read-repository.stub |
clean:repository |
{{Namespace}}, {{Class}} |
write-eloquent-repository.stub |
clean:repository |
{{Namespace}}, {{Class}} |
read-eloquent-repository.stub |
clean:repository |
{{Namespace}}, {{Class}} |
in-memory-write-repository.stub |
clean:repository |
{{Namespace}}, {{Class}} |
in-memory-read-repository.stub |
clean:repository |
{{Namespace}}, {{Class}} |
mapper.stub |
clean:repository, clean:mapper |
{{Namespace}}, {{Class}} |
value-object.stub |
clean:value-object |
{{Namespace}}, {{Class}} |
specification.stub |
clean:specification |
{{Namespace}}, {{Class}} |
read-model.stub |
clean:read-model |
{{Namespace}}, {{Class}} |
command.stub |
clean:command |
{{Namespace}}, {{Class}}, {{CommandConstructor}} |
command-handler.stub |
clean:command |
{{Namespace}}, {{Class}}, {{EntityImport}}, {{EntityConstructor}}, {{HandlerBody}} |
query.stub |
clean:query |
{{Namespace}}, {{Class}} |
query-handler.stub |
clean:query |
{{Namespace}}, {{Class}}, {{EntityImport}}, {{EntityConstructor}}, {{ReturnType}}, {{HandlerBody}} |
list-query.stub |
clean:query --collection |
{{Namespace}}, {{Class}} |
list-query-handler.stub |
clean:query --collection |
{{Namespace}}, {{Class}}, {{EntityImport}}, {{EntityConstructor}}, {{ReturnType}}, {{HandlerBody}} |
domain-event.stub |
clean:domain-event |
{{Namespace}}, {{Class}} |
domain-exception.stub |
clean:exception |
{{Namespace}}, {{Class}} |
not-found-exception.stub |
clean:command --crud=update|delete, clean:scaffold |
{{Namespace}}, {{Class}} |
sanitizer.stub |
clean:sanitizer |
{{Namespace}}, {{Class}} |
unit-test.stub |
clean:test |
{{Namespace}}, {{Class}} |
service-provider.stub |
clean:context |
{{Namespace}}, {{Context}}, // {bindings} / // {/bindings} markers |
routes.stub |
clean:context |
{{prefix}}, // {routes} / // {/routes} markers |
controller.stub |
clean:controller |
{{Namespace}}, {{Class}}, {{ControllerImports}}, {{ControllerConstructor}}, {{IndexBody}}, {{ShowBody}}, {{StoreBody}}, {{UpdateBody}}, {{DestroyBody}} |
store-request.stub / update-request.stub |
clean:request |
{{Namespace}}, {{Class}} |
enum.stub |
clean:enum |
{{Namespace}}, {{Class}} |
policy.stub |
clean:policy |
{{Namespace}}, {{Class}} |
factory.stub / seeder.stub |
clean:factory / clean:seeder |
{{Namespace}}, {{Class}} |
listener.stub |
clean:listener |
{{Namespace}}, {{Class}}, {{EventType}} |
job.stub |
clean:job |
{{Namespace}}, {{Class}} |
domain-service.stub |
clean:domain-service |
{{Namespace}}, {{Class}} |
handler-test.stub / feature-test.stub |
clean:test --handler / --feature |
{{Namespace}}, {{Class}}, {{routeBase}} |
resource.stub |
clean:resource |
{{Namespace}}, {{Class}} |
migration.stub |
clean:scaffold |
{{table}}, {{idType}} |
arch-test.stub |
clean:arch-test |
{{Namespace}}, {{Context}} |
Requirements
- PHP 8.2+
- Laravel 12.0+ or 13.0+
Laravel 11 support was removed in v2.0 — it is end of life upstream and carries unpatched security advisories. See UPGRADING.md.
Dev dependencies (for architecture tests)
- Pest ^3.0
- Pest Architecture Plugin ^3.0
Upgrading
Breaking changes between versions are documented in UPGRADING.md. The full history lives in the CHANGELOG.
Contributing
Contributions are welcome — see CONTRIBUTING.md for the setup, the quality bar (composer test / analyse / lint), and how the stub and generator tests work. This project follows the Contributor Covenant Code of Conduct. Questions and ideas belong in Discussions.
Security
Please report vulnerabilities privately via GitHub Security Advisories — see SECURITY.md. Do not open public issues for security problems.
License
This package is open-sourced software licensed under the MIT License.