- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Database Playbook: Choosing the Right Store for Every System You Build
- 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 a CustomerRepository in a four-year-old Laravel codebase. There are 47 methods on it. Half are some variation of findActive, findActivePremium, findActivePremiumWithUnpaidInvoices, findActivePremiumWithUnpaidInvoicesCreatedAfter. The other half are query-builder fragments that the team gave up trying to name and just called findForReport4, findForReport4b, findForReport4bFixed.
Each one is a snowflake. Each one knows about columns, joins, and whereIn. None of them compose. When someone needs "active premium customers with unpaid invoices created after Q1", they either write a new method or copy the closest one and edit the SQL.
The repository turned into a junk drawer because the domain has no way to say what it wants. It only knows how to ask the database. The Specification pattern is a small interface that lets the domain say what it wants (isActiveAndPremiumWithUnpaidInvoices()), then lets one adapter per persistence backend figure out the SQL.
What the domain actually wants to say
Before any pattern, start with the sentence the product owner says out loud:
"Send a dunning email to every active premium customer who has at least one unpaid invoice older than 30 days."
In a junk-drawer repository, that sentence becomes a method named after the email job:
public function findForDunningEmail(): array;
Six weeks later, marketing wants the same list minus customers who already received a dunning email this month. The method grows a flag. Then it grows two flags. Then it forks into findForDunningEmailV2.
The sentence the domain wants to say is composed of three independent ideas:
- The customer is active.
- The customer is premium.
- The customer has at least one unpaid invoice older than 30 days.
Each idea is a thing on its own. Each one might also be useful in a different context: "active premium customers" for analytics, "customers with overdue invoices" for collections. The Specification pattern names each idea once and lets you compose them.
The Specification interface
The interface has one job: given a domain object, say whether it satisfies the rule.
<?php
declare(strict_types=1);
namespace App\Domain\Customer\Spec;
use App\Domain\Customer\Customer;
interface Specification
{
public function isSatisfiedBy(Customer $customer): bool;
}
That is the whole contract. Notice what it does not have: no toSql(), no toQueryBuilder(), no $em parameter. The domain interface knows nothing about Doctrine, Eloquent, or database/sql. It only knows how to answer a yes/no question about a Customer.
The in-memory implementation is the reference behavior. Every other adapter has to agree with it.
<?php
declare(strict_types=1);
namespace App\Domain\Customer\Spec;
use App\Domain\Customer\Customer;
use App\Domain\Customer\CustomerStatus;
use App\Domain\Customer\Tier;
final class IsActive implements Specification
{
public function isSatisfiedBy(Customer $customer): bool
{
return $customer->status === CustomerStatus::Active;
}
}
final class IsPremium implements Specification
{
public function isSatisfiedBy(Customer $customer): bool
{
return $customer->tier === Tier::Premium;
}
}
final class HasUnpaidInvoiceOlderThan implements Specification
{
public function __construct(
private readonly \DateTimeImmutable $cutoff,
) {}
public function isSatisfiedBy(Customer $customer): bool
{
foreach ($customer->invoices as $invoice) {
if ($invoice->isUnpaid() && $invoice->issuedAt < $this->cutoff) {
return true;
}
}
return false;
}
}
Three small classes. Each one is a sentence from the product owner, in code.
And, Or, Not — composition without inheritance
The reason specifications are worth the ceremony is composition. Three combinators carry all the weight:
<?php
declare(strict_types=1);
namespace App\Domain\Customer\Spec;
use App\Domain\Customer\Customer;
final class AndSpec implements Specification
{
/** @var list<Specification> */
public readonly array $parts;
public function __construct(Specification ...$parts)
{
$this->parts = array_values($parts);
}
public function isSatisfiedBy(Customer $customer): bool
{
foreach ($this->parts as $part) {
if (! $part->isSatisfiedBy($customer)) {
return false;
}
}
return true;
}
}
final class OrSpec implements Specification
{
/** @var list<Specification> */
public readonly array $parts;
public function __construct(Specification ...$parts)
{
$this->parts = array_values($parts);
}
public function isSatisfiedBy(Customer $customer): bool
{
foreach ($this->parts as $part) {
if ($part->isSatisfiedBy($customer)) {
return true;
}
}
return false;
}
}
final class NotSpec implements Specification
{
public function __construct(
public readonly Specification $inner,
) {}
public function isSatisfiedBy(Customer $customer): bool
{
return ! $this->inner->isSatisfiedBy($customer);
}
}
Now the dunning sentence has a home. It is a named class that composes the three primitives:
<?php
declare(strict_types=1);
namespace App\Domain\Customer\Spec;
final class IsActiveAndPremiumWithUnpaidInvoices implements Specification
{
private readonly AndSpec $inner;
public function __construct(\DateTimeImmutable $now)
{
$thirtyDaysAgo = $now->modify('-30 days');
$this->inner = new AndSpec(
new IsActive(),
new IsPremium(),
new HasUnpaidInvoiceOlderThan($thirtyDaysAgo),
);
}
public function isSatisfiedBy(\App\Domain\Customer\Customer $customer): bool
{
return $this->inner->isSatisfiedBy($customer);
}
}
The domain service that schedules dunning emails reads like the product owner's sentence:
$spec = new IsActiveAndPremiumWithUnpaidInvoices($clock->now());
$customers = $repository->matching($spec);
No findForDunningEmail. No findForDunningEmailV2. No flags. If marketing later wants to exclude customers who already got an email this month, that is a new specification AND-ed onto this one. The existing one stays untouched.
The repository: one method instead of forty-seven
The repository port shrinks to a single query method:
<?php
declare(strict_types=1);
namespace App\Domain\Customer;
use App\Domain\Customer\Spec\Specification;
interface CustomerRepository
{
public function save(Customer $customer): void;
public function ofId(CustomerId $id): ?Customer;
/** @return list<Customer> */
public function matching(Specification $spec): array;
}
save and ofId are the two operations every aggregate repository needs. matching is where all the open-ended querying lives. Forty-seven snowflake methods collapse into one.
The in-memory adapter is the trivial implementation. It loads every customer into memory and filters in PHP. It is fine for tests and for small fixed datasets. The Doctrine adapter is the one that does real work.
<?php
declare(strict_types=1);
namespace App\Adapter\Persistence\InMemory;
use App\Domain\Customer\Customer;
use App\Domain\Customer\CustomerId;
use App\Domain\Customer\CustomerRepository;
use App\Domain\Customer\Spec\Specification;
final class InMemoryCustomerRepository implements CustomerRepository
{
/** @var array<string, Customer> */
private array $store = [];
public function save(Customer $customer): void
{
$this->store[$customer->id->value] = $customer;
}
public function ofId(CustomerId $id): ?Customer
{
return $this->store[$id->value] ?? null;
}
public function matching(Specification $spec): array
{
return array_values(array_filter(
$this->store,
static fn(Customer $c): bool => $spec->isSatisfiedBy($c),
));
}
}
That is the entire test database. Unit tests for the dunning use case do not need Docker, Doctrine, or a schema.
Translating to SQL without leaking it back
The Doctrine adapter is where things get interesting. The domain Specification has no toSql() method, but somebody has to turn AndSpec(IsActive, IsPremium, HasUnpaidInvoiceOlderThan) into a real query. The trick is that the translation lives in the adapter, not in the specification. The specification stays a yes/no predicate. The adapter visits the specification tree and emits DQL or query-builder expressions.
There are two real options. The first is to keep matching() simple and have a small visitor class in the adapter. The second is to give up some composition flexibility and use Doctrine's query builder directly inside dedicated translator methods per concrete specification.
The visitor reads cleaner once you have more than three specifications. Here is the shape:
<?php
declare(strict_types=1);
namespace App\Adapter\Persistence\Doctrine;
use App\Domain\Customer\Spec\AndSpec;
use App\Domain\Customer\Spec\HasUnpaidInvoiceOlderThan;
use App\Domain\Customer\Spec\IsActive;
use App\Domain\Customer\Spec\IsPremium;
use App\Domain\Customer\Spec\NotSpec;
use App\Domain\Customer\Spec\OrSpec;
use App\Domain\Customer\Spec\Specification;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
final class DoctrineSpecificationTranslator
{
private int $paramCounter = 0;
public function applyTo(QueryBuilder $qb, Specification $spec, string $alias): void
{
$qb->andWhere($this->build($qb, $spec, $alias));
}
private function build(QueryBuilder $qb, Specification $spec, string $alias): mixed
{
return match (true) {
$spec instanceof AndSpec => $this->buildAnd($qb, $spec, $alias),
$spec instanceof OrSpec => $this->buildOr($qb, $spec, $alias),
$spec instanceof NotSpec => $this->buildNot($qb, $spec, $alias),
$spec instanceof IsActive => "$alias.status = 'ACTIVE'",
$spec instanceof IsPremium => "$alias.tier = 'PREMIUM'",
$spec instanceof HasUnpaidInvoiceOlderThan
=> $this->buildHasUnpaidInvoice($qb, $spec, $alias),
default => throw new \LogicException(
'No Doctrine translation for ' . $spec::class,
),
};
}
private function buildAnd(QueryBuilder $qb, AndSpec $spec, string $alias): Expr\Andx
{
$and = $qb->expr()->andX();
foreach ($spec->parts as $part) {
$and->add($this->build($qb, $part, $alias));
}
return $and;
}
private function buildOr(QueryBuilder $qb, OrSpec $spec, string $alias): Expr\Orx
{
$or = $qb->expr()->orX();
foreach ($spec->parts as $part) {
$or->add($this->build($qb, $part, $alias));
}
return $or;
}
private function buildNot(QueryBuilder $qb, NotSpec $spec, string $alias): string
{
return 'NOT (' . $this->build($qb, $spec->inner, $alias) . ')';
}
The combinators only know how to combine. The leaf cases below are where SQL columns and joins finally appear.
private function buildHasUnpaidInvoice(
QueryBuilder $qb,
HasUnpaidInvoiceOlderThan $spec,
string $alias,
): string {
$param = 'cutoff_' . $this->paramCounter++;
$qb->setParameter($param, $spec->cutoff);
return "EXISTS (
SELECT 1 FROM App\\Domain\\Invoice\\Invoice i
WHERE i.customer = $alias
AND i.paidAt IS NULL
AND i.issuedAt < :$param
)";
}
}
The Doctrine repository now becomes thin:
<?php
declare(strict_types=1);
namespace App\Adapter\Persistence\Doctrine;
use App\Domain\Customer\Customer;
use App\Domain\Customer\CustomerId;
use App\Domain\Customer\CustomerRepository;
use App\Domain\Customer\Spec\Specification;
use Doctrine\ORM\EntityManagerInterface;
final class DoctrineCustomerRepository implements CustomerRepository
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly DoctrineSpecificationTranslator $translator,
) {}
public function save(Customer $customer): void
{
$this->em->persist($customer);
$this->em->flush();
}
public function ofId(CustomerId $id): ?Customer
{
return $this->em->find(Customer::class, $id->value);
}
public function matching(Specification $spec): array
{
$qb = $this->em->createQueryBuilder()
->select('c')
->from(Customer::class, 'c');
$this->translator->applyTo($qb, $spec, 'c');
return $qb->getQuery()->getResult();
}
}
Two important properties of this design:
The domain has zero knowledge that Doctrine exists. The Specification classes are pure PHP. They would run identically under Symfony, Laravel, a Slim app, or no framework at all. Swap Doctrine for Eloquent and only the translator and the repository implementation change.
The translator is the only place that knows about SQL columns and joins. When the database schema changes (rename tier to subscription_tier, move invoices to a separate database), exactly one file gets edited. The domain stays still.
Testing the use case without a database
The shape pays off in the test suite. The dunning use case can be exercised against the in-memory repository, with no Doctrine, no schema, no Docker:
<?php
declare(strict_types=1);
use App\Adapter\Persistence\InMemory\InMemoryCustomerRepository;
use App\Domain\Customer\Spec\IsActiveAndPremiumWithUnpaidInvoices;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class DunningSelectionTest extends TestCase
{
#[Test]
public function picks_active_premium_customers_with_overdue_invoices(): void
{
$repo = new InMemoryCustomerRepository();
$repo->save(CustomerFixtures::activePremiumWithOverdueInvoice());
$repo->save(CustomerFixtures::activeFreeWithOverdueInvoice());
$repo->save(CustomerFixtures::activePremiumPaidUp());
$repo->save(CustomerFixtures::cancelledPremiumWithOverdueInvoice());
$now = new DateTimeImmutable('2026-05-18');
$matches = $repo->matching(
new IsActiveAndPremiumWithUnpaidInvoices($now),
);
$this->assertCount(1, $matches);
$this->assertSame('cust-overdue', $matches[0]->id->value);
}
}
Four fixtures, one assertion, runs in milliseconds. The same specification then goes through the Doctrine adapter in a small integration test that confirms the translator emits the right SQL: one test per primitive, not one per business sentence.
When this is overkill
Specifications are not free. Three combinators and one interface per concept means more types in the project. Skip them when:
- The repository has fewer than ten query methods and they all stay distinct (admin CRUD, lookup-by-id workflows).
- The query parameters are flat and shared across the app (
findByStatus,findByEmail), and there is no composition pressure. - The team is small and the query patterns are stable. A junk drawer only hurts when it grows.
Reach for them when:
- Repository methods are sprouting suffixes (
V2,Full,WithDetails,ForReport4b). - The same three or four predicates keep showing up in different combinations.
- The domain language and the SQL have drifted so far apart that new hires read methods backwards from the SQL to guess what they do.
- You want the test suite to run business rules without a database: fast, deterministic, no fixtures-via-migrations.
The pattern's payoff scales with the size of the domain. A 47-method repository becomes a save / ofId / matching interface plus a folder of small named predicates. The next ten queries cost zero new methods.
If this was useful
Specifications are one of a handful of patterns that, once installed, change how the rest of the code wants to be shaped. Decoupled PHP walks the full hexagonal layout in PHP 8.3: ports, adapters, use cases, repositories, and the boundaries that keep Doctrine, Laravel, and Symfony out of your domain. Database Playbook is the companion when the question shifts from "how do I query" to "what store should this even live in."
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)