PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Specification pattern – encapsulating business rules, AND/OR/NOT composition, testing

by Henryk Tews / Tuesday, 04 March 2025 / Published in Wzorce projektowe

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.

About Henryk Tews

What you can read next

GoF patterns in Magento 2 – where to find them and how they work
State pattern – order state machine, serialisation, comparison with Strategy
Decorator and Proxy in PHP – structural patterns

© 2026 Created by

TOP
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Always active
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Manage options
  • Manage services
  • Manage {vendor_count} vendors
  • Read more about these purposes
Zobacz preferencje
  • {title}
  • {title}
  • {title}