Search by

mathiasonea / laravel-rulebook

mathiasonea

A Laravel package for selecting which code-defined business rule applies to a subject at any point in time—and explaining why.

Package info

github.com/mathiasonea/laravel-rulebook

Homepage

Forum

Documentation

pkg:composer/mathiasonea/laravel-rulebook

Fund package maintenance!

mathiasonea

Statistics

Installs: 2 179

Dependents: 0

Suggesters: 0

Stars: 89

Open Issues: 0

v0.3.0 2026-10-06 07:57 UTC

This package is auto-updated.

Last update: 2026-10-06 08:04:34 UTC


README

Latest Version on Packagist Total Downloads Tests PHP Version Laravel Compatibility License

Laravel Rulebook keeps business rules in PHP classes and selects the one that applies on a given date. Each rule has a date range and priority, and explains why it accepts or rejects the input.

Use it for pricing, billing, or eligibility policies that change over time. You can inspect how Rulebook reached a decision and save that explanation with the business record.

Before and after

Suppose your prices change each January. An invoice needs the policy that was valid when it was issued, so the pricing code might start like this:

if ($invoice->issued_at < new DateTimeImmutable('2026-01-01T00:00:00+01:00')) {
    return $this->priceUnder2025Policy($vehicle);
}

if ($invoice->issued_at < new DateTimeImmutable('2027-01-01T00:00:00+01:00')) {
    return $this->priceUnder2026Policy($vehicle);
}

return $this->currentPrice($vehicle);

With Rulebook, you put each policy in its own class and give it a date range. Then you ask the rulebook for a decision at the invoice date:

$decision = $vehiclePricingRulebook->resolveAt(
    subject: $vehicle,
    at: $invoice->issued_at,
    context: $pricingContext,
);

$price = $decision->outcome();
$rule = $decision->winningRule();
$reason = $decision->winningResult()->reason();

The decision gives you the price, the rule that produced it, and the reason it applied. You can also see what happened to the other rules. Lower-priority matches remain visible as fallbacks, while rules outside their date range are skipped:

Rule Status Role
Global default applicable Shadowed fallback
Austrian price applicable Shadowed fallback
Austrian EV 2025 outside_validity Skipped
Austrian EV 2026 applicable Winner
Austrian EV 2027 outside_validity Skipped

For the next policy change, add a rule with its own start date. Leave the previous rule in place for older invoices.

Installation

composer require mathiasonea/laravel-rulebook

You need PHP 8.3 or newer and Laravel 12 or 13. Laravel discovers the package provider automatically, so you can start defining rules after installation.

There is no configuration to publish or migration to run. You work with rulebook classes directly, without a facade or global registry.

If you prefer to start with a working application, the Austrian EV pricing example compares fictional policies for 2025, 2026, and 2027. Its Artisan command prints the price and explains which rules matched or were skipped.

When to use Rulebook

Rulebook is useful when date checks keep accumulating in a service, or when changing the current policy makes it difficult to preserve earlier calculations. It also gives you an explicit way to choose between several matching rules and inspect the fallbacks.

The examples below use vehicle pricing, but the same approach works for billing terms, shipping policies, commissions, and eligibility. The subject is the object you are making a decision about. Context carries any extra information the rules need, such as a country or customer type.

Rules live in PHP and each resolved decision has one winner. If you need people to edit rules through a UI, a workflow engine, or a way to combine several winning outcomes, those require a different model.

The package page has more examples. I wrote about the problem and the choices behind the package in Why I built it.

Versioned pricing in practice

For this example, an Austrian electric-vehicle price changes every calendar year. Each year's policy has its own base price, incentive, and battery fee. A general Austrian price and a global default provide fallbacks when the EV policy does not apply.

Start by registering those rules in a rulebook:

namespace App\Pricing;

use App\Models\Vehicle;
use MathiasOnea\Rulebook\Rulebook;

/**
 * @extends Rulebook<Vehicle, VehiclePricingContext, Money>
 */
final class VehiclePricingRulebook extends Rulebook
{
    protected function rules(): array
    {
        return [
            DefaultVehiclePrice::class,
            AustrianVehiclePrice::class,
            AustrianElectricVehiclePrice2025::class,
            AustrianElectricVehiclePrice2026::class,
            AustrianElectricVehiclePrice2027::class,
        ];
    }
}

