The Boundary in Laravel: FormRequest Is Not a DTO
Laravel has opinions about the boundary, and two of them are good. A FormRequest validates where the data arrives, and a JsonResource decides what leaves. That is two of the five boundary rules, already installed, no architecture review required.
The other three are where the framework stops helping and starts looking like it helped. validated() hands you an array, so the payload is validated and still untyped — the type is a step you add. A queued event with SerializesModels can carry a model, so "the wire never carries a domain type" is a rule the framework will happily break for you. And nothing in the framework has an opinion about versioned contracts at all.
So this is not the Symfony article with the names changed. The five rules are defined there — DTO-First: The Boundary Decides What the Domain Sees — and this one is about what Laravel does with them: which two come free, which three do not, and what changes when the layer gate is a tool you buy instead of a script you write.
Two things are different from that article. The gate is deptrac, not a hand-rolled text scanner: it is a mature dependency analyser, its output is file:line, and it leaves the five reflection checks that remain small enough to read in one sitting. And the price has a different shape: the mappers are cheap here too, but the expensive part is not a locale — it is two validation rules that quietly hit the database.
The demo is a refund request: POST /api/v1/refunds, a second payload version, one versioned message consumed by a queued listener, and an order that has to exist. Laravel 13, PHP 8.3+, Pint-clean, 58 tests with 166 assertions.
composer test 58 passed (166 assertions)
composer cs PASS
composer arch deptrac: 0 violations, 0 uncovered, 0 skipped · debug:unassigned: none
The code is at michalwolinski/laravel-dto-first. Where it cuts against its neighbours: the event-sourcing piece owns the aggregate's event stream and projections, and the Symfony boundary article owns the doctrine itself. This one owns the framework's half of it.
FormRequest is not a DTO
The idiomatic Laravel endpoint takes a FormRequest and passes $request->validated() onward. That is a real improvement over touching the request directly, and it is not a type:
// what validated() gives you: an array with keys the rules happened to name
$data = $request->validated(); // array{orderId: string, amountMinor: int, ...}? no — array
The demo keeps the FormRequest, and adds the one step that makes the shape real: the request knows how to build its own contract DTO. A data() method is the obvious name and it is already taken by Illuminate\Http\Request — a subclass may not narrow the signature — so it is called toData(), and that collision is the first thing worth knowing about writing this by hand:
// app/Http/Requests/V1/CreateRefundRequest.php
final class CreateRefundRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
// The domain's own format first: an id OrderId would refuse never reaches the database,
// and an id the database knows but the domain refuses cannot get through. Laravel already
// skips `exists` and `unique` once an earlier rule has failed; `bail` states the order
// instead of leaning on that.
'orderId' => [
'bail',
'required',
'string',
'regex:'.OrderId::PATTERN,
Rule::exists('orders', 'id'),
Rule::unique('refunds', 'order_id'),
],
'amountMinor' => ['required', 'integer', 'min:1', 'max:99999999999999999'],
'currency' => ['required', 'string', 'regex:'.Money::CURRENCY_PATTERN],
'reason' => ['required', Rule::enum(RefundReason::class)],
];
}
/**
* The bridge from "validated" to "typed": Laravel hands back an array, the boundary hands the
* application a shape.
*/
public function toData(): CreateRefundData
{
return new CreateRefundData(
orderId: (string) $this->validated('orderId'),
amountMinor: (int) $this->validated('amountMinor'),
currency: (string) $this->validated('currency'),
reason: (string) $this->validated('reason'),
);
}
}
The DTO itself is deliberately dull, and deliberately scalar:
// app/Contracts/Http/V1/CreateRefundData.php
final readonly class CreateRefundData
{
public function __construct(
public string $orderId,
public int $amountMinor,
public string $currency,
public string $reason,
) {}
}
Why bother, when the array already passed validation? Because a validated array is validated now and typed never. Every place that receives it reads keys by string, so a key renamed in the rules breaks each of those places separately, at runtime, wherever the array happened to travel. With the DTO, the keys are read in exactly one method, and everything after it is a constructor that the IDE, the type checker and a reader can follow. The DTO is the difference between a promise and a type, and toData() is the one line where the promise is cashed in.
That line has an honest weak point, and it is visible in the code above: the casts. (int) $this->validated('amountMinor') turns a missing key into 0 instead of failing, and (string) turns it into ''. In this demo it is Money that refuses the zero, not the DTO. The casts are there because the integer rule also accepts "12500" as a string; a stricter toData() would read the key, refuse null out loud, and only then convert.
The order id rule carries OrderId::PATTERN itself, and the currency rule Money::CURRENCY_PATTERN — the payload and the domain read one string, not two copies that can drift. Without it, an order the database knows under an id the domain refuses passes exists, reaches OrderId::fromString() and leaves as a 500 — the payload rule and the domain have to agree on what an id looks like, or the gap between them is an exception.
It also gives the two payload versions somewhere to differ. Version 2 sends "amount": "125.00" and has its own mapper; both versions end at the same command, and the handler never learns which one called it. The version lives on the DTO, not on the handler and not on the database:
V1 payload → V1 mapper ─┐
├→ RequestRefund → Domain
V2 payload → V2 mapper ─┘
What no payload rule can say is whether the refund fits the order. A request can be perfectly well formed and still ask for more than the order took, or for dollars out of an order paid in euros. That is an invariant, so it lives in the aggregate, and the order's total is an argument rather than something the aggregate looks up:
// app/Domain/Refund/Refund.php
public static function request(
RefundId $id,
OrderId $orderId,
Money $amount,
Money $orderTotal,
RefundReason $reason,
DateTimeImmutable $requestedAt,
): self {
if ($amount->isZero()) {
throw new InvalidArgumentException('A refund amount must be greater than zero.');
}
if (false === $amount->sameCurrencyAs($orderTotal)) {
throw RefundNotCoveredByOrderException::differentCurrency($orderId);
}
if ($amount->minorUnits() > $orderTotal->minorUnits()) {
throw RefundNotCoveredByOrderException::moreThanTheOrder($orderId);
}
$refund = new self($id, $orderId, $amount, $reason, $requestedAt);
$refund->events[] = new RefundRequested($id, $orderId, $amount, $reason, $requestedAt);
return $refund;
}
The total arrives through OrderTotals, a port the domain owns and the infrastructure implements over the orders table, and the refusal leaves as application/problem+json with a 422 and its own type. The first version of this demo did not have the check at all: a refund of 9 999.99 USD against an order of 1.00 EUR came back 201. Every payload rule passed, which is the point — validation answers whether the request is well formed, and only the aggregate can answer whether it is allowed.
Two details came out of review, and both are easy to get wrong the first time. "More than zero" belongs to the refund, not to Money: an order total is money too, a fully discounted order totals zero, and while Money refused zero, reading that order's total was a 500. And the refusal's message names the order and the reason, never the total. The message leaves the process as the problem's detail, and with authorize() returning true, a refusal that said "this order totals 7 345.12" would have let anyone read what any order cost by asking for too much. An error contract is a boundary as well, and it can leak like any other.
The model is deliberately one refund per order — the unique index on refunds.order_id says so — which is why "never more than the order" is a check on one amount rather than on a running sum. Partial refunds in several steps would need the sum, and the index would have to go.
Who enforces what
This is the article's spine: every rule has an owner, and the owners are different tools. Deptrac owns the question "which layer may see which"; reflection owns "what shape crossed"; nothing owns "what value went inside".
| Rule | deptrac | Reflection |
|---|---|---|
| 1. Nothing from outside gets a type inside the domain | yes — the domain may depend on no layer, and Framework collects Illuminate, Symfony, Psr | partly — a domain method that takes an array |
| 2. Validation happens once, at the entry | partly — the domain may not depend on the validator | yes — every FormRequest returns a non-empty rules() |
| 3. Every crossing has a named mapper | no — reachability is not a dependency | yes — every contract type is named by a mapper signature |
| 4. The wire never carries a domain type | yes — the two contract layers may depend on nothing | partly — a public action returning a domain type |
| 5. Every cross-process contract carries a version | no — a constant is not a dependency | yes — every message declares an integer VERSION |

