🕵️ SicherheitslückenHak5: Hackers Just Poisoned the Rust Supply Chain | Threat Wire(01.09.2026 um 14:00 Uhr)
🕵️ SicherheitslückenHak5: Hackers Found a Way Into Humanoid Robots | Threat Wire(04.09.2026 um 15:04 Uhr)
🔧 AI Nachrichten Bits und so #1021 (Passwort für Laufwerk)(31.08.2026 um 22:15 Uhr)
🔧 AI Nachrichten Bits und so #1022 (Wie Weißbier)(06.09.2026 um 20:39 Uhr)
🍏 iOS / Mac OSHue-App 6.0 ist da: das sind die Neuerungen(07.09.2026 um 17:21 Uhr)
🕵️ SicherheitslückenHak5: Hackers Just Poisoned the Rust Supply Chain | Threat Wire(01.09.2026 um 14:00 Uhr)
🕵️ SicherheitslückenHak5: Hackers Found a Way Into Humanoid Robots | Threat Wire(04.09.2026 um 15:04 Uhr)
🔧 AI Nachrichten Bits und so #1021 (Passwort für Laufwerk)(31.08.2026 um 22:15 Uhr)
🔧 AI Nachrichten Bits und so #1022 (Wie Weißbier)(06.09.2026 um 20:39 Uhr)
🍏 iOS / Mac OSHue-App 6.0 ist da: das sind die Neuerungen(07.09.2026 um 17:21 Uhr)

🔧 Programmierung 🕛 vor 2 Monaten 9 Min Lesezeit SECURITY-FEED
0

The Specification Pattern With Doctrine Criteria: Queries That Don't Leak

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht


  • 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;
    }
    }






    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.




    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, and not are 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;
    }
    }






    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.




    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. 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.




    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'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.




    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 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.




    CODE
    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.



    Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework



    Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

    Vollständiger Original-Bericht
    Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
    ↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:
Community Threat-Level Barometer
Live Votum

Wie stufst du das Risiko dieser Schwachstelle / Bedrohung für dein Unternehmen ein?

Noch keine Stimmen — schätze das Risiko als Erster ein.

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
1 Quelle
Hackers Just Poisoned the Rust Supply Chain | Threat Wire
1 Quelle
Hackers Found a Way Into Humanoid Robots | Threat Wire
1 Quelle
Bits und so #1021 (Passwort für Laufwerk)
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten The Specification Pattern With Doctrine Criteria: Queries That Don't Leak

Thematisch verwandte Begriffe: Specification, Pattern, With, Doctrine · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...