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
LayerFolderResponsibility
Domainsrc/Domain/<Name>/Actions/One business operation per class
Domainsrc/Domain/<Name>/Payloads/final readonly DTOs with a toArray()
Domainsrc/Domain/<Name>/Models/Eloquent, casts, relationships — no business logic
Domainsrc/Domain/<Name>/Enums/Status, type, role values
HTTPapp/Http/Controllers/<Name>/V1/Wire request → action → response. Nothing else.
HTTPapp/Http/Requests/<Name>/V1/Validation rules + a payload() method
HTTPapp/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 in app/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 $data across 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() (or handle() in API contexts that need queue-job parity)
  • Wrap multi-write operations in DB::transaction()
  • Compose with other actions via constructor injection — never app() or resolve() 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 — reads
  • StoreController, 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 file
  • final readonly class by default (drop readonly only when you genuinely need mutation)
  • Constructor property promotion, always
  • Return types and parameter types on every method — no exceptions
  • PSR-12 formatting
  • match instead of nested ternaries or chained if/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

TypePatternExample
Action{Verb}{Domain}ActionCreateTaskAction
Controller{Verb}{Domain}ControllerStoreTaskController
Request{Verb}{Domain}RequestStoreTaskRequest
Payload{Verb}{Domain}PayloadStoreTaskPayload
Response{Shape}ResponseJsonDataResponse, JsonErrorResponse
Enum{Concept}{Suffix}TaskStatus, Priority
Exception{Problem}ExceptionInvalidStateTransition

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\CreateTaskAction
  • Domain\Task\Payloads\StoreTaskPayload
  • App\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 $data crossing 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 (and readonly where 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