Skip to content

ADR-0012: FormRequest → DTO Flow ​

Accepted Cross-Project

Date: 2026-03-08 · Amended: 2026-11-06 (nested DTOs, see Amendment 1)

Context ​

Laravel Actions in these projects accept typed parameters — not raw arrays. The question is how validated HTTP input gets from a FormRequest to an Action in a type-safe way.

Without a DTO layer, two patterns emerge:

  1. Pass validated array — $action->execute($request->validated()) — loses type safety, Actions receive array<string, mixed>
  2. Pass the FormRequest itself — $action->execute($request) — couples Actions to the HTTP layer, violating their HTTP-agnostic design (see Action Class Architecture)

The Problem for AI Agents ​

AI agents generating Laravel code will default to $request->validated() arrays or pass the entire $request to service classes. Both violate this project's architecture: Actions must not depend on HTTP classes, and they must receive typed data.

Decision ​

FormRequests define a toDto() method that converts validated input into a typed, immutable Data Transfer Object. Controllers call $request->toDto() and pass the result to the Action.

The Flow ​

HTTP Request
  → FormRequest (validates + authorizes)
    → toDto() (converts to typed DTO)
      → Controller passes DTO to Action
        → Action accesses typed properties: $data->title, $data->laneId

DTO Structure ​

DTOs are final readonly classes with promoted constructor properties:

php
declare(strict_types=1);

final readonly class SaveIssueData
{
    public function __construct(
        public string $title,
        public string $description,
        public int $laneId,
        public int $order,
        public PriorityEnum $priority,
        public TypeEnum $type,
        public ?int $assigneeId = null,
        /** @var list<int>|null */
        public ?array $blockedByIds = null,
        /** @var list<int>|null */
        public ?array $blocksIds = null,
        public ?int $sprintId = null,
        public ?string $prompt = null,
    ) {}
}

DTO rules (enforced by architecture tests):

RuleEnforcement
final readonly classtoBeFinal()->toBeReadonly()
Suffix DatatoHaveSuffix('Data')
declare(strict_types=1)toUseStrictTypes()
No base class, no interfacestoExtendNothing()->toImplementNothing()
No public methods besides __constructReflection test

toDto() Method ​

FormRequests convert validated input to DTOs using $this->safe():

php
final class SaveIssueRequest extends FormRequest
{
    use ExtractsIntArray;

    public function rules(): array
    {
        $project = $this->route('project');
        $projectId = $project instanceof Project ? $project->id : null;

        return [
            'title' => ['required', 'string', 'max:500'],
            'lane_id' => ['required', 'integer', Rule::exists('lanes', 'id')->where('project_id', $projectId)],
            'priority' => ['required', Rule::enum(PriorityEnum::class)],
            'type' => ['required', Rule::enum(TypeEnum::class)],
            'sprint_id' => ['integer', Rule::exists('sprints', 'id')->where('project_id', $projectId), 'nullable'],
            // ...
        ];
    }

    public function toDto(): SaveIssueData
    {
        $safe = $this->safe();

        $assigneeId = $safe->integer('assignee_id');
        $sprintId = $safe->integer('sprint_id');

        return new SaveIssueData(
            title: $safe->string('title')->toString(),
            description: $safe->string('description')->toString(),
            laneId: $safe->integer('lane_id'),
            order: $safe->integer('order'),
            priority: PriorityEnum::from($safe->integer('priority')),
            type: TypeEnum::from($safe->integer('type')),
            assigneeId: $assigneeId !== 0 ? $assigneeId : null,
            blockedByIds: $this->extractNullableIntArray('blocked_by_ids'),
            blocksIds: $this->extractNullableIntArray('blocks_ids'),
            sprintId: $sprintId !== 0 ? $sprintId : null,
            prompt: $safe->string('prompt')->toString() !== '' ? $safe->string('prompt')->toString() : null,
        );
    }
}

toDto() conventions:

  • Use $this->safe() — never $this->validated() or $this->all()
  • Cast with ->string('key')->toString() and ->integer('key') for type safety
  • Convert enums: PriorityEnum::from($safe->integer('priority'))
  • Handle nullable: $value !== 0 ? $value : null
  • Use ExtractsIntArray trait for list<int> arrays

toDto() with Additional Parameters ​

Some DTOs need data that doesn't come from the request body (route bindings, current user). The controller passes these to toDto():

php
// FormRequest
public function toDto(int $userId, int $issueId): CreateCommentData
{
    $safe = $this->safe();
    return new CreateCommentData(
        content: $safe->string('content')->toString(),
        userId: $userId,
        issueId: $issueId,
    );
}

// Controller
$comment = $action->execute($request->toDto($user->id, $issue->id));

toDtos() for Array Payloads ​

When the request body is an array of items (e.g., board position updates), use toDtos():

