mathiasonea/laravel-rulebook

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

Maintainers

Package info

github.com/mathiasonea/laravel-rulebook

Homepage

Forum

Documentation

pkg:composer/mathiasonea/laravel-rulebook

Transparency log

Fund package maintenance!

mathiasonea

Statistics

Installs: 34

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v0.2.1 2026-08-10 06:09 UTC

README

Latest Version on Packagist Total Downloads Tests PHP Version License

Business rules change. Old decisions still need to make sense.

Laravel Rulebook selects which code-defined business rule applies to a subject at any point in time—and explains why.

Editing dated conditionals in place destroys the reproducibility and explainability of historical decisions. As policies accumulate, overlaps and fallbacks become accidental: the same invoice, quote, or eligibility check can produce a different answer after the code changes, with no durable account of which rule won.

Rulebook keeps those decisions code-defined, time-aware, deterministic, and explainable. Every resolution has one explicit winner, a decision time, and the complete evaluation behind it.

Before and after

Without an explicit model, effective-date logic tends to grow inside one branching path:

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, each policy version remains a named rule and the decision date is part of resolution:

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

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

Use it when

  • Effective-date conditionals keep accumulating in application services.
  • A policy is edited in place even though old decisions must remain reproducible.
  • Several rules can apply and the intended winner or fallback must be explicit.
  • A decision varies by subject, context, and point in time.
  • You need to reconstruct why an invoice, price, entitlement, or eligibility decision was made.

Installation

composer require mathiasonea/laravel-rulebook

Laravel discovers the package provider automatically. There is no configuration to publish, migration to run, facade, or global registry.

Requirements

  • PHP 8.3 or newer
  • Laravel 12 or 13

Scope

Rulebook is code-defined and resolves exactly one winning rule. It does not provide a DSL, database- or UI-authored rules, workflow or state-machine behavior, or multi-rule outcome composition.

Resources

  • Project page — the permanent overview of Laravel Rulebook.
  • Architecture guide — a practical explanation of replacing dated conditionals with auditable, versioned rules.
  • Runnable Austrian EV example — a focused Laravel 12 application comparing the 2025, 2026, and 2027 policies, including fallbacks and skipped validity windows.

Versioned pricing in practice

Suppose an Austrian electric-vehicle price changes every calendar year. The base price, incentive, and battery fee can all change, while a general Austrian price and a global default must remain available as fallbacks.

Model each policy version as its own rule. The rulebook then becomes a readable history of every policy that can govern the decision:

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 establishes the subject, context, and outcome types for every rule in the rulebook. Rule order does not decide the winner; every applicable rule participates in explicit priority resolution.

Share stable policy, isolate yearly changes

Common eligibility can live in an abstract application-owned rule. The calculation delegates the values that are expected to change to the concrete yearly policy:

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 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;
}

Each year supplies its own validity window and parameters:

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; }
}

This keeps a historical policy intact after a new year begins. If the formula itself changes in 2027—not just its parameters—the 2027 class can override the calculation without adding if ($year === ...) branches to older rules.

The rule class strings are resolved through Laravel's container, so the shared rule or concrete yearly rules can use constructor injection without package-specific registration. Exceptions from a rule or one of its dependencies bubble unchanged; an operational failure is never converted into “does not apply.”

Resolve and inspect a dated decision

Resolve historical or future decisions with a DateTimeInterface:

$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."

At that instant, the 2025 and 2027 rules are outside their validity windows and are not invoked. The 2026 rule wins with priority 100; the general Austrian and default prices can still be inspected as applicable but shadowed fallbacks.

Rule What happens on 2026-06-15 Role in the decision
DefaultVehiclePrice Applies Shadowed fallback
AustrianVehiclePrice Applies Shadowed fallback
AustrianElectricVehiclePrice2025 Outside its validity window; not invoked Inapplicable
AustrianElectricVehiclePrice2026 Applies Winner
AustrianElectricVehiclePrice2027 Outside its validity window; not invoked Inapplicable

The same rulebook can reproduce decisions under earlier or later policy versions without changing application code:

$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

The returned decision exposes the winner and the complete evaluation:

$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();

A lower-priority match is still applicable. It is described as shadowed because the sole higher-priority rule governs the decision.

Evaluate without requiring a winner

Use evaluateNow() or evaluateAt() when diagnostics must remain available even if no rule applies or the top priority is ambiguous.

$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() are convenience methods for evaluating and then resolving.

