Specification is a pattern that encapsulates a business rule as a composable object. Instead of scattering if-else checks throughout the codebase, you name the rule, make it testable, and combine it with AND, OR, NOT. I show a PHP implementation with composition operators and apply it to product eligibility rules in Magento 2.
The problem – scattered business rules
<?php
// Business rule scattered across multiple places
class DiscountService
{
public function isEligible(array $product, array $customer): bool
{
// Rule is buried in a method, not named, not reusable
return $product['price'] > 100
&& $customer['group'] === 'wholesale'
&& $product['stock'] > 0
&& !in_array($product['category_id'], [5, 12, 18]); // what are these?
}
}
// Same rule duplicated in the email notifier:
class DiscountEmailNotifier
{
public function shouldNotify(array $product, array $customer): bool
{
return $product['price'] > 100 // copy-paste
&& $customer['group'] === 'wholesale'
&& $product['stock'] > 0;
// Forgot the category exclusion!
}
}
Specification base implementation
<?php
declare(strict_types=1);
// Base Specification - immutable, composable
abstract class Specification
{
abstract public function isSatisfiedBy(mixed $candidate): bool;
// Composition operators - return new specifications
public function and(Specification $other): AndSpecification
{
return new AndSpecification($this, $other);
}
public function or(Specification $other): OrSpecification
{
return new OrSpecification($this, $other);
}
public function not(): NotSpecification
{
return new NotSpecification($this);
}
}
class AndSpecification extends Specification
{
public function __construct(
private Specification $left,
private Specification $right
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
return $this->left->isSatisfiedBy($candidate)
&& $this->right->isSatisfiedBy($candidate);
}
}
class OrSpecification extends Specification
{
public function __construct(
private Specification $left,
private Specification $right
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
return $this->left->isSatisfiedBy($candidate)
|| $this->right->isSatisfiedBy($candidate);
}
}
class NotSpecification extends Specification
{
public function __construct(private Specification $spec) {}
public function isSatisfiedBy(mixed $candidate): bool
{
return !$this->spec->isSatisfiedBy($candidate);
}
}
Concrete specifications – named business rules
<?php
declare(strict_types=1);
// Each rule has a name and a single responsibility
class PriceAboveMinimum extends Specification
{
public function __construct(private float $minimum = 100.0) {}
public function isSatisfiedBy(mixed $product): bool
{
return (float) ($product['price'] ?? 0) > $this->minimum;
}
}
class InStockSpecification extends Specification
{
public function isSatisfiedBy(mixed $product): bool
{
return (int) ($product['stock'] ?? 0) > 0;
}
}
class NotInExcludedCategory extends Specification
{
private array $excludedIds;
public function __construct(int ...$excludedCategoryIds)
{
$this->excludedIds = $excludedCategoryIds;
}
public function isSatisfiedBy(mixed $product): bool
{
return !in_array($product['category_id'] ?? null, $this->excludedIds, true);
}
}
class WholesaleCustomerProduct extends Specification
{
public function isSatisfiedBy(mixed $product): bool
{
return ($product['customer_group'] ?? '') === 'wholesale';
}
}
// Composed specification - reads like a business requirement
$discountEligible = (new PriceAboveMinimum(100.0))
->and(new InStockSpecification())
->and(new NotInExcludedCategory(5, 12, 18))
->and(new WholesaleCustomerProduct());
// Same specification, used consistently everywhere
foreach ($products as $product) {
if ($discountEligible->isSatisfiedBy($product)) {
$this->applyDiscount($product);
}
}
Magento 2 integration – rule-based product filtering
<?php
declare(strict_types=1);
namespace Vendor\Module\Model\Specification\Product;
use Vendor\Module\Model\Specification\Specification;
use Magento\Catalog\Api\Data\ProductInterface;
class IsEligibleForFlashSale extends Specification
{
private Specification $spec;
public function __construct(
private \Magento\CatalogInventory\Api\StockRegistryInterface $stockRegistry
) {
// Build the composed rule in the constructor
$this->spec = (new HasMinimumPrice(50.0))
->and(new IsInStock($stockRegistry))
->and(new IsNotOnSaleAlready())
->and(new IsNotInExcludedCategory(4, 7)); // no clearance, no discontinued
}
public function isSatisfiedBy(mixed $product): bool
{
return $this->spec->isSatisfiedBy($product);
}
}
class HasMinimumPrice extends Specification
{
public function __construct(private float $minimum) {}
public function isSatisfiedBy(mixed $product): bool
{
return (float) $product->getPrice() >= $this->minimum;
}
}
class IsInStock extends Specification
{
public function __construct(
private \Magento\CatalogInventory\Api\StockRegistryInterface $stockRegistry
) {}
public function isSatisfiedBy(mixed $product): bool
{
$stockItem = $this->stockRegistry->getStockItem($product->getId());
return $stockItem->getIsInStock();
}
}
class IsNotOnSaleAlready extends Specification
{
public function isSatisfiedBy(mixed $product): bool
{
$special = $product->getSpecialPrice();
return empty($special) || (float)$special >= (float)$product->getPrice();
}
}
class IsNotInExcludedCategory extends Specification
{
private array $excludedIds;
public function __construct(int ...$ids) { $this->excludedIds = $ids; }
public function isSatisfiedBy(mixed $product): bool
{
return empty(array_intersect($product->getCategoryIds(), $this->excludedIds));
}
}
Unit testing specifications
<?php
use PHPUnit\Framework\TestCase;
class HasMinimumPriceTest extends TestCase
{
public function testSatisfiedWhenPriceAboveMinimum(): void
{
$spec = new HasMinimumPrice(50.0);
$product = $this->createMock(\Magento\Catalog\Api\Data\ProductInterface::class);
$product->method('getPrice')->willReturn(99.99);
$this->assertTrue($spec->isSatisfiedBy($product));
}
public function testNotSatisfiedWhenPriceBelowMinimum(): void
{
$spec = new HasMinimumPrice(50.0);
$product = $this->createMock(\Magento\Catalog\Api\Data\ProductInterface::class);
$product->method('getPrice')->willReturn(29.99);
$this->assertFalse($spec->isSatisfiedBy($product));
}
}
// Composition is also easily testable
class FlashSaleEligibilityTest extends TestCase
{
public function testComposedRuleWorks(): void
{
$rule = (new HasMinimumPrice(50.0))
->and(new IsNotInExcludedCategory(4, 7));
$product = $this->createMock(\Magento\Catalog\Api\Data\ProductInterface::class);
$product->method('getPrice')->willReturn(79.99);
$product->method('getCategoryIds')->willReturn([1, 3, 5]);
$this->assertTrue($rule->isSatisfiedBy($product));
}
}
Summary
Specification turns anonymous business rules into named, composable objects. The benefits are: rules have names (readable code), rules are testable in isolation, rules can be composed with AND/OR/NOT without modifying existing code, and they are reusable across services. In Magento 2 context this pattern shines for discount eligibility, promotion rules, product filtering, and any domain with complex conditional logic that would otherwise grow into an unreadable nest of if statements.