php
/**
 * @return list<IssuePositionData>
 */
public function toDtos(): array
{
    /** @var list<array{id: int, order: int, lane_id: int, sprint_id: int|null, epic_id: int|null}> $validated */
    $validated = $this->validated();

    return array_map(
        static fn (array $issue): IssuePositionData => new IssuePositionData(
            id: $issue['id'],
            order: $issue['order'],
            laneId: $issue['lane_id'],
            sprintId: $issue['sprint_id'] ?? null,
            epicId: $issue['epic_id'] ?? null,
        ),
        $validated,
    );
}

The same shape applies one level down, to a list inside a DTO: see Amendment 1.

Controller Wiring ​

The controller connects all pieces without containing logic:

php
public function store(
    SaveIssueRequest $request,
    Project $project,
    #[CurrentUser] User $user,
    CreateIssueAction $action,
): JsonResponse {
    $issue = $action->execute($project, $user, $request->toDto(), RequestContext::fromRequest($request));
    return new IssueResource($issue->load(IssueResource::EAGER_LOAD))->response()->setStatusCode(201);
}

Cross-Entity Scoping in Validation ​

FormRequests must scope Rule::exists() to the parent entity. This prevents a user in one project from referencing lanes/sprints/issues from another project:

php
// WRONG — no project scoping, allows cross-project references
'lane_id' => ['required', 'exists:lanes,id'],

// RIGHT — scoped to current project
$projectId = $project instanceof Project ? $project->id : null;
'lane_id' => ['required', 'integer', Rule::exists('lanes', 'id')->where('project_id', $projectId)],

Enforced by architecture test: any Rule::exists() referencing project-owned tables must include a .where('project_id', ...) scope.

FormRequest Architecture Tests ​

RuleEnforcement
final classtoBeFinal()
declare(strict_types=1)toUseStrictTypes()
Must define rules()toHaveMethod('rules')
Must define toDto() or toDtos()Reflection test (2 exemptions)
Scoped Rule::exists() for project-owned tablesPattern-matching test
Use ExtractsIntArray trait for int arraysNo inline array_map(...(int)...)

Options Considered ​

OptionVerdictReason
Pass validated arrays ($request->validated())RejectedActions receive array<string, mixed> — no type safety, no IDE autocomplete, PHPStan cannot verify property access
Pass FormRequest to ActionRejectedCouples Actions to HTTP layer. Actions must be callable from jobs, commands, MCP tools where no FormRequest exists
spatie/laravel-data packageConsidered, not chosenAdds magic (automatic hydration, casting). The toDto() method is explicit — you see exactly how each field is extracted. Consistent with Explicit Over Implicit
FormRequest toDto() → typed DTO → ActionAcceptedExplicit, type-safe, no magic. Architecture tests enforce the pattern.

Consequences ​

Positive ​

  • Full type safety from HTTP boundary to business logic — PHPStan verifies the entire chain
  • Actions are HTTP-agnostic — the same DTO can be constructed from any source
  • Refactoring is safe — rename a DTO property and the compiler catches every usage
  • $this->safe() ensures only validated data enters the DTO
  • Cross-entity scoping prevents authorization bypass via invalid foreign keys

Negative ​

  • More boilerplate per endpoint: FormRequest + DTO + toDto() method
  • Enum conversion is manual (PriorityEnum::from(...)) — not automatic
  • Nullable handling is verbose ($value !== 0 ? $value : null)
  • AI agents will generate $request->validated() code that fails architecture tests

References ​

  • Action Class Architecture — defines how Actions consume DTOs
  • Architecture tests: tests/Arch/DataTransferObjectsTest.php, tests/Arch/FormRequestsTest.php

Implementation ​

