- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: System Design Pocket Guide: Fundamentals
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Open any architecture thread on PHP Twitter and someone will tell you validation belongs in the controller. The next reply says it belongs in the form request. The third says only the domain can be trusted. The fourth quotes a book and tells everyone to use value objects. By the bottom of the thread, you still don't know where to put the code that checks whether quantity is greater than zero.
The argument is unwinnable in the abstract because everyone is right about a different thing. There are three different jobs called "validation," and they sit in three different places. When a controller tries to do all three it turns into a god object, and when a domain tries to do all three it ends up returning HTTP status codes from inside an entity. Both shapes happen in real codebases. Both are avoidable once you name the three jobs separately.
This post names them, places them, and shows what each one looks like in PHP 8.3 with Laravel 11 and Symfony 7.
The three kinds of validation
The mistake is treating "validation" as one concern. It is three.
Shape validation asks: did the request contain the fields I need, in the types I expect? Is quantity an integer? Is email a string that looks like an email? Is currency one of the three letters in the enum? This is the boundary between the outside world and your code. If shape is wrong, no further work is possible, because the use case cannot run without something well-typed to pass to it.
Business validation asks: given a well-typed request, is this operation allowed right now? It looks at the state of the system. The customer might not exist, their account might be frozen, they might be short on credit, or the product might be discontinued. This needs to query repositories, check permissions, look at the clock. It cannot run at the HTTP boundary because the HTTP boundary doesn't have a database connection, and shouldn't.
Invariant enforcement asks: can this domain object exist in this state at all? Can an Order have zero items? Can Money have a negative amount? Can a Discount exceed 100 percent? This is the constructor of the type saying "no, that combination of fields is incoherent, the type itself refuses to be constructed that way." It runs in the constructor, so a fixture, a migration, or a Tinker session can't skip it.
The three jobs map onto the three layers of a Clean / Hexagonal PHP application:
| Job | Lives in | Knows about | Fails with |
|---|---|---|---|
| Shape | Controller / FormRequest / #[Assert]
|
HTTP, JSON, form fields | 422 Unprocessable Entity |
| Business | Use case / application service | Repositories, clock, policies | Domain exceptions → mapped to 409, 403, 404
|
| Invariant | Domain entity / value object constructor | Nothing outside the domain |
\DomainException / \InvalidArgumentException
|
Shape lives in the controller — Laravel 11
Laravel's FormRequest is built for this exact job. It runs before the controller method is called, fails the request with a 422 if the shape is wrong, and hands the controller a well-typed bag of data.
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class PlaceOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'customer_id' => ['required', 'uuid'],
'currency' => ['required', Rule::in(['EUR', 'USD', 'GBP'])],
'items' => ['required', 'array', 'min:1', 'max:100'],
'items.*.sku' => ['required', 'string', 'max:64'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:999'],
'items.*.price_cents' => ['required', 'integer', 'min:1'],
];
}
public function authorize(): bool
{
return true;
}
}
Every rule above is a fact about the request. None of them needs a database. None of them needs to know whether the customer exists, whether the SKU is in the catalog, or whether the currency matches the customer's account. Those are different questions for a different layer.
The controller stays thin. It pulls the validated payload, maps it to a use case input DTO, and delegates:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Application\PlaceOrder\PlaceOrderInput;
use App\Application\PlaceOrder\PlaceOrderUseCase;
use App\Application\PlaceOrder\Exceptions\CustomerNotActive;
use App\Application\PlaceOrder\Exceptions\UnknownSku;
use App\Http\Requests\PlaceOrderRequest;
use Illuminate\Http\JsonResponse;
final readonly class PlaceOrderController
{
public function __construct(
private PlaceOrderUseCase $useCase,
) {}
public function __invoke(PlaceOrderRequest $request): JsonResponse
{
$input = PlaceOrderInput::fromArray($request->validated());
try {
$order = ($this->useCase)($input);
} catch (CustomerNotActive $e) {
return new JsonResponse(['error' => $e->getMessage()], 403);
} catch (UnknownSku $e) {
return new JsonResponse(['error' => $e->getMessage()], 404);
}
return new JsonResponse(['order_id' => $order->id], 201);
}
}
Notice the controller catches application exceptions, not domain ones. That is on purpose: domain exceptions should never escape the use case. If a \DomainException reaches the HTTP layer, the use case forgot to translate it, and the response is a 500. Which is correct. An unenforced invariant reaching the edge is a bug, not a user error.
Shape lives in the controller — Symfony 7
Symfony's preferred shape-validation tool in 2026 is attribute-driven #[Assert] on a DTO, paired with the MapRequestPayload argument resolver. The runtime hydrates the DTO from the request body, runs the constraints, and short-circuits with a 422 if anything fails. The controller never sees a malformed request.
<?php
declare(strict_types=1);
namespace App\Http\Dto;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class PlaceOrderPayload
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Uuid]
public string $customerId,
#[Assert\NotBlank]
#[Assert\Choice(['EUR', 'USD', 'GBP'])]
public string $currency,
#[Assert\Count(min: 1, max: 100)]
#[Assert\Valid]
public array $items,
) {}
}
final readonly class PlaceOrderItem
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(max: 64)]
public string $sku,
#[Assert\Positive]
#[Assert\LessThanOrEqual(999)]
public int $quantity,
#[Assert\Positive]
public int $priceCents,
) {}
}
<?php
declare(strict_types=1);
namespace App\Http\Controller;
use App\Application\PlaceOrder\PlaceOrderUseCase;
use App\Http\Dto\PlaceOrderPayload;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
final readonly class PlaceOrderController
{
public function __construct(
private PlaceOrderUseCase $useCase,
) {}
#[Route('/orders', methods: ['POST'])]
public function __invoke(
#[MapRequestPayload] PlaceOrderPayload $payload,
): JsonResponse {
$order = ($this->useCase)($payload->toInput());
return new JsonResponse(
['order_id' => $order->id],
201,
);
}
}
The two frameworks express the same idea with different syntax. The shape check runs before the controller body, the controller delegates to a use case, and there is no business logic at the HTTP boundary or HTTP concern leaking into the use case.
Business validation lives in the use case
The use case is where shape-valid input meets the real state of the system. It checks the things a static schema cannot know: does this customer exist, is their account active, is the SKU still in the catalog, is the requested price still current, does the user have permission for this product line.
<?php
declare(strict_types=1);
namespace App\Application\PlaceOrder;
use App\Application\PlaceOrder\Exceptions\CustomerNotActive;
use App\Application\PlaceOrder\Exceptions\UnknownSku;
use App\Domain\Customer\CustomerRepository;
use App\Domain\Order\Item;
use App\Domain\Order\Money;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;
use App\Domain\Pricing\PriceCatalog;
use App\Support\Clock;
final readonly class PlaceOrderUseCase
{
public function __construct(
private CustomerRepository $customers,
private PriceCatalog $catalog,
private OrderRepository $orders,
private Clock $clock,
) {}
public function __invoke(PlaceOrderInput $input): Order
{
$customer = $this->customers->find($input->customerId);
if ($customer === null || !$customer->isActive()) {
throw new CustomerNotActive($input->customerId);
}
$items = [];
foreach ($input->items as $line) {
$price = $this->catalog->priceFor($line->sku, $input->currency);
if ($price === null) {
throw new UnknownSku($line->sku);
}
$items[] = new Item(
sku: $line->sku,
quantity: $line->quantity,
price: $price,
);
}
$order = Order::place(
customer: $customer,
items: $items,
placedAt: $this->clock->now(),
);
$this->orders->save($order);
return $order;
}
}
A few things to notice. The use case has no idea what HTTP is. It accepts an input DTO and returns a domain object, so you can call it from a queue worker, a CLI command, or a unit test with no framework loaded. The failures it raises are application exceptions with named types like CustomerNotActive and UnknownSku, not generic strings, which lets the controller map each named type to an HTTP status without anyone parsing exception messages to decide on 403 vs 404. And the use case never builds a domain object from raw scalars without going through the domain's own factory (Order::place). The domain has the last word on whether the object can exist.
Invariant enforcement lives in the domain
The domain's job is to refuse to exist in an incoherent state. If Money cannot be negative, the Money constructor throws. If an Order cannot have zero items, the Order::place factory throws. The rule is the same for every type: invariants are checked at construction and at every state transition that could violate them.
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use DomainException;
final readonly class Money
{
public function __construct(
public int $amountCents,
public string $currency,
) {
if ($amountCents < 0) {
throw new DomainException('Money cannot be negative');
}
if (!in_array($currency, ['EUR', 'USD', 'GBP'], true)) {
throw new DomainException("Unknown currency: {$currency}");
}
}
public function add(self $other): self
{
if ($other->currency !== $this->currency) {
throw new DomainException('Currency mismatch');
}
return new self(
$this->amountCents + $other->amountCents,
$this->currency,
);
}
}
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use App\Domain\Customer\Customer;
use DateTimeImmutable;
use DomainException;
use Ramsey\Uuid\Uuid;
final class Order
{
private function __construct(
public readonly string $id,
public readonly string $customerId,
public readonly array $items,
public readonly Money $total,
public readonly DateTimeImmutable $placedAt,
) {}
public static function place(
Customer $customer,
array $items,
DateTimeImmutable $placedAt,
): self {
if ($items === []) {
throw new DomainException('Order must have at least one item');
}
$currency = $items[0]->price->currency;
$total = new Money(0, $currency);
foreach ($items as $item) {
if ($item->price->currency !== $currency) {
throw new DomainException('All items must share a currency');
}
$total = $total->add(
new Money(
$item->price->amountCents * $item->quantity,
$currency,
),
);
}
return new self(
id: Uuid::uuid4()->toString(),
customerId: $customer->id,
items: $items,
total: $total,
placedAt: $placedAt,
);
}
}
The invariants are short and absolute: at least one item, single currency across the order, non-negative money. They are the same whether the order arrived through HTTP, a queue, or a CLI. The domain does not care.
"Doesn't this double up with shape validation? The FormRequest already checks min:1 on items." It does, and it should. The FormRequest is the polite door, giving the caller a 422 with a helpful message instead of a 500. The domain is the locked vault that assumes the door was kicked in. Both are needed, because the domain might be reached by code paths that didn't pass through the door (a fixture, a Tinker session, a migration script, a queue consumer reading legacy events).
"Should I throw DomainException or use a Result type?" Either. The book treats \DomainException as the default because it composes naturally with PHP's exception machinery and stack traces, and because shipping a Result library adds a dependency without much return for most teams. Result types are great when invariant failures are routine, though if your invariant fails routinely, the rule is wrong, not the mechanism.
The decision table
When you're staring at a new validation rule and don't know where to put it, ask one question: what does this rule depend on?
| The rule needs... | It lives in... | Example |
|---|---|---|
| Only the shape of the request | Controller / FormRequest / #[Assert]
|
quantity is a positive integer |
| The current state of the system (DB, clock, config) | Use case | Customer must be active |
| Nothing outside the domain itself | Domain entity / value object constructor | An order must have at least one item |
Notice the failure mode of putting a rule in the wrong layer:
- Business rule in the controller: the controller now reaches into the repository, runs queries, knows about domain types. Every queue worker and CLI command has to re-implement the rule because they don't go through the controller.
- Invariant in the use case: a different use case (or a fixture, or a migration) constructs the same domain object without the check and produces a corrupt entity. Two months later, a report breaks because half the orders in production have a zero total.
-
Shape check in the domain: the domain now knows about HTTP-shaped strings, the constructor throws on user typos, and the 500 page on your site is full of
Money: amount must be string of digits.
The pattern is symmetric: each layer owns one question, and can answer it without consulting the others. The result is code that survives a framework migration, because shape moves with the framework, business stays with the application, and invariants stay with the domain. Three independent moving parts instead of one tangled one.
What about value objects at the boundary?
A reasonable variant: parse the request straight into value objects in the controller, so by the time the use case is called, Sku, Quantity, Currency, and Money are already domain types. The shape check becomes "can I construct the value object from this string?" and the invariant check moves up into the constructor.
It works, and it removes one mapping step. The cost is that the controller now imports the domain types, which crosses a layer boundary some teams want to keep clean. The book treats this as a taste call: small services with a handful of types lean into it, larger services with many entry points keep the DTO-in-the-middle so the HTTP layer stays free of domain imports. Either is defensible; what is not defensible is mixing both styles in the same codebase and forgetting which controllers use which convention.
If a rule could be enforced at every layer, that is defense in depth. The shape check at the edge gives users a polite error, the invariant in the domain catches the code path that bypassed the edge, and the use case sits in the middle and asks the questions only the application can answer.
Where validation lives is not a holy war. It is a function of what the rule depends on. Once you can name the dependency, the layer picks itself.
If this was useful
This is one of the decisions the book walks through end-to-end — three layers, real PHP 8.3, Laravel and Symfony side by side, with the failure modes of getting each layer wrong. If you want the full picture of writing PHP that survives the framework underneath it, that is what Decoupled PHP is.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)