ADR-0012: FormRequest → DTO Flow
Accepted Cross-ProjectDate: 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:
- Pass validated array —
$action->execute($request->validated())— loses type safety, Actions receivearray<string, mixed> - 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->laneIdDTO Structure
DTOs are final readonly classes with promoted constructor properties:
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):
| Rule | Enforcement |
|---|---|
final readonly class | toBeFinal()->toBeReadonly() |
Suffix Data | toHaveSuffix('Data') |
declare(strict_types=1) | toUseStrictTypes() |
| No base class, no interfaces | toExtendNothing()->toImplementNothing() |
No public methods besides __construct | Reflection test |
toDto() Method
FormRequests convert validated input to DTOs using $this->safe():
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
ExtractsIntArraytrait forlist<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():
// 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():
/**
* @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:
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:
// 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
| Rule | Enforcement |
|---|---|
final class | toBeFinal() |
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 tables | Pattern-matching test |
Use ExtractsIntArray trait for int arrays | No inline array_map(...(int)...) |
Options Considered
| Option | Verdict | Reason |
|---|---|---|
Pass validated arrays ($request->validated()) | Rejected | Actions receive array<string, mixed> — no type safety, no IDE autocomplete, PHPStan cannot verify property access |
| Pass FormRequest to Action | Rejected | Couples Actions to HTTP layer. Actions must be callable from jobs, commands, MCP tools where no FormRequest exists |
spatie/laravel-data package | Considered, not chosen | Adds 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 → Action | Accepted | Explicit, 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