TerritoryStateNotes
kendoEvergreenPattern established. Architecture tests enforce DTO conventions and FormRequest structure.
brick-inventoryEvergreenPattern established. Architecture tests enforce conventions.
ublgenieEvergreenCorrected 2026-08-20 (was In Progress on "PR #17" — #17 MERGED 2026-04-01, 141 days stale). Verified at origin/development: 15 of 15 mutation FormRequests define toDto() (the two without are Concerns/AuthorizesBulkInvoiceOwnership.php, a trait, and LoginRequest.php, the documented login-handler exception), 24 DTOs, and the once-deferred ProcessInboundInvoice now has app/DataTransferObjects/Input/Invoice/ProcessInboundInvoiceData.php. Arch-test-enforced: tests/Arch/FormRequestsTest.php:30 ("form requests with mutation actions define toDto method") + tests/Arch/DataTransferObjectsTest.php; the test asserts a non-empty population before checking violations.
emmieIn ProgressRetiring its own DTOFormRequest under EMMIE-0246. 101 subclasses at origin/development (6e110a97e3, 2026-11-06), held by a ratchet since #3479, and 64 on the finance slice's branch. Allies: Marcel builds; the Commander rules.

Amendment 1 (Accepted, 2026-11-06): a list of items with fields is a list of DTOs ​

Accepted

Status. Ally Marcel proposed this from emmie's DTOFormRequest retirement (EMMIE-0246, decision D11). The war room integrated it here under ADR-0015 § Amendment Protocol. The Commander accepted it on 2026-11-06 with the edits below, fleet-wide and with no sweep: a territory converts a list when it next touches the file.

Context ​

This ADR rejects raw arrays at the Action boundary, shows toDtos() for a body that is itself a list, and shows list<int> for plain values. It does not say what happens to a list of items with fields inside a DTO.

The fleet has mostly answered already. Measured 2026-11-06 on the Input layer of 13 territories, by a docblock scan whose positive control fired: 30 files hold a list of nested DTOs and 11 mention a shaped array. Those 11 are in emmie (2), kendo (3), codebook (4) and ublgenie (2). Read one by one, they shrink further: see Implementation.

Decision ​

  1. A list whose fields an Action reads becomes a list of small DTOs, built in toDto() with array_map. Plain values stay an array, and so does a payload the Action stores or forwards unread.
  2. The mapping changes no meaning: no cast may turn an invalid value into a valid one, and absent and null stay distinct where the Action treats them differently (WR-0374).
  3. A nested DTO an Action receives lives in Input\.
  4. A ratchet test enforces 1 and 3. Each listed entry names a ticket, or for an unread payload the reason the Action does not read it.
php
public function toDto(): CreateMeetingData
{
    /** @var list<array{id: int, minutes: string|null}> $clients */
    $clients = $this->safe()->array('clients');

    return new CreateMeetingData(
        clients: array_map(
            static fn (array $client): MeetingClientData => new MeetingClientData(
                id: $client['id'],
                minutes: $client['minutes'],
            ),
            $clients,
        ),
    );
}

Why ​

PHPStan is not the reason: it already catches typos and wrong types on a shaped array, as it does on a DTO (probed 2026-11-06 at level 8). The gains are elsewhere:

  • A constructor enforces the types at runtime. A @var on a validated array is a claim PHPStan trusts.
  • One name replaces a shape copied around. emmie's learning_goal_reports shape is spelled five times in its request files alone.
  • The field has a place for a domain type (ADR-0020 Amendment, Semantic Boundary Types).

Boundaries ​

  • An unread payload stays an array. emmie's CreateIntroductoryMeetingReportAction runs json_encode on its questions; as DTOs, absent optional keys would be stored as null in the report's JSON. Codebook's CreateReviewAction stores reviewContent the same way.
  • A positional tuple has no fields. list<array{int, int}> is outside point 1.
  • This governs what an Action receives, from any source: a FormRequest, a Service or a Job. A Result DTO is out of scope.
  • Domain types follow the territory's adoption of ADR-0020's amendment. A conversion that must keep behaviour does not adopt it in passing.

Enforcement ​

WhatMechanismScopeState
Points 1 and 3Ratchet: a known-violations list and a verdict that names new and stale entriesemmieOwed: its own ticket, built before the clients slice
Points 1 and 3phpstan-warroom-rules ruleFleetQueue #260, after emmie's ratchet has been seen to fail and pass. Until then this is review only outside emmie
Point 2ReviewFleetA cast that decodes and a cast that hides cannot be told apart mechanically

ADR-0020's reflection tests cannot see any of this: to reflection, list<X> is array. The ratchet's population is the constructor docblocks of the DTOs an Action receives, not request files, because a @var list<array{...}> local before an array_map is the pattern itself.

Consequences ​

  • Positive: the Action reads $client->minutes, and the rule writes down what most of the fleet already does.
  • Negative: one small DTO per kind of item. The ratchet cannot tell read from unread, so an unread payload is listed by hand with its reason.

Implementation ​

TerritoryStateNotes
emmieIn ProgressOn the finance branch (feaa4f3d6c), 10 DTOs hold a list with named keys: three are learning_goal_reports, which keeps an array under its own ticket because plain FormRequests outside EMMIE-0246 also feed it; two are the unread question lists; several look like outputs. Marcel counts 7 nested DTOs outside Input\.
kendoOn next touchUpdateTwoFactorEnforcementData, PermissionSnapshotData, SaveRoleData.
codebookOn next touchUpdateStrategyStepsData, GenerateReviewedFilesFromCommentsData. CreateReviewData and UpdateReviewData carry the unread reviewContent and stay.
ublgenieCleanIts two hits are the @return of a helper method, not a field.
lokalekeuze, entreezuil, brick-inventory, wijs, isms, town-crier, btc-dash, time-machine, laravel-skeletonCleanNo shaped list in Input.

Front-door projections are owed: WR-2880.

Architecture documentation for contributors and collaborators.