- 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 open the customer repository and count the methods. findActive. findActivePremium. findActivePremiumWithRecentOrders. findActiveInEurope. findChurnRiskForCampaign. Fourteen finders, each a slightly different WHERE clause pasted into a DQL string.
Then marketing asks for "active premium customers in Europe who haven't ordered in 90 days." You write finder number fifteen. The WHERE clause is a recombination of clauses you already wrote four times over, and now the definition of "premium" lives in six DQL strings. Change the tier threshold and you get to find all six.
The rules are real. The problem is where they live. "Premium" is a business concept, and it is smeared across the persistence layer as SQL fragments. The specification pattern pulls that concept back into the domain as a named object, and compiles it to a Doctrine Criteria only at the edge.
A specification is a predicate with a name
Start with the smallest possible interface. A specification answers one question about one candidate.
<?php
declare(strict_types=1);
namespace App\Domain\Customer\Spec;
use App\Domain\Customer\Customer;
interface CustomerSpec
{
public function isSatisfiedBy(Customer $customer): bool;
}
Each business rule becomes a class that implements it. The name is the rule. The constructor holds the rule's parameters.
<?php
declare(strict_types=1);
namespace App\Domain\Customer\Spec;
use App\Domain\Customer\Customer;
use App\Domain\Customer\CustomerStatus;
final readonly class IsActive implements CustomerSpec
{
public function isSatisfiedBy(Customer $customer): bool
{
return $customer->status() === CustomerStatus::Active;
}
}
<?php
declare(strict_types=1);
namespace App\Domain\Customer\Spec;
use App\Domain\Customer\Customer;
use App\Domain\Customer\Tier;
final readonly class HasTier implements CustomerSpec
{
public function __construct(private Tier $tier) {}
public function isSatisfiedBy(Customer $customer): bool
{
return $customer->tier() === $this->tier;
}
public function tier(): Tier
{
return $this->tier;
}
}
HasTier exposes its tier() so the adapter can read it later. That getter is the only concession the domain makes to persistence, and it leaks nothing: it returns a domain enum, not a column name.
Composing without a single new class
The value shows up when you combine rules. Give the interface three combinators through a trait, so every spec composes without a new leaf class per question.
<?php
declare(strict_types=1);
namespace App\Domain\Customer\Spec;
trait Composable
{
public function and(CustomerSpec $other): CustomerSpec
{
return new AndSpec($this, $other);
}
public function or(CustomerSpec $other): CustomerSpec
{
return new OrSpec($this, $other);
}
public function not(): CustomerSpec
{
return new NotSpec($this);
}
}
and, or, and not are valid method names in modern PHP even though they read like keywords. The composites are just as small.
<?php
declare(strict_types=1);
namespace App\Domain\Customer\Spec;
use App\Domain\Customer\Customer;
final readonly class AndSpec implements CustomerSpec
{
use Composable;
/** @var list<CustomerSpec> */
private array $parts;
public function __construct(CustomerSpec ...$parts)
{
$this->parts = $parts;
}
public function isSatisfiedBy(Customer $customer): bool
{
foreach ($this->parts as $part) {
if (!$part->isSatisfiedBy($customer)) {
return false;
}
}
return true;
}
/** @return list<CustomerSpec> */
public function parts(): array
{
return $this->parts;
}
}
OrSpec is the same with the boolean flipped and a short-circuit on true. NotSpec wraps one spec and negates its result. Add the Composable trait to every leaf too, and marketing's request becomes one readable line.
$spec = (new IsActive())
->and(new HasTier(Tier::Premium))
->and(new SignedUpBefore($ninetyDaysAgo))
->and((new HasOrderSince($ninetyDaysAgo))->not());
That expression is a domain object. It carries no SQL, no DQL, no table names. You can pass it into a use case, store it, log it, or hand it to two completely different adapters.
The port speaks specifications, not DQL
The repository interface loses its fourteen finders and gains one method.
<?php
declare(strict_types=1);
namespace App\Domain\Customer;
use App\Domain\Customer\Spec\CustomerSpec;
interface CustomerRepository
{
/** @return list<Customer> */
public function matching(CustomerSpec $spec): array;
}
The application layer now reads like the request that produced it. No finder proliferation, no leaking of what "premium" means into the port.
$targets = $this->customers->matching(
(new IsActive())
->and(new HasTier(Tier::Premium))
->and((new HasOrderSince($cutoff))->not()),
);
The domain has said what it wants. It has not said a word about how the database will answer.
Compiling to Criteria at the adapter
Doctrine ships the translation target already: Doctrine\Common\Collections\Criteria. An EntityRepository implements Selectable, so it accepts a Criteria through matching() and turns the expression into a real SQL WHERE at the database level. Your job is a translator that walks the spec tree and builds that Criteria. It lives in the infrastructure layer, next to the Doctrine repository, and it is the only file that imports Doctrine.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Doctrine\Spec;
use App\Domain\Customer\CustomerStatus;
use App\Domain\Customer\Spec\AndSpec;
use App\Domain\Customer\Spec\CustomerSpec;
use App\Domain\Customer\Spec\HasOrderSince;
use App\Domain\Customer\Spec\HasTier;
use App\Domain\Customer\Spec\IsActive;
use App\Domain\Customer\Spec\NotSpec;
use App\Domain\Customer\Spec\OrSpec;
use App\Domain\Customer\Spec\SignedUpBefore;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Expr\Expression;
final class CustomerCriteriaCompiler
{
public function compile(CustomerSpec $spec): Criteria
{
return Criteria::create()->where($this->expr($spec));
}
private function expr(CustomerSpec $spec): Expression
{
$e = Criteria::expr();
return match (true) {
$spec instanceof IsActive =>
$e->eq('status', CustomerStatus::Active->value),
$spec instanceof HasTier =>
$e->eq('tier', $spec->tier()->value),
$spec instanceof SignedUpBefore =>
$e->lt('signedUpAt', $spec->cutoff()),
$spec instanceof HasOrderSince =>
$e->gte('lastOrderAt', $spec->since()),
$spec instanceof AndSpec =>
$e->andX(...$this->all($spec->parts())),
$spec instanceof OrSpec =>
$e->orX(...$this->all($spec->parts())),
$spec instanceof NotSpec =>
$e->not($this->expr($spec->inner())),
default =>
throw new UnknownSpec($spec::class),
};
}
/**
* @param list<CustomerSpec> $specs
* @return list<Expression>
*/
private function all(array $specs): array
{
return array_map($this->expr(...), $specs);
}
}
The match (true) maps each spec type to an expression from Doctrine's ExpressionBuilder. Leaf specs become comparisons. AndSpec and OrSpec fold their parts with andX and orX. NotSpec uses not, which wraps the inner expression in a negated composite. The recursion handles arbitrary nesting for free, because a spec tree is exactly a tree of expressions.
The Doctrine repository wires the compiler in and stays boring.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Doctrine;
use App\Domain\Customer\Customer;
use App\Domain\Customer\CustomerRepository;
use App\Domain\Customer\Spec\CustomerSpec;
use App\Infrastructure\Persistence\Doctrine\Spec\CustomerCriteriaCompiler;
use Doctrine\ORM\EntityManagerInterface;
final readonly class DoctrineCustomerRepository implements CustomerRepository
{
public function __construct(
private EntityManagerInterface $em,
private CustomerCriteriaCompiler $compiler,
) {}
/** @return list<Customer> */
public function matching(CustomerSpec $spec): array
{
$criteria = $this->compiler->compile($spec);
return $this->em->getRepository(Customer::class)
->matching($criteria)
->toArray();
}
}
The WHERE clause is generated by Doctrine from the criteria you built. No string concatenation, no parameter binding by hand, no DQL in a domain file. And the field names in the compiler (status, tier, lastOrderAt) are the entity's mapped fields, so a rename in the mapping is caught in one place instead of fourteen.
The same spec guards in memory
Here is the part the DQL finders could never give you. That isSatisfiedBy method was not decoration. The exact spec you send to the database also validates a single object in memory, with no round trip.
public function assignToCampaign(
Customer $customer,
Campaign $campaign,
): void {
if (!$campaign->audience()->isSatisfiedBy($customer)) {
throw new CustomerNotEligible(
$customer->id(),
$campaign->id(),
);
}
$campaign->enrol($customer);
}
The rule that selected the batch is the rule that guards the individual write. One definition of "eligible," used to query and to enforce, so the list can never disagree with the check. When the eligibility rule changes, you edit one spec and both paths move together.
Where the pattern stops
Be honest about the boundary. Doctrine Criteria filters on a single entity's own scalar fields and its owning relations. It is not a general join engine. A spec that means "customers whose last three orders each exceeded 500 euro" does not compile to a flat WHERE, and forcing it through the compiler produces a lie.
Two clean exits when that happens. Denormalise the value onto the entity (a lastOrderAt or lifetimeValueCents column kept current by a domain event handler) so the spec stays a simple comparison. Or add a second port method for the genuinely relational query and let a dedicated adapter own the DQL, named for the question it answers. What you do not do is widen the CustomerSpec interface until it becomes a query builder with domain paint on it. The moment a spec grows a join() method, the SQL has leaked back in through the front door.
The test that keeps you honest: hand every leaf spec to a plain in-memory array filter and to the Doctrine compiler, and assert both return the same customers against the same fixtures. When those two agree, the domain rule and the SQL are the same rule. When they drift, you have found the leak before production did.
Keeping query intent in the domain and the SQL dialect at the adapter is the same move hexagonal architecture asks of every outbound concern: the core states what it needs in its own words, and a thin translator at the edge speaks to the machine. Decoupled PHP works through specifications, ports, and the Doctrine adapter boundary in full, so the rules that define your business outlive the ORM you happen to be using this year.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)