Laravel DDD layout
Read when: writing or reviewing PHP in a Laravel app that splits code into src/Domain/{Domain}/ and
src/Presentation/{Panel}/, or setting up that layout in a new app.
Owns: the layer rules, the folder map, one request end to end, and the review checklist. Each layer's
detail lives in its own reference (table at the end).
Written for PHP 8.4 and Laravel 12 (checked 2026-11-03). Where generic Laravel guidance disagrees on the topics below, this skill wins.
Consistency first
These rules are defaults for new code. Before applying one, open two neighbouring files in the same domain and match them: a working local pattern beats a default here. Do not retrofit old code as a side effect of an unrelated change; a rename across many files is its own task.
TL;DR
- Dependencies point inward through Contracts. Controller → Contract → Service → Action → Model. Why: each domain has one published door, so its internals can change without breaking callers.
- Across domains, only the Contract. Never import another domain's Model, Action or Service; resolve
app(OtherContract::class). Inside a domain, import the Action directly. Why: a cross-domain import couples two domains for good. - Actions are
finalwith onepublic static function handle()and no constructor. Dependencies are resolved insidehandle()withapp(). Why: callable from a controller, job, command or test with no wiring. - Services contain zero queries. Each method is one delegation to an Action. Why: the query lives in one reusable, testable place.
- Multi-statement writes run in
DB::transaction()inside the Action; a once-only change claims its row first. Why: no partial writes, no double runs. - DTOs end in
Data, havepublic readonlysnake_caseproperties, and are built withfromArray()orfromRequest(). They remember which keys were sent, so an update writes only those. Why: keys match columns, and a form can clear a column tonullwithout wiping the ones it did not send. - Controllers are thin and inject Contracts only. Form Request → DTO → Contract → response. No Eloquent, no business rules, no Action calls. Why: the same operation must work from a job or the CLI.
- No route-model binding. Take
string $id, fetch through the Contract, 404 onnull. Why: binding queries past the Contract and skips its scopes and tenant filter. - Validation lives in Form Requests, never in an Action. Why: by the time a DTO reaches the domain, the data is trusted.
- The domain never reads
auth(),request()orsession(). It asks aCurrentUsercontract. Why: jobs and commands have no request. - Enum cases are
UPPER_SNAKE_CASE; statuses change only through the transition Action. Why: one style to grep, and the guard, side effects and audit run every time. - Migrations: anonymous classes, no index "just in case", no
->index()on a foreign key, no custom index names. Why: speculative indexes slow writes; custom names drift between environments. - Domain and Presentation files are created by hand (or cloned from a neighbour). The
make:*generators write intoapp/.
The layers in one picture
HTTP ─► FormRequest ─► Controller ─► {Domain}Contract ─► {Domain}Service ─► Action ─► Model
(validates) (thin) (interface) (delegates) (queries, (casts,
writes, relations,
transaction) scopes)
Other domain ─────────────────────► app({Domain}Contract::class)
Job / Command / Listener ─────────► app({Domain}Contract::class)
Folder map
src/Domain/{Domain}/ src/Presentation/{Panel}/
├── Actions/ may group by entity ├── Controllers/{Feature}/
├── Commands/ ├── Requests/{Feature}/
├── Contracts/ ├── Resources/ JSON and page shapes
├── DataTransferObjects/ may nest ├── Props/ one class per Inertia page
├── Enums/ ├── Routes/{web,api}.php
├── Events/ only if published ├── Providers/
├── Exceptions/ └── Rules/
├── Jobs/
├── Listeners/ only if it reacts
├── Models/
│ ├── Entities/ Eloquent models
│ ├── Scopes/ {Entity}Filter traits, global scopes
│ ├── Abilities/ {Entity}Abilities
│ ├── Features/ feature-flag wrappers (optional)
│ └── Traits/
├── Observers/
├── Providers/ {Domain}ServiceProvider ($bindings, events)
└── Services/
app/ framework glue only: middleware, providers, adapters
A shared Core domain holds the kernel every domain uses: the Arrayable DTO base, FilterData,
GetDataAbstract, query macros, BaseEnum, ModelAbility, BaseException, CurrentUser. Detail:
architecture.md, core-kernel.md.
One request, end to end
// Presentation\Admin\Controllers\Employee\EmployeeController
public function __construct(private readonly EmployeeContract $employees) {}
public function store(StoreEmployeeRequest $request): RedirectResponse
{
$employee = $this->employees->create(CreateEmployeeData::fromRequest($request));
return to_route('admin.employees.show', $employee->id)->with('success', __('messages.saved'));
}
// Domain\Employee\DataTransferObjects\CreateEmployeeData
public static function fromRequest(StoreEmployeeRequest $request): self
{
return self::fromArray([
...$request->validated(),
'contacts' => EmployeeContactData::arrayFrom($request->validated('contacts')),
]);
}
// Domain\Employee\Services\EmployeeService
public function create(CreateEmployeeData $data): Employee
{
return CreateEmployee::handle($data);
}
// Domain\Employee\Actions\Employee\CreateEmployee
final class CreateEmployee
{
public static function handle(CreateEmployeeData $data): Employee
{
return DB::transaction(function () use ($data) {
$user = app(UserContract::class)->getUser($data->user_id);
$employee = Employee::create([...(clone $data)->exclude(['contacts'])->toArray(), 'user_id' => $user->id]);
$employee->contacts()->createMany(array_map(fn (EmployeeContactData $contact) => $contact->toArray(), $data->contacts));
return $employee;
});
}
}
Choices to make once per project
These are real alternatives, not drift. Pick one, write it in the project's README, and do not mix them.
| Topic | Choose between | Detail |
|---|---|---|
| What panels return | Inertia or Blade pages with redirects, a JSON API with the envelope, or both | http.md, json-api.md |
| Strict DTO input | Lenient fromArray() (default) or strict, throwing on an unknown key |
dtos.md |
| Feature flags | Per-domain Models/Features/ wrappers or Pennant called directly |
core-kernel.md |
| Tenancy | A single tenant, or every row scoped by a tenant resolved in middleware | architecture.md |
| Enforcement | Pest arch() tests, PHPStan rules, or both |
testing.md |
Where each topic lives
| I'm working on… | Read |
|---|---|
| Placing a file, adding a domain, boundaries, events, jobs, commands, observers | architecture.md |
| The shared kernel a new project needs | core-kernel.md |
| An Action: shape, naming, transactions, concurrency, templates | actions.md |
| A Contract, its Service, the provider binding | contracts-and-services.md |
| A Create, Update, Filter, nested or options DTO | dtos.md |
| An Entity: casts, attributes, scopes, abilities, traits, translated names | models.md |
| An enum, a status and its transitions, once-only work, request idempotency | enums-and-states.md |
| A filter scope, eager loading, counting, chunking, raw SQL | queries.md |
| A controller, Form Request, page response, redirect, authorization | http.md |
| The JSON envelope, status codes, Resources, API CRUD | json-api.md |
| A migration or seeded reference rows | migrations.md |
| A test, or an architecture test that enforces the layers | testing.md |
Review checklist
- Every new file sits in the folder the map gives it; no invented layer (
Handlers/,Resolvers/). - No cross-domain import of a Model, Action or Service.
- Actions are
final, static, constructor-free; writes touching more than one row use a transaction. - Services are one-line delegations;
$withRelations/$withCountare forwarded. - DTOs end in
Data, aresnake_case, come fromvalidated()on writes, and updates write sent keys only. - Controllers inject Contracts only, hold no Eloquent, take
string $id, and redirect or return 201/204 after a write. - No
auth(),request()orsession()undersrc/Domain. - Migrations follow the index policy and have a real
down(). - Each comment you added says a WHY someone would get wrong without it; delete the rest.
- A test covers the Action (logic) or the endpoint (routing, validation, response), including a failure path.