The PHPStan annotation tells your editor and static analysis that this rulebook takes a Vehicle and VehiclePricingContext, and returns Money. Rulebook checks the registered rules and selects the applicable one with the highest priority. Array order does not affect the result.

Write the pricing rules

All three EV policies check the same things: the vehicle must be electric and the pricing country must be Austria. An abstract rule can hold those checks and the shared calculation, while each yearly class supplies the amounts:

namespace App\Pricing;

use App\Models\Vehicle;
use MathiasOnea\Rulebook\Inputs\RuleInput;
use MathiasOnea\Rulebook\Results\RuleResult;
use MathiasOnea\Rulebook\Rule;

/**
 * @extends Rule<Vehicle, VehiclePricingContext, Money>
 */
abstract class AustrianElectricVehiclePrice extends Rule
{
    public function key(): string
    {
        return 'austria.ev-price.'.$this->policyYear();
    }

    public function priority(): int
    {
        return 100;
    }

    public function evaluate(RuleInput $input): RuleResult
    {
        $vehicle = $input->subject(Vehicle::class);
        $context = $input->context(VehiclePricingContext::class);

        if (! $vehicle->isElectric()) {
            return RuleResult::doesNotApply(
                reason: 'The vehicle is not electric.',
            );
        }

        if ($context->country !== Country::Austria) {
            return RuleResult::doesNotApply(
                reason: 'The pricing country is not Austria.',
            );
        }

        return RuleResult::applies(
            outcome: Money::EUR(
                $this->basePriceInCents()
                - $this->incentiveInCents()
                + ($vehicle->batteryCapacityInKwh * $this->batteryFeePerKwhInCents()),
            ),
            reason: "The {$this->policyYear()} Austrian electric-vehicle price applies.",
        );
    }

    abstract protected function policyYear(): int;

    abstract protected function basePriceInCents(): int;

    abstract protected function incentiveInCents(): int;

    abstract protected function batteryFeePerKwhInCents(): int;
}

Give each yearly rule a validity period and its pricing values:

use DateTimeImmutable;
use MathiasOnea\Rulebook\Periods\ValidityPeriod;

final class AustrianElectricVehiclePrice2025 extends AustrianElectricVehiclePrice
{
    public function validity(): ValidityPeriod
    {
        return ValidityPeriod::between(
            from: new DateTimeImmutable('2025-01-01T00:00:00+01:00'),
            until: new DateTimeImmutable('2026-01-01T00:00:00+01:00'),
        );
    }

    protected function policyYear(): int { return 2025; }
    protected function basePriceInCents(): int { return 35_000_00; }
    protected function incentiveInCents(): int { return 4_000_00; }
    protected function batteryFeePerKwhInCents(): int { return 0; }
}

final class AustrianElectricVehiclePrice2026 extends AustrianElectricVehiclePrice
{
    public function validity(): ValidityPeriod
    {
        return ValidityPeriod::between(
            from: new DateTimeImmutable('2026-01-01T00:00:00+01:00'),
            until: new DateTimeImmutable('2027-01-01T00:00:00+01:00'),
        );
    }

    protected function policyYear(): int { return 2026; }
    protected function basePriceInCents(): int { return 35_000_00; }
    protected function incentiveInCents(): int { return 2_800_00; }
    protected function batteryFeePerKwhInCents(): int { return 4_00; }
}

final class AustrianElectricVehiclePrice2027 extends AustrianElectricVehiclePrice
{
    public function validity(): ValidityPeriod
    {
        return ValidityPeriod::between(
            from: new DateTimeImmutable('2027-01-01T00:00:00+01:00'),
            until: new DateTimeImmutable('2028-01-01T00:00:00+01:00'),
        );
    }

    protected function policyYear(): int { return 2027; }
    protected function basePriceInCents(): int { return 35_500_00; }
    protected function incentiveInCents(): int { return 1_000_00; }
    protected function batteryFeePerKwhInCents(): int { return 5_00; }
}

