Book: + | | 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.
CODE<?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.
CODE<?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;
}
}
CODE<?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;
}
}
HasTierexposes itstier()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.
CODE<?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, andnotare valid method names in modern PHP even though they read like keywords. The composites are just as small.
CODE<?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;
}
}
OrSpecis the same with the boolean flipped and a short-circuit ontrue.NotSpecwraps one spec and negates its result. Add theComposabletrait to every leaf too, and marketing's request becomes one readable line.
CODE$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.
CODE<?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.
CODE$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. AnEntityRepositoryimplementsSelectable, so it accepts aCriteriathroughmatching()and turns the expression into a real SQLWHEREat the database level. Your job is a translator that walks the spec tree and builds thatCriteria. It lives in the infrastructure layer, next to the Doctrine repository, and it is the only file that imports Doctrine.
CODE<?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'sExpressionBuilder. Leaf specs become comparisons.AndSpecandOrSpecfold their parts withandXandorX.NotSpecusesnot, 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.
CODE<?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
WHEREclause 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
isSatisfiedBymethod was not decoration. The exact spec you send to the database also validates a single object in memory, with no round trip.
CODEpublic 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
Criteriafilters 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 flatWHERE, and forcing it through the compiler produces a lie.
Two clean exits when that happens. Denormalise the value onto the entity (a
lastOrderAtorlifetimeValueCentscolumn 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 theCustomerSpecinterface until it becomes a query builder with domain paint on it. The moment a spec grows ajoin()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.
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
Ähnliche Beiträge
Auch interessante Nachrichten The Specification Pattern With Doctrine Criteria: Queries That Don't Leak
Thematisch verwandte Begriffe: Specification, Pattern, With, Doctrine · 6 Treffer
How to Build AI Systems That Know When They Don't Know: A Practical Guide
How to evaluate LLMs before production
Videos werden geladen ...
Beiträge werden geladen ...
Videos werden geladen ...
Beiträge werden geladen ...
Videos werden geladen ...
Beiträge werden geladen ...
Videos werden geladen ...
Beiträge werden geladen ...
Videos werden geladen ...

SOCIAL SHARE CARD GENERATOR