The specification pattern is one of those design patterns that sounds academic until you have a controller with twelve query parameters and an if/else chain that has grown for a year. It is a tool for treating query criteria as first-class objects: composable, reusable, and named.
The pattern shows up most often in places where business rules drive queries — "active customers in the EU who have spent more than $1,000 in the last 90 days" — and where those rules appear in multiple places.
The Problem
A search endpoint accepts filters: status, tier, country, signup date range, minimum spend. The naive implementation builds a query inline.
public function search(Request $request)
{
$query = User::query();
if ($request->has('status')) {
$query->where('status', $request->status);
}
if ($request->has('tier')) {
$query->where('tier', $request->tier);
}
if ($request->has('country')) {
$query->where('country', $request->country);
}
if ($request->has('signed_up_after')) {
$query->where('created_at', '>=', $request->signed_up_after);
}
if ($request->has('min_spend')) {
$query->whereHas('orders', function ($q) use ($request) {
$q->selectRaw('user_id, SUM(total) as t')
->groupBy('user_id')
->havingRaw('SUM(total) >= ?', [$request->min_spend]);
});
}
// ... five more
return $query->paginate(20);
}
This works. Then six months later, you need the same filters for a CSV export. Then for a notification campaign target list. Then for a report. Each copy diverges slightly.
The Specification Pattern
A specification is a small object representing one piece of query logic. Specifications compose.
interface UserSpecification
{
public function apply(Builder $query): Builder;
}
final class ActiveUsers implements UserSpecification
{
public function apply(Builder $query): Builder
{
return $query->where('status', 'active');
}
}
final class UsersInCountry implements UserSpecification
{
public function __construct(private string $country) {}
public function apply(Builder $query): Builder
{
return $query->where('country', $this->country);
}
}
final class UsersWhoSpentAtLeast implements UserSpecification
{
public function __construct(private int $amountCents) {}
public function apply(Builder $query): Builder
{
return $query->whereExists(fn ($q) =>
$q->selectRaw('1')
->from('orders')
->whereColumn('orders.user_id', 'users.id')
->groupBy('orders.user_id')
->havingRaw('SUM(orders.total_cents) >= ?', [$this->amountCents])
);
}
}
Each specification is a class with one job: applying one criterion to a query. They are tiny, named, and easy to test.
Composing Specifications
Specifications combine into a pipeline:
final class UserQuery
{
/** @var UserSpecification[] */
private array $specifications = [];
public function withSpecification(UserSpecification $spec): static
{
$clone = clone $this;
$clone->specifications[] = $spec;
return $clone;
}
public function build(): Builder
{
return collect($this->specifications)->reduce(
fn (Builder $q, UserSpecification $s) => $s->apply($q),
User::query()
);
}
}
// Usage
$users = (new UserQuery())
->withSpecification(new ActiveUsers())
->withSpecification(new UsersInCountry('US'))
->withSpecification(new UsersWhoSpentAtLeast(100000))
->build()
->get();
The same composition works in the controller, the export job, and the campaign target list. Each adds the specifications it needs. The criteria are reusable.
When Specifications Pay Off
Cross-cutting query logic. When the same filter logic appears in multiple places — controllers, jobs, reports — specifications eliminate copy-paste.
Business rules that change. "Active customer" might mean status = active today and status = active AND email_verified tomorrow. Changing the ActiveUsers specification updates every usage at once.
Complex composition from user input. A search UI with many optional filters maps cleanly onto specifications.
Testing. Each specification can be tested in isolation. A test for the composite is much simpler than a test for an inline 200-line query method.
When They Cost More
Single-use criteria. A filter used in exactly one place is better expressed inline. The specification overhead is unjustified.
Trivial criteria. where('status', 'active') does not need a class around it. The class adds nothing.
Performance-critical aggregations. Specifications encourage repeated whereExists patterns. For high-frequency reports, hand-tuned SQL beats composed specifications. Profile before deciding.
Heavy joins that interact. Specifications compose well for WHERE clauses; they compose poorly when multiple criteria need shared joins. The pattern can produce duplicate joins that hurt performance.
A Practical Variant — Named Scopes Plus Specifications
Eloquent scopes give you the named-query benefit without the class proliferation. Use them when the criterion is model-local.
final class User extends Model
{
public function scopeActive(Builder $q): Builder { return $q->where('status', 'active'); }
public function scopeInCountry(Builder $q, string $c): Builder { return $q->where('country', $c); }
}
User::active()->inCountry('US')->get();
This is most of the specification pattern's benefit with a fraction of the code. Add specification classes for criteria that cross model boundaries or that are too complex for a scope.
Negation and Combination
Sometimes you need NOT spec or spec1 AND NOT spec2. The pattern handles it with a wrapper:
final class NotSpecification implements UserSpecification
{
public function __construct(private UserSpecification $inner) {}
public function apply(Builder $query): Builder
{
return $query->whereNot(fn ($q) => $this->inner->apply($q));
}
}
$query->withSpecification(new NotSpecification(new UsersInCountry('US')));
OR composition is harder — applying two specifications combines them with AND. For OR, you need a different shape:
final class AnyOfSpecification implements UserSpecification
{
public function __construct(private array $specs) {}
public function apply(Builder $query): Builder
{
return $query->where(function ($q) {
foreach ($this->specs as $spec) {
$q->orWhere(fn ($q2) => $spec->apply($q2));
}
});
}
}
These extensions add real value when the query logic actually has boolean structure. They are overkill if you only need straight AND.
Specifications as Documentation
The hidden benefit of specifications is that they name business concepts. UsersWhoSpentAtLeast is a phrase a product manager understands. where('total_cents', '>=', ?) is implementation detail.
A specification-heavy codebase reads like the business rules it implements. That alone is worth the cost in many projects — especially in domains where the rules are subtle and named concepts matter.
When to Adopt
Reach for specifications when:
- Query logic is duplicated across controllers, jobs, or reports
- Business rules have names that should appear in the code
- Filter composition from user input is more than three or four criteria
- Tests are easier to write per criterion than per composite
Skip the pattern when query logic is local, simple, and unlikely to be reused. Eloquent scopes get you 80% of the benefit at 20% of the cost.
Looking at a codebase where the same query logic shows up six places with subtle variations? We help teams refactor toward composable, named query patterns without overshooting into ceremony. scopeforged.com