If the formula changes in 2027, override evaluate() in that year's class. The earlier classes can continue using the shared calculation. Changing the abstract rule would affect every year that inherits it, so make shared changes with that in mind.

Laravel's container creates each rule, so you can inject dependencies through its constructor. If a rule or dependency throws an exception, Rulebook lets it reach the caller. That keeps a failed dependency distinguishable from a rule that checked the input and rejected it.

Get a decision for a date

Pass the subject, date, and context to resolveAt():

$decision = $rulebook->resolveAt(
    subject: new Vehicle(
        electric: true,
        batteryCapacityInKwh: 75,
    ),
    at: new DateTimeImmutable('2026-06-15T10:00:00+02:00'),
    context: new VehiclePricingContext(country: Country::Austria),
);

$decision->winningRule();          // an AustrianElectricVehiclePrice2026 instance
$decision->outcome();              // EUR 32,500.00
$decision->winningResult()->reason();
// "The 2026 Austrian electric-vehicle price applies."

The 2026 EV rule wins with priority 100. Rulebook skips the 2025 and 2027 rules because their date ranges do not include this instant. The general Austrian price and global default still apply, but their lower priorities make them fallbacks. Rulebook calls these matches "shadowed".

Use the same rulebook to evaluate an earlier or later policy date:

$rulebook->resolveAt($vehicle, new DateTimeImmutable('2025-07-01T00:00:00+02:00'), $context)
    ->winningRule(); // AustrianElectricVehiclePrice2025

$rulebook->resolveAt($vehicle, new DateTimeImmutable('2027-07-01T00:00:00+02:00'), $context)
    ->winningRule(); // AustrianElectricVehiclePrice2027

You can inspect both the winner and the results from the other rules:

$decision->outcome();             // Money
$decision->winningRule();         // the selected Rule instance
$decision->winningRuleKey();      // the stable key captured for the winner
$decision->winningResult();       // outcome and mandatory reason
$decision->evaluations();         // every RuleEvaluation
$decision->evaluationFor($key);   // one evaluation by stable rule key
$decision->applicableRules();     // includes lower-priority matches
$decision->inapplicableRules();
$decision->shadowedRules();       // applicable, but below the winner
$decision->shadowedEvaluations(); // rules, results, reasons, and captured metadata
$decision->evaluatedAt();

Evaluate without requiring a winner

Sometimes you need to inspect the rules even when none apply or several share the highest priority. Use evaluateNow() or evaluateAt() to get the evaluation first, then call resolve() when you want a decision:

$evaluation = $rulebook->evaluateNow($vehicle, $context);

$evaluation->evaluations();
$evaluation->applicableEvaluations();
$evaluation->inapplicableEvaluations();
$evaluation->applicableRules();
$evaluation->inapplicableRules();
$evaluation->shadowedRules();
$evaluation->shadowedEvaluations();
$evaluation->conflictingEvaluations();
$evaluation->evaluationFor($key);
$evaluation->hasWinner();
$evaluation->hasConflict();

$decision = $evaluation->resolve();

resolveNow() and resolveAt() do both steps in one call.

Rulebook throws these exceptions when it cannot produce a decision:

  • NoMatchingRule when nothing applies.
  • AmbiguousRuleMatch when more than one applicable rule shares the highest priority.
  • DuplicateRuleKey when registered rules expose the same key.
  • InvalidRuleKey when a registered rule exposes a blank key.

NoMatchingRule and AmbiguousRuleMatch both carry the evaluation, so you can inspect the cause after catching either exception. Access it through the public $evaluation property or evaluation(). Rulebook never uses registration order to break a tie.

use Illuminate\Support\Facades\Log;

try {
    $decision = $rulebook->resolveNow($vehicle, $context);
} catch (AmbiguousRuleMatch $exception) {
    foreach ($exception->evaluation->evaluations() as $ruleEvaluation) {
        Log::warning('Ambiguous rulebook evaluation.', [
            'rule' => $ruleEvaluation->key(),
            'applies' => $ruleEvaluation->isApplicable(),
            'reason' => $ruleEvaluation->result()->reason(),
        ]);
    }
}

Writing rules

