Skip to content

ADR-0012: FormRequest → DTO Flow

Accepted Cross-Project

Date: 2026-03-08

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,
    );
}

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

Architecture documentation for contributors and collaborators.