Specification Pattern¶
Encapsulate business rules into reusable, composable objects. Each rule is a single class; combine them with logical operators (and, or, not) to build complex business logic from small, testable pieces.
Specification (interface)
│ isSatisfiedBy(mixed): bool
│ and(Specification): Specification
│ or(Specification): Specification
│ not(): Specification
│
└── CompositeSpecification (abstract)
│ provides and(), or(), not() for free
│
├── AndSpecification -- $a && $b
├── OrSpecification -- $a || $b
├── NotSpecification -- !$a
└── YourDomainRule -- extend CompositeSpecification
Table of Contents¶
- The Interface: Specification
- The Base: CompositeSpecification
- The Composites
- Writing Your First Specification
- Composition Examples
- The Evaluation Tree
- Best Practices
- Complete Example: Order Discount Eligibility
The Interface: Specification¶
Fight\Common\Domain\Specification\Specification
Every specification implements this contract:
interface Specification
{
public function isSatisfiedBy(mixed $candidate): bool;
public function and(Specification $other): Specification;
public function or(Specification $other): Specification;
public function not(): Specification;
}
The single evaluation method isSatisfiedBy takes a candidate (any domain object) and returns true or false. The three combinator methods return new composite specifications, enabling fluent chaining.
The Base: CompositeSpecification¶
Fight\Common\Domain\Specification\CompositeSpecification
Extend this abstract class to create your business rules. You only need to implement isSatisfiedBy — the three combinator methods are provided for free:
abstract class CompositeSpecification implements Specification
{
abstract public function isSatisfiedBy(mixed $candidate): bool;
public function and(Specification $other): Specification
{
return new AndSpecification($this, $other);
}
public function or(Specification $other): Specification
{
return new OrSpecification($this, $other);
}
public function not(): Specification
{
return new NotSpecification($this);
}
}
Each combinator returns a new composite node that wraps $this with $other. Nothing is mutated — the tree is immutable.
The Composites¶
Three concrete classes implement the boolean operations. All are final — you compose them via the combinator methods, not by extending them.
| Class | Operator | Behavior |
|---|---|---|
AndSpecification |
&& |
Both sub-specifications must be satisfied |
OrSpecification |
|| |
At least one sub-specification must be satisfied |
NotSpecification |
! |
The wrapped specification must not be satisfied |
Internally they are simple wrappers around PHP's native operators:
// AndSpecification
public function isSatisfiedBy(mixed $candidate): bool
{
return $this->firstSpec->isSatisfiedBy($candidate)
&& $this->secondSpec->isSatisfiedBy($candidate);
}
// OrSpecification
public function isSatisfiedBy(mixed $candidate): bool
{
return $this->firstSpec->isSatisfiedBy($candidate)
|| $this->secondSpec->isSatisfiedBy($candidate);
}
// NotSpecification
public function isSatisfiedBy(mixed $candidate): bool
{
return !$this->spec->isSatisfiedBy($candidate);
}
Writing Your First Specification¶
Ad-Hoc Rule (Anonymous Class)¶
For one-off checks in tests or simple filters, create an anonymous class extending CompositeSpecification:
$isPremium = new class extends CompositeSpecification {
public function isSatisfiedBy(mixed $candidate): bool
{
return $candidate->tier === 'premium';
}
};
$isPremium->isSatisfiedBy($user); // true or false
Named Rule (Reusable)¶
Extract the logic into a named class when the rule is used in multiple places or needs configuration:
use Fight\Common\Domain\Specification\CompositeSpecification;
class PremiumCustomerSpecification extends CompositeSpecification
{
public function __construct(
private readonly float $minAnnualRevenue
) {
}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!($candidate instanceof Customer)) {
return false;
}
return $candidate->tier === 'premium'
&& $candidate->annualRevenue >= $this->minAnnualRevenue;
}
}
$spec = new PremiumCustomerSpecification(100000.0);
$spec->isSatisfiedBy($customer);
The $candidate has no type constraint on the interface — use instanceof or assert in your implementation to guard.
Composition Examples¶
Build complex business rules from small, single-responsibility leaf specs.
Leaf Specifications¶
Each checks one thing:
class IsAdminSpecification extends CompositeSpecification
{
public function isSatisfiedBy(mixed $candidate): bool
{
return in_array('admin', $candidate->roles, true);
}
}
class IsActiveSpecification extends CompositeSpecification
{
public function isSatisfiedBy(mixed $candidate): bool
{
return $candidate->status === 'active';
}
}
class IsBannedSpecification extends CompositeSpecification
{
public function isSatisfiedBy(mixed $candidate): bool
{
return $candidate->status === 'banned';
}
}
class TeamNotFullSpecification extends CompositeSpecification
{
public function __construct(private readonly int $maxMembers)
{
}
public function isSatisfiedBy(mixed $candidate): bool
{
return count($candidate->members) < $this->maxMembers;
}
}
And / Or¶
Combine them fluently:
// User must be admin AND active
$canAccessAdmin = (new IsAdminSpecification())
->and(new IsActiveSpecification());
// User must have a role: editor OR admin
$hasEditorialRole = (new IsEditorSpecification())
->or(new IsAdminSpecification());
Negation¶
// User must be active AND NOT banned
$canAccess = (new IsActiveSpecification())
->and(new IsBannedSpecification()->not());
Chained Composition¶
// Admin or editor, active, and not banned
$canPublish = (new IsAdminSpecification())
->or(new IsEditorSpecification())
->and(new IsActiveSpecification())
->and(new IsBannedSpecification()->not());
Parameterized Rules¶
// Not full AND (admin OR editor) AND active AND not banned
$canInviteMember = (new TeamNotFullSpecification(10))
->and(new IsAdminSpecification())
->or(new IsEditorSpecification())
->and(new IsActiveSpecification())
->and(new IsBannedSpecification()->not());
The Evaluation Tree¶
Each combinator creates a node in a tree. When isSatisfiedBy is called, the tree is traversed depth-first.
$spec = (new IsAdminSpecification())
->or(new IsEditorSpecification())
->and(new IsActiveSpecification());
Produces this structure:
AndSpecification
├── OrSpecification
│ ├── IsAdminSpecification
│ └── IsEditorSpecification
└── IsActiveSpecification
Evaluation for a user who is an editor and active:
spec.isSatisfiedBy($user)
└─► AndSpecification
├─► OrSpecification
│ ├─► IsAdmin.isSatisfiedBy($user) → false
│ └─► IsEditor.isSatisfiedBy($user) → true
│ result: true
└─► IsActive.isSatisfiedBy($user) → true
result: true
The not() combinator wraps its inner spec in a NotSpecification node that inverts the result:
Best Practices¶
- One rule per class. A specification should check a single business concept. Compose narrow specs to build broad rules.
- Name after the business concept.
PremiumCustomerSpecificationnotHighRevenueAndTierSpecification. - Guard the candidate type. The interface accepts
mixed; useinstanceoforassertat the top ofisSatisfiedBy. - Use anonymous classes for test stubs. When a test needs a specification that always returns a known value, an anonymous class is cleaner than a mock.
- Keep specs in the domain layer. Specifications express business rules — they belong in
src/Domain/Specification/alongside your entities. - Parameterize through the constructor. Let callers configure thresholds, limits, or collections rather than hard-coding them.
Complete Example: Order Discount Eligibility¶
A realistic domain rule: an order qualifies for a promotional discount when the customer is a loyalty member, the order total meets a minimum, the coupon code is valid, and the current date falls within the promotion window.
Leaf Specifications¶
class LoyalCustomerSpecification extends CompositeSpecification
{
public function isSatisfiedBy(mixed $candidate): bool
{
return $candidate->customer->loyaltyTier >= 2;
}
}
class MinimumOrderSpecification extends CompositeSpecification
{
public function __construct(private readonly float $minTotal)
{
}
public function isSatisfiedBy(mixed $candidate): bool
{
return $candidate->total >= $this->minTotal;
}
}
class ValidCouponSpecification extends CompositeSpecification
{
/** @var string[] */
private readonly array $validCoupons;
public function __construct(string ...$validCoupons)
{
$this->validCoupons = $validCoupons;
}
public function isSatisfiedBy(mixed $candidate): bool
{
return in_array($candidate->couponCode, $this->validCoupons, true);
}
}
class DateRangeSpecification extends CompositeSpecification
{
public function __construct(
private readonly DateTimeImmutable $start,
private readonly DateTimeImmutable $end
) {
}
public function isSatisfiedBy(mixed $candidate): bool
{
$now = new DateTimeImmutable();
return $now >= $this->start && $now <= $this->end;
}
}
Composed Rule¶
$eligibleForDiscount = (new LoyalCustomerSpecification())
->and(new MinimumOrderSpecification(50.0))
->and(new ValidCouponSpecification('SAVE20', 'WELCOME10'))
->and(new DateRangeSpecification(
new DateTimeImmutable('2026-06-01'),
new DateTimeImmutable('2026-09-01')
));
if ($eligibleForDiscount->isSatisfiedBy($order)) {
// apply discount
}
Reuse Through Composition¶
The same leaf specs can be combined differently for other business rules:
// Early-access program: loyal OR has a special coupon
$earlyAccess = (new LoyalCustomerSpecification())
->or(new ValidCouponSpecification('EARLY2026'));
// VIP treatment: loyal AND high-value, regardless of coupon
$vipTreatment = (new LoyalCustomerSpecification())
->and(new MinimumOrderSpecification(200.0));
Each leaf spec is independently testable and can be composed in any combination without modification — the pattern grows with your domain logic rather than requiring new classes for every permutation.