Rulebook runs every rule whose date range includes the requested instant, including lower-priority fallbacks. Treat evaluate() as a calculation. With the same inputs and dependency state, it should return the same result without changing anything:

  • Use $input->at instead of reading the current clock inside a rule.
  • Do not send messages, write data, or trigger other side effects from evaluate().
  • Keep key(), priority(), and validity() stable for the lifetime of an evaluation.
  • Write reasons that help someone understand the decision and are safe to include in logs.
  • Let exceptions reach the caller instead of turning a failed dependency into a rejected match.

By default, a rule's key is its fully qualified class name. Renaming the class therefore changes its key. If you store decisions or reference rules elsewhere, override key() with an identifier you intend to keep:

public function key(): string
{
    return 'austria.ev-price.2026';
}

The abstract EV rule above does this for each policy year, producing keys such as austria.ev-price.2026 and austria.ev-price.2027.

Rule statuses and reasons

The status on each RuleEvaluation tells you whether the rule matched, rejected the input, or was skipped:

  • RuleEvaluationStatus::Applicable when the rule produced an applicable result.
  • RuleEvaluationStatus::DoesNotApply when the rule was evaluated but did not apply.
  • RuleEvaluationStatus::OutsideValidity when the rule was skipped because of its validity period.

Use wasEvaluated() when you only need to know whether Rulebook called the rule. Keys, priorities, and validity periods are captured before any rules run, so later changes to a rule object cannot change the recorded winner.

Every result needs a reason. You can also add a reason code when the application needs to identify a particular rejection without parsing the text:

return RuleResult::doesNotApply(
    reason: 'The vehicle is not electric.',
    reasonCode: 'vehicle_not_electric',
);

The application can use vehicle_not_electric for filtering, metrics, or translations. The reason text explains the rejection to whoever reads the decision.

Save a decision snapshot

An invoice may need the exact price and explanation from the day it was issued. Calling resolveAt() with that date later still uses the code and data deployed at the time of the call. To preserve the original decision, create a snapshot when you make it and store it with the invoice.

A snapshot keeps the decision time, outcomes, rule metadata, statuses, and reasons in JSON-compatible values. It leaves out the live subject, context, and rule objects. Both decision and evaluation snapshots implement JsonSerializable and have a toArray() method:

$snapshot = $decision->snapshot(
    normalizeOutcome: static fn (Money $money): array => [
        'currency' => $money->currency,
        'amount_in_cents' => $money->cents,
    ],
);

$record = $snapshot->toArray();
$json = json_encode($snapshot, JSON_THROW_ON_ERROR);

For scalar, array, backed-enum, or JsonSerializable outcomes, call $decision->snapshot() directly. For other objects, use normalizeOutcome to choose the values to save, as the Money example does above.

Rulebook copies those values when it creates the snapshot. It throws UnportableSnapshotValue if it encounters unsupported objects, resources, invalid UTF-8, non-finite numbers, cycles, or excessive nesting.

You can also snapshot an evaluation before resolving it. This lets you save cases with no match or conflicting rules as well as decisions with a winner:

$snapshot = $rulebook->evaluateAt($vehicle, $at, $context)->snapshot();

$snapshot->winningRuleKey();       // string|null
$snapshot->conflictingRuleKeys();  // list<string>
$snapshot->evaluations();          // list<RuleEvaluationSnapshot>

Rulebook creates the snapshot. Your application handles storage and associates it with the relevant subject or business record.

Every top-level snapshot contains schema_version: 1. The field names, their meanings, and the status values are part of the public API and follow semantic versioning.

Validity periods

ValidityPeriod::always();
ValidityPeriod::from($startsAt);
ValidityPeriod::until($endsAt);
ValidityPeriod::between(from: $startsAt, until: $endsAt);

The start is inclusive and the end is exclusive, written as [from, until). A rule ending at midnight on January 1 no longer applies at that instant, so the next year's rule can start at the same time without an overlap.

Use from() or until() when only one end is fixed, or always() when there is no date restriction. Empty or reversed periods throw InvalidValidityPeriod.

Rulebook copies input dates into immutable values and compares their absolute instants without rewriting their timezones. If the requested date is outside a rule's period, Rulebook skips evaluate(). Its evaluation has an OutsideValidity status, a generated reason, and wasEvaluated() returns false.

