- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've seen this class. A User entity with #[Assert\Email] on the email property, #[Assert\NotBlank] on the name, #[Assert\Length(min: 8)] on the password. It works. The form rejects bad input. Everyone moves on.
Then a background job creates a user from an imported CSV, skips the form, and writes a row with a blank email straight to the database. The constraints were on the entity, but nobody ran the validator on that path. The rule existed. It just wasn't enforced where it mattered.
That gap is what this comes down to. Symfony's Validator is good at one job. Your domain needs a different job done. When you mix them, you get rules that live in two places and disagree with each other.
Two gates, two responsibilities
Draw a line down the middle of any request.
On the left is input. A JSON body, a form submission, query parameters. It arrives as strings, it might be missing fields, and it comes from someone you don't trust. The question here is: is this shaped like what I asked for? Is the email a string that looks like an email. Is the age a positive integer. Is the required field present.
On the right is your domain. Orders, accounts, invoices. The question here is different: does this operation keep my business rules true? Can this account be charged. Is this discount still valid on this date. Does this transfer leave the source balance non-negative.
Symfony's Validator belongs on the left. It checks the shape of input before your application does anything with it. Domain invariants belong on the right, enforced by the objects that own them, and they hold no matter which code path got there.
The mistake is asking one gate to do both jobs.
What the framework gate is good at
Put the constraints on a DTO, not the entity. The DTO represents the request payload, and validating it is the framework's job.
<?php
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
final class RegisterUserRequest
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Email]
public string $email = '',
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 80)]
public string $name = '',
#[Assert\NotBlank]
#[Assert\Length(min: 12)]
public string $password = '',
) {
}
}
In a controller, map the request onto it and validate. On Symfony 7 you can let the framework do the mapping with #[MapRequestPayload], which runs the validator for you and throws a 422 on failure.
<?php
namespace App\Controller;
use App\Dto\RegisterUserRequest;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\HttpFoundation\JsonResponse;
final class RegisterUserController
{
public function __invoke(
#[MapRequestPayload] RegisterUserRequest $request,
): JsonResponse {
// $request is already shape-valid here.
// Hand it to the application layer.
return new JsonResponse(status: 201);
}
}
This is the gate at its best. It runs early, it returns a clean 422 with field-level errors, and it keeps malformed input from ever reaching your domain. The constraints answer format questions: present, string, well-formed, within length. Nothing here knows what an account is.
Note the property defaults (= ''). They keep the DTO constructible even when a field is missing, so NotBlank produces a readable error instead of a type error on an unset property.
What the framework gate is bad at
Try to express a real business rule as a constraint and it starts to strain.
"A user can withdraw only if the resulting balance stays at or above their overdraft limit." That rule needs the current balance, the overdraft limit, and the requested amount together. A constraint on a single property can't see the others cleanly. You reach for a class-level #[Assert\Callback], load the account inside it, and now your validation layer is running domain logic and hitting the database.
<?php
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
final class WithdrawRequest
{
public function __construct(
public string $accountId = '',
public int $amountCents = 0,
) {
}
#[Assert\Callback]
public function validateBalance(
ExecutionContextInterface $context,
): void {
// To check this you need the account.
// Now the DTO validator loads domain state.
// The rule has leaked out of the domain.
}
}
The moment a constraint needs to load an aggregate to decide, it has stopped validating input shape and started enforcing an invariant. That rule belongs to the account, not to the request. Keep pushing rules like this into callbacks and your DTO becomes a second, weaker copy of your domain, one that only runs when someone remembered to call the validator.
Where invariants actually live
An invariant is a rule the object refuses to break, ever. The clean place to enforce it is the constructor of a value object or the method on the aggregate that performs the change.
<?php
namespace App\Domain\Account;
final class Money
{
private function __construct(
public readonly int $cents,
) {
}
public static function fromCents(int $cents): self
{
if ($cents < 0) {
throw new \InvalidArgumentException(
'Money cannot be negative.',
);
}
return new self($cents);
}
}
The withdrawal rule lives on the aggregate, expressed with domain types, unaware that Symfony exists.
<?php
namespace App\Domain\Account;
final class Account
{
public function __construct(
private int $balanceCents,
private readonly int $overdraftCents,
) {
}
public function withdraw(Money $amount): void
{
$after = $this->balanceCents - $amount->cents;
if ($after < -$this->overdraftCents) {
throw new InsufficientFundsException(
$this->balanceCents,
$amount->cents,
);
}
$this->balanceCents = $after;
}
}
Now the rule holds on every path. The HTTP controller, the CSV importer, the admin command, the message handler. None of them can withdraw past the overdraft, because the check is inside the operation, not bolted onto one entry point. That is what makes it an invariant and not a suggestion.
The duplicated-truth trap
This is the failure mode. You write #[Assert\GreaterThan(0)] on the DTO amount. You also write if ($cents < 0) throw in Money. Two rules, two places, describing the same truth.
For a while they agree. Then a product change says withdrawals must be at least 5 euros. Someone updates the constraint to #[Assert\GreaterThanOrEqual(500)] and ships. The Money value object still accepts 1 cent, because nobody changed it. Now the API rejects 3-euro withdrawals but the importer and the internal command let them through. The rule is technically written down twice and enforced inconsistently, which is worse than writing it once.
The fix is not "validate in both places to be safe." The fix is to give each rule one owner.
- Format and presence: owned by the DTO constraints. Is
amountCentsan integer, is it present, is it within a sane request range. - Business meaning: owned by the domain. Is this amount allowed given the account, the limits, the date.
The DTO can carry a loose sanity bound (GreaterThan(0)) so obviously broken requests fail fast with a friendly 422. It should not carry the real minimum. That belongs to the domain, and the domain is the only place that gets to say the operation is valid.
Making the two gates cooperate
The controller runs the framework gate, then hands a valid-shaped DTO to the application layer, which drives the domain. When the domain refuses, catch the domain exception and translate it back to an HTTP response.
<?php
namespace App\Controller;
use App\Application\WithdrawHandler;
use App\Domain\Account\InsufficientFundsException;
use App\Dto\WithdrawRequest;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
final class WithdrawController
{
public function __construct(
private readonly WithdrawHandler $handler,
) {
}
public function __invoke(
#[MapRequestPayload] WithdrawRequest $request,
): JsonResponse {
try {
$this->handler->handle($request);
} catch (InsufficientFundsException $e) {
return new JsonResponse(
['error' => $e->getMessage()],
status: 422,
);
}
return new JsonResponse(status: 200);
}
}
Two gates, in order. The first rejects garbage before your domain wakes up. The second enforces meaning and reports the domain's own reason for saying no. Neither pretends to be the other. The DTO never loads an account. The Account never imports a Symfony constraint.
If you want the domain refusal to feel like a normal validation error on the frontend, map the exception to a ConstraintViolation at the boundary, in an exception listener. The translation lives in the framework layer where it belongs, and the domain stays clean.
A quick test for which gate owns a rule
When you're unsure where a check goes, ask one question: can I decide this from the request alone?
If yes, it's a shape rule. Email format, string length, required fields, numeric range with fixed bounds. Framework gate, constraint on the DTO.
If you need to load something, compare against stored state, or know the current date or the account's history to answer, it's an invariant. Domain gate, enforced inside the object that owns the state.
Run that test on every rule and the duplication stops appearing on its own. Most #[Assert\Callback] methods that load an entity are invariants wearing a constraint costume. Move them.
The payoff
Split cleanly, each layer gets simpler. Your DTOs are small and honest: they describe the request and nothing more, and they're trivial to unit test with no container. Your domain objects hold the rules that actually protect your data, and those rules hold on every path because they're inside the operation, not guarding one door.
You also stop the slow rot where a form and a domain object drift apart over years of edits. There's one place to change the withdrawal minimum. One place to read to know what "valid" means. When the CSV importer creates a user, it goes through the same domain constructor the controller does, so the same rules apply without anyone remembering to copy a constraint across.
Framework validation ends where a rule needs to know your business. That's the line. Keep the Validator on the input side of it, keep invariants in the domain, and the two gates stop fighting over the same truth.
If this was useful
The reason this split works is the same reason the rest of a decoupled codebase works: input handling is a framework concern that belongs at the edge, and business rules are a domain concern that belongs at the center, unaware of which framework wraps them. Keeping the Symfony Validator on one side of that boundary and your invariants on the other is a small version of the larger discipline. Decoupled PHP is the book about drawing those boundaries and keeping them: what stays in the framework, what moves to the domain, and how to make sure a rule lives in exactly one place.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)