AGENTS.md Guide: Pragmatic Laravel DDD with Actions, Payloads & Invokables
A pragmatic, API-first take on Domain-Driven Design in Laravel — actions, payloads, invokable controllers, and Responsable responses. Inspired by Spatie Beyond CRUD, adapted for real-world projects.
This document defines how engineers (and AI coding agents) should design and implement business capabilities in a Laravel application.
It’s inspired by Spatie’s Laravel Beyond CRUD book, with explicit src/Domain/<DomainName>/ boundaries — but pared back from the full Spatie package stack. No laravel-data, no laravel-model-states, no laravel-view-models. Just clean folders, plain DTOs, and invokable actions.
Copy this into AGENTS.md at the project root and adapt as needed.
1. Purpose
A domain represents a coherent business capability — Subscriber, Order, Invoice, Automation. Each domain owns its actions, payloads, models, and enums, and exposes Actions as its public API.
Goal: the folder tree should read like the product owner’s vocabulary, and a new engineer should find any business operation in under 10 seconds.
2. Where Things Live
Domain code lives in src/Domain/<DomainName>/ — one folder per business capability. HTTP plumbing (controllers, requests, responses) stays in app/Http/, but everything that describes the business model — actions, payloads, models, enums — sits inside its domain.
Add the namespace to composer.json:
"autoload": {
"psr-4": {
"App\\": "app/",
"Domain\\": "src/Domain/"
}
}
Then run composer dump-autoload.
src/Domain/<DomainName>/
├── Actions/ ← business operations
├── Payloads/ ← typed DTOs (consumed by HTTP, Jobs, CLI)
├── Models/ ← Eloquent — data access only
├── Enums/ ← status, type, role values
├── Events/ ← domain events (optional)
└── Exceptions/ ← domain exceptions (optional)
app/Http/
├── Controllers/<Domain>/V1/ ← invokable, versioned
├── Requests/<Domain>/V1/ ← validation + payload()
└── Responses/ ← shared Responsable classes
| Layer | Folder | Responsibility |
|---|---|---|
| Domain | src/Domain/<Name>/Actions/ | One business operation per class |
| Domain | src/Domain/<Name>/Payloads/ | final readonly DTOs with a toArray() |
| Domain | src/Domain/<Name>/Models/ | Eloquent, casts, relationships — no business logic |
| Domain | src/Domain/<Name>/Enums/ | Status, type, role values |
| HTTP | app/Http/Controllers/<Name>/V1/ | Wire request → action → response. Nothing else. |
| HTTP | app/Http/Requests/<Name>/V1/ | Validation rules + a payload() method |
| HTTP | app/Http/Responses/ | Responsable classes (e.g. JsonDataResponse) |
Rule of thumb: if a file would still make sense in a queue worker or CLI command, it belongs in
src/Domain/. If it only exists to serve HTTP, it lives inapp/Http/.
3. Core Rules
1. Business-First Naming
Use the language the business uses. Rename technical defaults:
User → Customer
Job → Broadcast
Task → AutomationStep
2. Invokable Controllers
One controller per operation, one __invoke() method. Versioned under V1/, V2/ so you can ship breaking changes safely.
final class StoreTaskController
{
public function __invoke(StoreTaskRequest $request, CreateTaskAction $action): JsonDataResponse
{
$task = $action($request->payload());
return new JsonDataResponse($task);
}
}
Rule: Controllers have one job — wire the request to the action and shape the response. No business logic, no model writes.
3. Form Requests Carry the DTO
Validation belongs in FormRequest. The same class produces the typed payload — the controller never touches $request->all().
final class StoreTaskRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:200'],
'due_at' => ['nullable', 'date'],
'priority' => ['required', new Enum(Priority::class)],
];
}
public function payload(): StoreTaskPayload
{
return new StoreTaskPayload(
title: $this->validated('title'),
dueAt: $this->date('due_at'),
priority: Priority::from($this->validated('priority')),
);
}
}
4. Payloads (DTOs)
Skip spatie/laravel-data. A final readonly class with public properties is enough — you get strong typing, immutability, and zero runtime overhead.
final readonly class StoreTaskPayload
{
public function __construct(
public string $title,
public ?CarbonImmutable $dueAt,
public Priority $priority,
) {}
public function toArray(): array
{
return [
'title' => $this->title,
'due_at' => $this->dueAt,
'priority' => $this->priority->value,
];
}
}
Rule: All communication between layers happens through Payloads. Never pass
array $dataacross a boundary.
5. Actions
A single business operation per class. Invokable, dependency-injected, returns a model or value — not arrays.
final readonly class CreateTaskAction
{
public function __construct(
private RecordTaskCreatedAction $recordAuditTrail,
) {}
public function __invoke(StoreTaskPayload $payload): Task
{
return DB::transaction(function () use ($payload) {
$task = Task::create($payload->toArray());
($this->recordAuditTrail)($task);
return $task;
});
}
}
Action conventions:
final readonly class {Verb}{Domain}Action- Single
__invoke()(orhandle()in API contexts that need queue-job parity) - Wrap multi-write operations in
DB::transaction() - Compose with other actions via constructor injection — never
app()orresolve()inside the body - Guard preconditions early; throw domain exceptions, don’t return error tuples
Rule: One action = one user story. If the name doesn’t describe a thing a stakeholder might ask for, it’s the wrong shape.
6. Models Are Dumb
Models hold relationships, casts, and accessors — that’s it. No saving, no validation, no orchestration. If logic depends on more than one model or sends a notification, it belongs in an Action.
final class Task extends Model
{
protected $casts = [
'priority' => Priority::class,
'status' => TaskStatus::class,
'due_at' => 'immutable_datetime',
];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Prefer ULIDs over auto-increment IDs — they’re sortable, URL-safe, and don’t leak record counts.
7. Responses via Responsable
Skip ViewModels. For APIs, return Responsable classes that handle the JSON shape consistently.
final readonly class JsonDataResponse implements Responsable
{
public function __construct(
private mixed $data,
private int $status = 200,
) {}
public function toResponse($request): JsonResponse
{
return new JsonResponse(['data' => $this->data], $this->status);
}
}
Pair with JsonErrorResponse so error envelopes stay predictable. For HTML, return Blade views from the controller — they’re simple enough that an extra abstraction is pure cost.
8. State via Enums + Guards
Don’t reach for spatie/laravel-model-states. A backed enum and a guard in the relevant action handle 90% of cases:
enum TaskStatus: string
{
case Draft = 'draft';
case Active = 'active';
case Completed = 'completed';
public function canTransitionTo(self $next): bool
{
return match ($this) {
self::Draft => $next === self::Active,
self::Active => $next === self::Completed,
self::Completed => false,
};
}
}
final readonly class CompleteTaskAction
{
public function __invoke(Task $task): Task
{
throw_unless(
$task->status->canTransitionTo(TaskStatus::Completed),
new InvalidStateTransition("Task {$task->id} cannot be completed from {$task->status->value}"),
);
$task->update(['status' => TaskStatus::Completed]);
return $task;
}
}
If transitions get complex (side effects per transition, audit trails, parallel states), then reach for spatie/laravel-model-states — not before.
9. CQRS, Lightly
Strict CQRS is overkill for most CRUD apps. Split at the controller level instead:
IndexController,ShowController— readsStoreController,UpdateController,DestroyController— writes
Reads can hit the model/query builder directly. Writes always go through an Action.
Rule: A controller either reads or writes. Never both.
4. PHP Style
These aren’t nice-to-haves — turn them on at the linter level:
declare(strict_types=1);at the top of every filefinal readonly classby default (dropreadonlyonly when you genuinely need mutation)- Constructor property promotion, always
- Return types and parameter types on every method — no exceptions
- PSR-12 formatting
matchinstead of nested ternaries or chainedif/elseif- Never call
app(),resolve(),Container::make(), or facade roots inside a class — always inject - Models use ULIDs
5. Boundaries Between Domains
- Cross-domain calls go through Actions, not raw model relationships
- A domain may expose a few public Actions; everything else (private helpers, internal models) stays inside the namespace
- Avoid foreign keys across “important” boundaries (billing → CRM, for example) — bridge with IDs and an explicit lookup, so each side can move independently
- Domain Actions can call other domains’ Actions, but never
use Domain\Other\Models\...directly — go through the Action seam
6. Naming Reference
| Type | Pattern | Example |
|---|---|---|
| Action | {Verb}{Domain}Action | CreateTaskAction |
| Controller | {Verb}{Domain}Controller | StoreTaskController |
| Request | {Verb}{Domain}Request | StoreTaskRequest |
| Payload | {Verb}{Domain}Payload | StoreTaskPayload |
| Response | {Shape}Response | JsonDataResponse, JsonErrorResponse |
| Enum | {Concept}{Suffix} | TaskStatus, Priority |
| Exception | {Problem}Exception | InvalidStateTransition |
7. Example Domain Layout
src/Domain/Task/
├── Actions/
│ ├── CreateTaskAction.php
│ ├── CompleteTaskAction.php
│ └── RecordTaskCreatedAction.php
├── Payloads/
│ └── StoreTaskPayload.php
├── Models/
│ └── Task.php
├── Enums/
│ ├── TaskStatus.php
│ └── Priority.php
└── Exceptions/
└── InvalidStateTransition.php
app/Http/
├── Controllers/Task/V1/
│ ├── IndexTaskController.php
│ ├── ShowTaskController.php
│ └── StoreTaskController.php
├── Requests/Task/V1/
│ └── StoreTaskRequest.php
└── Responses/
├── JsonDataResponse.php
└── JsonErrorResponse.php
Namespaces:
Domain\Task\Actions\CreateTaskActionDomain\Task\Payloads\StoreTaskPayloadApp\Http\Controllers\Task\V1\StoreTaskController
8. Pre-Ship Checklist
- Every business operation lives in an Action
- Controllers do nothing but wire Request → Action → Response
- No
array $datacrossing a boundary — Payloads everywhere - Form Requests carry validation and the
payload()method - Models contain no business logic
-
declare(strict_types=1)on every file - Every class is
final(andreadonlywhere mutation isn’t required) - No
app()/resolve()calls inside classes — DI everywhere - State transitions are gated by enum guards or explicit Actions
- Tests target Actions and HTTP endpoints, not models