Input validation

Rulebook rejects blank rule keys, blank reasons, and invalid date ranges. Reason codes are optional, but cannot be blank when supplied. Snapshots also reject values that cannot be represented safely in their JSON schema.

Optional context

If the rules only need the subject, use null as the context type and omit the context argument:

/**
 * @extends Rulebook<Subscription, null, BillingTerms>
 */
final class SubscriptionBillingRulebook extends Rulebook
{
    protected function rules(): array
    {
        return [
            StandardSubscriptionBilling::class,
            LegacySubscriptionBilling::class,
        ];
    }
}

$terms = $rulebook->resolveNow($subscription)->outcome();

Inside a rule, RuleInput::subject() and RuleInput::context() check the type you request. A mismatch throws UnexpectedSubject or UnexpectedContext and reports the expected and actual types.

Nullable outcomes

A matching rule can return null as its outcome. For example, a billing policy might select no charge:

return RuleResult::applies(
    outcome: null,
    reason: 'No charge is the selected billing outcome.',
);

Rulebook tracks whether a rule applies separately from its outcome. A match with a null outcome therefore still produces a winner; RuleResult::doesNotApply(...) does not.

Test your rules in Pest or PHPUnit

Test the rules through the same evaluation you use in the application. RulebookAssertions lets you check the winning key and inspect the outcome with a predicate:

use MathiasOnea\Rulebook\Testing\RulebookAssertions;

$evaluation = $rulebook->evaluateAt($vehicle, $at, $pricingContext);

RulebookAssertions::assertWinner($evaluation, 'austria.ev-price.2027');
RulebookAssertions::assertOutcomeSatisfies(
    $evaluation,
    static fn (Money $price): bool => $price->cents === 34_875_00,
    'Expected the 2027 price for a 75 kWh vehicle.',
);

The keys in these examples come from the EV rules' key() override above. If you keep the default keys, use the fully qualified rule class names in your assertions instead.

You can also check conflicts, missing matches, and individual rule statuses. When an assertion fails, the message includes the decision date, rule keys, priorities, validity periods, statuses, and reasons. The helpers inspect the existing evaluation without running the rules again, and only format the diagnostics on failure.

For a policy change, check which rule wins just before the change and which wins at the exact boundary. TemporalTestCases::around() creates named before, at, and after cases, with the neighboring dates one microsecond away. You provide the expected values:

use MathiasOnea\Rulebook\Testing\TemporalTestCases;

$cases = TemporalTestCases::around(
    new DateTimeImmutable('2027-01-01T00:00:00+01:00'),
    before: 'austria.ev-price.2026',
    at: 'austria.ev-price.2027',
    after: 'austria.ev-price.2027',
);

Run these cases in your existing Pest or PHPUnit suite. Your normal CI test step will run them too. RulebookAssertions uses your project's PHPUnit installation, including the one supplied by Pest, so the package does not add PHPUnit as a runtime dependency. TemporalTestCases only needs PHP.

To test calls to resolveNow() or evaluateNow(), set Carbon's clock with CarbonImmutable::setTestNow(). For resolveAt() and evaluateAt(), pass a DateTimeInterface directly. Rulebook copies it without changing its instant.

The testing guide has complete Pest and PHPUnit examples, conflict checks, sample failure output, and details about timezones. The helpers only check the cases you give them. Add scenarios for the customer types, countries, and fallbacks your application needs.

Roadmap

  • Tools to inspect rule keys, priorities, and date ranges, including gaps and overlaps
  • Optional Laravel hooks for observing decisions and saving snapshots

If you try Rulebook in an application, I'd like to hear where it fits and where it gets awkward. Open an issue or start a discussion with a small example. Pull requests are welcome; please discuss larger changes first.

Development

composer validate --strict
composer format-check
composer analyse
composer test

The tests use vehicle-pricing and subscription-billing rulebooks to cover date boundaries, timezones, fallbacks, conflicts, and missing matches. They also check container injection, nullable outcomes, Carbon's test clock, exception propagation, architecture constraints, and PHPStan's inferred outcome types.

License

Laravel Rulebook is open-source software licensed under the MIT license.