Deptrac does the layers
The gate is a PHP config, and the shape worth copying is the empty ruleset: a layer with no accesses() may depend on no other layer, which is how the domain and the two contract layers are stated. The excerpt below is the part that states the rules; the full file also declares the Http, Application, Models, Infrastructure, Providers and Architecture layers and what each of them may see, because every class under app/ has to belong to one.
// deptrac.php — excerpt; the full file is in the repository
return static function (DeptracConfig $config): void {
$config
->paths('./app')
->layers(
$domain = Layer::withName('Domain')->collectors(
DirectoryConfig::create('app/Domain/.*'),
),
// … Http, Application, Models, Infrastructure, Providers, Architecture
$contractsHttp = Layer::withName('ContractsHttp')->collectors(
DirectoryConfig::create('app/Contracts/Http/.*'),
),
$contractsMessage = Layer::withName('ContractsMessage')->collectors(
DirectoryConfig::create('app/Contracts/Message/.*'),
),
$contractsCommand = Layer::withName('ContractsCommand')->collectors(
DirectoryConfig::create('app/Contracts/Command/.*'),
),
$framework = Layer::withName('Framework')->collectors(
ClassNameRegexConfig::create('#^Illuminate\\#'),
ClassNameRegexConfig::create('#^Symfony\\#'),
ClassNameRegexConfig::create('#^Psr\\#'),
),
)
->rulesets(
// Rule 1: the domain speaks the language, not the framework.
Ruleset::forLayer($domain),
// Rule 4: wire types carry scalars, not our domain and not our models.
Ruleset::forLayer($contractsHttp),
Ruleset::forLayer($contractsMessage),
// The one deliberate exception: the command stays inside and carries domain values.
Ruleset::forLayer($contractsCommand)->accesses($domain),
// … one ruleset for each of the remaining layers
);
};
Two details in there cost an afternoon between them, and both are the kind of thing a README will not tell you.
The collectors escape backslashes for you. ClassNameRegexConfig::create(), ClassLikeConfig::create() and the other collector configs run the pattern through an escaping step that doubles every backslash. A pattern written the way a regex is normally written in PHP source — '#^Illuminate\\#', one backslash once PHP has read the string — comes out matching a literal backslash, which is what you want. A pattern escaped by hand, '#^Illuminate\\\\#', is escaped twice and matches nothing: the layer comes out empty, and the report says zero violations, which is the worst possible combination. I hit it first with ClassLikeConfig on App\Domain, blamed the collector, and then hit it again on the anchored vendor pattern and blamed the anchor. It was the same mistake both times. DirectoryConfig is still what the config uses for the application's own layers, because a file path needs no backslashes at all and reads like the thing it describes.
The anchor is worth keeping. Without it, #Psr# collects any class with those three letters anywhere in its name, our own included. With the Framework layer collecting properly, rule 1 means "no framework" rather than "no other layer of ours" — and the moment it works, it tells you that RequestRefundHandler uses Carbon and that every Eloquent model extends Model, so the ruleset needs Application and Models to be allowed the framework, and the rest not.
By default, deptrac reports uses, not imports. Its default emitters read class and function tokens, so a dead use Illuminate\Support\Str; at the top of a domain class passes — nothing touches the framework. EmitterType::USE_TOKEN adds imports to the analysis; this repository leaves it off on purpose. It also means the proof has to use a real call, not an import; my first mutation "proved" the gate was broken when it was only a typo in the mutation.
The proof that it bites
A gate that has never failed is a gate nobody should trust, and with a bought tool the proof is cheap: a second config, the same rules for the layers it declares, pointed at the deliberate breakages.
// deptrac-violations.php
return static function (DeptracConfig $config): void {
$config
->paths('./tests/Fixtures/Violations', './app/Models')
->layers(
$domain = Layer::withName('Domain')->collectors(
DirectoryConfig::create('tests/Fixtures/Violations/Domain/.*'),
),
$contractsHttp = Layer::withName('ContractsHttp')->collectors(
DirectoryConfig::create('tests/Fixtures/Violations/Contracts/Http/.*'),
),
$contractsMessage = Layer::withName('ContractsMessage')->collectors(
DirectoryConfig::create('tests/Fixtures/Violations/Contracts/Message/.*'),
),
$contractsCommand = Layer::withName('ContractsCommand')->collectors(
DirectoryConfig::create('tests/Fixtures/Violations/Contracts/Command/.*'),
),
$models = Layer::withName('Models')->collectors(
DirectoryConfig::create('app/Models/.*'),
),
$framework = Layer::withName('Framework')->collectors(
ClassNameRegexConfig::create('#^Illuminate\\#'),
ClassNameRegexConfig::create('#^Symfony\\#'),
ClassNameRegexConfig::create('#^Psr\\#'),
),
)
->rulesets(
Ruleset::forLayer($domain),
Ruleset::forLayer($contractsHttp),
Ruleset::forLayer($contractsMessage),
Ruleset::forLayer($contractsCommand)->accesses($domain),
Ruleset::forLayer($models)->accesses($framework),
Ruleset::forLayer($framework),
);
};
app/Models is in paths on purpose: two fixture contracts type-hint a real Eloquent model, and deptrac can only call that a Models dependency if the model is a token it has seen. The test then asserts the entire violation list, not "something came back":
// tests/Architecture/DeptracTest.php
$this->assertSame([
'tests/Fixtures/Violations/Contracts/Http/LeaksTheModel.php:15 App\Models\Order',
'tests/Fixtures/Violations/Contracts/Message/CarriesAModel.php:20 App\Models\RefundRecord',
'tests/Fixtures/Violations/Domain/UsesTheFramework.php:17 Illuminate\Support\Str',
], $violations);
The same test asserts the report's own counters on the config that governs app/, and the first version of it misread one of them. Skipped: 0 means what it looks like: no violation is excused by the config, so no rule has been quietly widened. Uncovered: 0 means less than it looks like. It counts dependencies of classes that are in a layer on something no layer collects — Carbon\CarbonImmutable inside the domain shows up there as two uncovered uses. It does not count classes that are in no layer at all, because those are not analysed, and a class that is not analysed has no dependencies to report.
That is not a theory. A new app/Support/Leaky.php that uses both Str and the Refund aggregate left the report at zero violations, zero uncovered and zero skipped. The demo already had such a class: AppServiceProvider sat in no layer the whole time. Only deptrac debug:unassigned lists those classes, so the test runs it and requires the answer "There are no unassigned tokens", and the provider now has a layer of its own. A gate that reports zero violations while never looking at part of the codebase is telling the truth about the wrong question.

