maartenpaauw/laravel-specification-pattern
A Laravel package for the specification pattern, letting you encapsulate business rules into reusable, composable specifications and apply them to Eloquent queries or collections for cleaner, testable domain logic.
composer require maartenpaauw/laravel-specification-pattern
The package is auto-discovered on Laravel 9+. No configuration file or service provider wiring is required — it just exposes a handful of interfaces, an abstract base class, and concerns/traits that you mix into your own classes.
Where to look first (in the repo, in this order):
src/Specification.php — the contract with isSatisfiedBy(mixed $candidate): bool. This is the only method you must implement.src/AbstractSpecification.php — base class that gives you and(), or(), not() composition so you don't have to hand-roll boolean trees.src/CompositeSpecification.php / the And / Or / Not concrete classes — how combined specs are wired internally (useful if you need custom composites).src/Concerns/ — these are what let you attach specification filtering to Eloquent models/builders rather than only bare Collection instances.Skim those four things and you have read essentially the whole package — it is deliberately tiny.
First use case: filtering an in-memory collection.
The canonical example is filtering a collection of domain objects by a business rule that would otherwise be a sprawl of where() closures inside a controller:
use MaartenPaauw\Specifications\Specification;
final class IsOverdueInvoice implements Specification
{
public function isSatisfiedBy(mixed $candidate): bool
{
return $candidate->due_at->isPast()
&& $candidate->paid_at === null;
}
}
Then:
$overdue = $invoices->filter(
fn ($invoice) => (new IsOverdueInvoice())->isSatisfiedBy($invoice)
);
Once that works, the second step is composition — the pattern only pays off when you combine specs:
$spec = (new IsOverdueInvoice())->and(new BelongsToAccount($accountId));
Start there before touching Eloquent; the collection path is where the package is most obviously useful and where you can see whether the abstractions fit your domain.
Keep atomic specs small and stateless. Each concrete specification should answer one question about one candidate. Inject required context (e.g. an account ID, a date) through the constructor as readonly properties, not through method arguments. This keeps them trivially testable and safe to reuse inside composites:
final class HasMinimumBalance implements Specification
{
public function __construct(private readonly int $threshold) {}
public function isSatisfiedBy(mixed $candidate): bool
{
return $candidate->balance >= $this->threshold;
}
}
Compose, don't chain. Prefer building the tree once and reusing it in three places (validation, filtering, reporting) rather than repeating the same boolean logic per call site:
$eligible = (new HasMinimumBalance(1_000))
->and(new IsVerifiedAccount())
->and((new IsSuspended())->not());
Because CompositeSpecification wraps child specs, each composite is itself a Specification — so composites nest, and you can build named composites (ActivePremiumCustomer) that read like business vocabulary. This is the single biggest readability win over raw Collection::filter closures.
Wrap it in a trait for Eloquent. The most common day-to-day integration is a HasSpecifications concern on a model that converts a specification into a query scope. Because Eloquent can't evaluate isSatisfiedBy on a hydrated model without loading every row, the practical pattern is either:
Model::all()->filter(...) is fine up to low thousands of rows.applyToQuery(Builder $query): Builder method (or a second interface) and have the trait dispatch on it. You keep the "one class = one rule" organisation while still hitting the database.If you go the second route, make the query-side and in-memory sides live in the same class so they cannot drift. A spec that lies about one of its two answers is the worst failure mode of this pattern.
Use specs as injection points for services. Because a spec is just an object with a boolean method, it is a natural seam for feature flags and configuration:
final class IsFeatureEnabled implements Specification
{
public function __construct(private readonly Feature $feature) {}
public function isSatisfiedBy(mixed $candidate): bool
{
return Feature::active($this->feature);
}
}
Test specs in isolation and test the composite as a table. A data provider against isSatisfiedBy gives you exhaustive coverage cheaply; a second small test suite per composite verifies the truth table. You rarely need to test specs through HTTP or the query builder once the individual rules are trusted.
Reuse spec instances in long collection pipelines — $spec = ...; $a = $items->filter(fn ($i) => $spec->isSatisfiedBy($i)); $b = $items->reject(fn ($i) => $spec->isSatisfiedBy($i)); — so the definition of "matches" lives in exactly one place.
Collections, not queries. Despite Eloquent-flavoured traits, the core contract is a pure PHP boolean check. ->and()/->or() do not build SQL — anything you write with composites is evaluated after hydration. For large tables, either push the logic into query scopes yourself or accept the memory cost; do not assume the package turns specs into WHERE clauses.
N+1 hiding inside specs. A spec that touches $candidate->relation->something will silently lazy-load once per element. Model::preventLazyLoading() in AppServiceProvider will surface this loudly in development — turn it on early.
Type the candidate loosely but document it. The contract is mixed, so a mistyped collection produces a bool from PHP's loose comparison or a TypeError deep inside a nested composite. Either add a public function supports(mixed $candidate): bool guard or use @implements Specification docblocks with a concrete type hint in each class; the failure is otherwise very hard to trace back through AndSpecification.
Keep specs immutable. Composites hold references to their children. If a spec carries mutable state (a running counter, a memoized array), reusing it across three filters in one request will produce results that depend on call order. Treat every spec as readonly unless you have a strong reason.
Boolean precedence is not visible in fluent chains. $a->and($b)->or($c) is evaluated left-to-right by the wrapper objects, but readers (and future you) will assume and binds tighter, as in SQL. For anything with more than two children, name the intermediate composites: $active = $a->and($b); $active->or($c)->not();.
not() on a composite is a full negation, not a De Morgan shortcut — verify the truth table in a test rather than reasoning about it. This is the classic bug when someone adds a third condition.
Serialization for caching. Composite specs hold child instances, so they serialize cleanly if every leaf is constructor-only state. If you cache a specification alongside a query result (e.g. in a repository layer), add a __serialize/__unserialize test — closures or injected services will break serialize() at runtime, far from the spec itself.
Debugging: dump the tree. There is no built-in explain(). When a composite gives the wrong answer, temporarily walk it:
collect([$a, $b, $c])->map(fn ($s) => [get_class($s), $s->isSatisfiedBy($row)]);
Adding a __toString() to your base spec (name + constructor args) makes the composite readable in logs and test failures.
Extension point: subclass CompositeSpecification when you need a third combinator. "At least N of these N" and "exactly one of" are common business rules that and/or/not cannot express. A AtLeastSpecification that loops its children is ~10 lines and keeps call sites declarative.
Don't over-abstract. With a package this small, the real risk is wrapping it in a SpecificationService, a repository and a factory. The pattern earns its keep at roughly 3+ combinable rules per entity; below that, a plain filter() closure is clearer and easier to delete.
Version note: the package is pre-1.0 in spirit (single-digit star count, small surface, latest release 2026-03-23). Pin the version in composer.json and read the contract file after any upgrade — the isSatisfiedBy signature and the trait names are the only things that could plausibly change and both are easy to spot-check.
How can I help you explore Laravel packages today?