Resolution throws:

  • 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 retain the exact Evaluation on their public $evaluation property and through evaluation(). Registration order never breaks an equal-priority 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(),
        ]);
    }
}

Rule authoring contract

Rulebook evaluates every rule inside its validity period, including lower-priority fallbacks, so that the returned evaluation explains the complete decision. Treat evaluate() as a deterministic, side-effect-free operation:

  • 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.
  • Make reasons safe and useful for logs or other diagnostic output.
  • Let operational failures bubble; do not convert exceptions into domain-level rejections.
  • Account for mutable dependencies: resolving an old date reproduces the policy represented by the currently deployed code and data, not necessarily the exact historical execution.

The default rule key is its class name. That is convenient while developing, but a class rename changes the key. Override key() with a stable domain identifier when decisions or their explanations are stored outside the current request:

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

Rulebook returns the complete evaluation but deliberately does not persist it. Applications that require a durable audit record can store a portable snapshot alongside their own business record.

Structured statuses and reason codes

Every RuleEvaluation has one structured status:

  • 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.

wasEvaluated() remains available when only the distinction between a domain rejection and a skipped rule matters. Rule keys, priorities, and validity periods are captured once before domain evaluation, so later inspection cannot change the winner.

Results may also include an optional machine-readable reason code alongside the mandatory human explanation:

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

Use reason codes for stable filtering, metrics, or localization; keep the reason useful to a human reading the decision.

Portable decision snapshots

Snapshots remove live subject, context, and rule objects while retaining the decision time, frozen rule metadata, validity windows, statuses, reasons, and reason codes. A decision snapshot always has one winner; an evaluation snapshot can instead record a conflict or no match. Both implement JsonSerializable and expose toArray():

$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);

Scalar, array, backed-enum, or JsonSerializable outcomes can use $decision->snapshot() directly. Supply normalizeOutcome when another outcome object needs an application-specific portable representation. Snapshot creation eagerly copies the normalized value and throws UnportableSnapshotValue for unsupported objects, resources, invalid UTF-8, non-finite numbers, cycles, or excessive nesting.

An evaluation can be snapshotted before resolution, including when no rule applies or several rules conflict:

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

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

Snapshots are transport records, not persistence. The application remains responsible for choosing where to store them and which subject or business-record identifier belongs beside them.

Every top-level snapshot contains schema_version: 1. The serialized field names, meanings, and status values are public API and follow the package's semantic-versioning policy.

Validity periods

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

Periods are half-open: [from, until).

  • from is inclusive.
  • until is exclusive.
  • Open starts and ends are supported.
  • Empty and reversed periods throw InvalidValidityPeriod.
  • Inputs are copied into immutable date-time values.
  • Comparisons use absolute instants; timezones are never silently rewritten.

An out-of-window rule is not invoked. Its RuleEvaluation is inapplicable, has a generated validity reason, and returns false from wasEvaluated().

Optional context

Use null as the context type when a decision only needs its subject:

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

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

RuleInput::subject() and RuleInput::context() provide typed access. A mismatch throws UnexpectedSubject or UnexpectedContext with the expected and actual types.

Nullable outcomes

null can be a valid typed outcome:

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

Applicability is stored separately from the outcome, so this is not confused with RuleResult::doesNotApply(...).

Time and testing

“Now” resolutions use Carbon's clock, including CarbonImmutable::setTestNow() in tests. Explicit DateTimeInterface values passed to resolveAt() and evaluateAt() are copied without changing their instant.

Roadmap

  • First-party temporal test matrices and expressive decision assertions
  • Inspection tooling for keys, priorities, validity windows, gaps, and obvious collisions
  • Optional Laravel integrations for observing or persisting snapshots
  • Further extension points driven by concrete production use cases

Already maintaining effective-date business logic? Model one real decision with Rulebook, then open an issue or start a discussion and tell us where the API feels heavy or breaks down. Focused pull requests are welcome; please discuss larger changes first.

Core invariants

Blank reasons, blank provided reason codes, blank rule keys, and invalid or empty validity periods are rejected. Skipped out-of-window rules remain distinguishable from evaluated domain rejections, and rule metadata is captured once per evaluation. Portable snapshots reject values that cannot be represented safely in their versioned JSON schema.

Development

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

The test suite includes vehicle-pricing and subscription-billing rulebooks, boundary and timezone cases, conflict and missing-match inspection, container injection, nullable outcomes, Carbon test time, exception propagation, architecture constraints, and PHPStan outcome inference.

License

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