debug:unassigned lists it; a skipped violation is a rule that no longer applies.The five checks that remain
What is left over is small because deptrac took the architectural half. Each check is a handful of reflection, and each is pinned the same way: silent on app/, the exact list on the fixtures.
// app/Architecture/BoundaryChecks.php
public function messagesWithoutVersion(string $directory): array
{
$violations = [];
foreach ($this->classesIn($directory) as $class) {
$version = $class->getReflectionConstant('VERSION');
if (false === $version || false === is_int($version->getValue())) {
$violations[] = $class->getName();
}
}
return $violations;
}
The interesting one is not the version check; it is the array check. A domain class that takes array $data imports nothing, so deptrac has nothing to report — and yet the boundary has walked straight into the aggregate. Reflection sees it:
// app/Architecture/BoundaryChecks.php — parameters only, on purpose
foreach ($method->getParameters() as $parameter) {
if (in_array('array', $this->typeNames($parameter->getType()), true)) {
$violations[] = sprintf('%s::%s()', $class->getName(), $method->getName());
break;
}
}
typeNames() is there because a single named type is not the only kind PHP has. ?array, array|string and Refund|string are all ways to say the same thing, and a check that only understood ReflectionNamedType read past every union — the leaky controller fixture now has an action returning Refund|string for exactly that reason.
Return types are deliberately excluded: releaseEvents(): array is an idiomatic collection of domain objects, and flagging it would teach the team to ignore the check. Every check has a limit like that, and the honest thing is to write them down instead of implying completeness:
- Rule 3 reads signatures, not bodies. A mapper that only
news a contract inside a method, with no type hint, is invisible. The demo's mappers type everything, which is the habit the check rewards. - The
rules()check instantiates the request without a constructor, so arules()that consults$this->route()cannot be read. The check reports that loudly rather than passing it — a checker that stays silent is the failure mode this whole article is about. - Nothing checks values.
return $model->toArray();in a controller hands a column dump to the client without naming a domain type in any signature, and both tools shrug.
Where Laravel leaks on its own
Two Laravel idioms deserve their own paragraphs, because they are the framework actively helping you break rule 4.
SerializesModels makes a model look like a payload. A queued event that takes an Eloquent model is serialised as a model reference, which is fine inside one application and wrong the moment the consumer is another process: the consumer does not have your rows, your casts or your hidden columns. In this demo the message is a value — scalars and a version — and the queued thing is the listener, so the payload cannot grow a handle() and cannot become a job by accident:
// app/Contracts/Message/RefundRequestedMessage.php
final readonly class RefundRequestedMessage
{
public const int VERSION = 1;
public function __construct(
public int $version,
public string $refundId,
public string $orderId,
public int $amountMinor,
public string $currency,
public string $reason,
public string $requestedAt,
) {}
}
And deptrac can prove this one, which is more than the Symfony demo could: a model in the payload is a dependency from ContractsMessage to Models, and the layer may depend on nothing. CarriesAModel in the fixtures is exactly that message — versioned, so the reflection checks have nothing to say about it, and carrying a RefundRecord, so deptrac does.
One limit belongs next to it. Laravel's queue does not send this message as JSON: it sends the event as PHP serialize() output, so the consumer has to have the class. That is the same coupling the paragraph above holds against SerializesModels, one level up — scalars instead of rows, but still a shared PHP type rather than a wire format. The version starts to buy something once the payload leaves as a document another process decodes; inside one Laravel application, it is a habit you are practising for that day.
The handler stores the refund and dispatches the message in one database transaction, and the listener implements ShouldQueueAfterCommit. A message is queued only for a refund that was actually stored; a refund that rolls back announces nothing. Without that pairing, a queued listener can run before the row it describes is committed, or a stored refund can lose its message when the push to the queue fails. The test for it cannot use Queue::fake(), which records a push the moment it is made and knows nothing about commits; it runs the real sync queue inside a transaction that rolls back, and asserts that a recording logger heard nothing.
toArray() is not a response shape. It is a column dump: snake_case keys, timestamps nobody asked for, and — in this table, on purpose — an internal_note. The demo's test pins the disagreement rather than the intent:
// tests/Feature/ResponseLeakTest.php
$this->assertContains('internal_note', $model, 'the column never belongs on the wire');
$this->assertContains('order_id', $model, 'the model speaks in columns');
$this->assertNotContains('internal_note', $contract);
$this->assertNotContains('order_id', $contract);
$this->assertContains('orderId', $contract);
The fix is the resource wrapping the contract DTO, not the model — and one small decision that belongs to this boundary rather than to the application:
// app/Http/Resources/RefundResource.php
final class RefundResource extends JsonResource
{
/**
* No `data` envelope: the contract is the object, and the client should not have to unwrap it.
*
* @var string|null
*/
public static $wrap = null;
withoutWrapping() would do the same globally; $wrap = null on the resource keeps the shape a property of this boundary instead of a decision the whole API inherits.
The price, measured
Everything below is ns/op for one incoming payload, measured on PHP 8.5.11 (CLI, OPcache off) with Laravel 13.38 and a local SQLite file, inputs built at runtime. Medians of repeated runs on one machine: the ratios are the finding, the absolutes are not. The Eloquent rows in particular move a lot between runs, and their cost grows with the number of attributes the model is filled with.
| Path | ns/op | Against the doctrine |
|---|---|---|
| read the fields out of the validated array | ~29 | no type, no contract — the thing the doctrine removes |
| hand-written DTO + mapper | ~1 200 | the doctrine's own cost |
new RefundRecord(array) | ~64 000 | ~54× the DTO, and it is not even persisted |
RefundRecord::hydrate([row]) | ~63 000 | the same, for one row out of the database |
RefundResource on the contract DTO | ~2 700 | seven fields, one type |
$model->toArray() | ~74 000 | ~27× the resource, and it leaks the columns |
The DTO is the cheapest thing in the table that has a shape, and the Eloquent paths are fifty times it — new RefundRecord([...]) is not free just because no query ran. If the argument for skipping DTOs was performance, this is the number that ends it.
The cost is not there. It is in validation, and the Laravel-specific part is which validation:
| Validation | ns/op | Queries per request |
|---|---|---|
the demo's rules without exists and unique | ~319 000 | 0 |
the demo's rules (exists:orders,id, unique:refunds,order_id) | ~546 000 | 2 |
Validation is several hundred times the mapping it protects — that much is the same story as the Symfony piece. What is new is part of the why: exists and unique are not rules, they are queries, two of them per request. The two rows differ by exactly those two rules, and the gap is ~227 µs — about 40% of the validation bill, on a SQLite file on the same machine. Against a database across a network, each of those rules costs a round trip on top.
The comparison has to keep everything else identical, and the first version of the benchmark did not. Its "in memory" row left out the order id rules and used in: where the request uses Rule::enum(), and the gap came out at 428 µs — nearly twice the real one. The detail it hid is worth having on its own: of the rules that stay in memory, Rule::enum(RefundReason::class) is the most expensive by far, about four times an in: list of the same four values on this machine.
And one of the two is weaker than it looks. unique:refunds,order_id is check-then-act: two requests for the same order can both pass it before either has written, and then the second insert is stopped by the unique index in the migration. The repository turns that refusal into the domain's RefundAlreadyRequestedException, which leaves as a 409 problem rather than a database error. The index is the guarantee; the rule only makes the common case polite.
The two validation queries are not the whole bill, either. A successful request costs four, and a test pins the number: exists, unique, the order total for the invariant, and one insert. exists and the order total read the same row. Dropping exists would save a query and cost the client a field-level validation message for an unknown order; the demo keeps it, and says so here rather than leaving the duplicate for a profiler to find.

Which gives the same conclusion as the sibling article, reached from a different direction: the doctrine decides where validation happens, not what it costs — and in Laravel the expensive rule is the one that reads the database, which means the boundary now depends on the database at the moment it validates. That is a real trade (you cannot validate an order id without asking), and it belongs in the decision rather than in the surprise.
Where it does not fit
You do not have to win rule 1. If your aggregate is an Eloquent model, the doctrine asks you to rewrite the domain, and that is a far bigger fight than it is in Symfony, where a domain model often already exists. Take rules 2 to 5 and trade rule 1 away consciously: typed payloads, one mapper per crossing, resources instead of models, versioned messages. A project can get four of five and be honest about the fifth; pretending it has all five is how the gate becomes theatre.
A CRUD application does not need this. Five endpoints, one client, no consumer: FormRequest plus Resource is the whole boundary, and the doctrine is ceremony around it. The value scales with the number of crossings and the number of clients you do not control.
The tools have blind spots, and they are different blind spots. Deptrac sees uses and not values; reflection sees declared types and not bodies; neither sees that a well-named field is the wrong field. The gate is a smoke detector with a long and honest list of things it cannot smell.
Errors are a contract too, and Laravel's default is not one. The demo renders its domain refusals — a missing refund (404), a missing order (422), a refund the order does not cover (422), a second refund for the same order (409) — as application/problem+json with a type, but validation failures still come back in Laravel's own {message, errors} shape. Normalising that is a decision the demo did not take, and a real API should.
A demo of one process. There is no consumer here, no database beyond SQLite, no authentication — authorize() returns true, so anyone may ask for a refund on any order. The message is queued and a listener logs it; that is enough to show the shape and not enough to show a system.
Five rules, two tools, one number
Laravel gives you validation at the entry and a response type as conventions, and stops there: validated() is an array, a queued model is a leak, and a message without a version is a message you cannot change. The demo turns the first into a type (toData()), the second into a rule the gate can see, and the third into a constant a test insists on.
The gate is bought rather than written: deptrac owns the layers, the proof is a second config with the exact violations, the counters Uncovered: 0 and Skipped: 0, and an empty debug:unassigned — because a class in no layer appears in no counter — and five reflection checks cover the shapes it cannot see. What is left is a number: the mappers cost about 1.2 microseconds, the validation about 546 — and about 227 of those microseconds are two database lookups wearing the costume of a validation rule.