Open-source Laravel package

Laravel Rulebook

Select a business rule for a date, see why it won, and test what happens when the policy changes.

composer require mathiasonea/laravel-rulebook

Package
mathiasonea/laravel-rulebook
Latest stable
Latest stable version on Packagist
Laravel
^12.0 | ^13.0
PHP
^8.3
License
MIT
Registry
Packagist

Releases: Follow the release notes on GitHub.

One rule wins. The other results stay visible.

Decision flow

How Rulebook reaches a decision

Rulebook checks the date first, then the subject and context. If several rules apply, the one with the highest priority wins. Each rule has a recorded reason, including rules skipped because of their date range.

  1. 01InputPass the subject, optional context, and decision time.
  2. 02Date rangeSkip rules that are not valid at the requested date.
  3. 03Applies?Ask the remaining rules whether they apply to the subject and context.
  4. 04WinnerPick the highest priority and keep the other results and reasons.

What is Laravel Rulebook?

Laravel Rulebook selects one business rule for an object at a specific time and explains why it won. It uses date ranges and priorities, creates rules through Laravel's container, and returns a typed outcome.

I built it for business logic that changes over time. Editing a dated conditional in place can change how an old invoice, quote, entitlement, or eligibility check resolves today. Each policy version has its own class. Earlier dates use those classes and the code and data deployed when you run the evaluation.

Read why I built Laravel Rulebook for the problem, design choices, and Austrian EV example.

How do you install Laravel Rulebook?

Install it with Composer. Laravel discovers the provider automatically. There is no configuration to publish, migration to run, facade, or global registry.

bash· Install Laravel Rulebook from Packagist
composer require mathiasonea/laravel-rulebook

A resolved decision gives you the outcome, winning rule, reason, and decision time:

php· Resolve using the invoice date
$decision = $vehiclePricingRulebook->resolveAt(
    subject: $vehicle,
    at: $invoice->issued_at,
    context: $pricingContext,
);

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

How do you define a typed rulebook?

Extend Rulebook and return your rule classes. The PHPStan annotation connects the subject, context, and outcome types. Array order does not decide the winner.

php· A versioned vehicle-pricing 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,
        ];
    }
}

How do you keep old rule versions?

Give each policy version a date range and its own calculation. Checks that do not change can stay in an abstract rule. Each yearly rule only needs to provide the values or formula for that year.

php· A rule for the 2026 policy
final class AustrianElectricVehiclePrice2026 extends AustrianElectricVehiclePrice
{
    public function key(): string
    {
        return 'austria.ev-price.2026';
    }

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

The class name is the default rule key. Use a stable domain key when a decision or snapshot will be stored outside the current request. A blank key throws InvalidRuleKey.

What belongs inside a rule?

Rulebook runs every rule valid at the requested date, including fallbacks. For the same inputs and dependencies, evaluate() should return the same result and cause no side effects.

  • Use $input->at instead of reading the current clock inside a rule.
  • Do not write data, send messages, or trigger other side effects from evaluate().
  • Keep key(), priority(), and validity() stable while the evaluation runs.
  • Write reasons that are safe and useful in logs or support screens.
  • Let dependency failures throw. A failed dependency does not mean the rule rejected the input.

How does Rulebook select one winning rule?

Rulebook skips rules outside the requested date, then asks the remaining rules whether they apply to the subject and context. The applicable rule with the highest priority wins. Lower-priority matches stay visible as fallbacks.

php· Resolve and inspect a dated decision
$decision = $rulebook->resolveAt(
    subject: $vehicle,
    at: new DateTimeImmutable('2026-06-15T10:00:00+02:00'),
    context: $pricingContext,
);

$decision->outcome();
$decision->winningRule();
$decision->winningRuleKey();
$decision->winningResult()->reason();
$decision->evaluationFor('austria.ev-price.2026');
$decision->shadowedEvaluations();

If two rules share the highest priority, Rulebook throws AmbiguousRuleMatch. Registration order never breaks the tie. A missing match throws NoMatchingRule, duplicate rule keys throw DuplicateRuleKey, and blank keys throw InvalidRuleKey.

How do you inspect a decision without requiring a winner?

Use evaluateNow() or evaluateAt() when you still want the results if no rule wins or two rules tie. The evaluation includes applicable, inapplicable, lower-priority, skipped, and conflicting rules with their reasons.

php· Evaluate before resolving
$evaluation = $rulebook->evaluateNow($subject, $context);

$evaluation->evaluations();
$evaluation->applicableEvaluations();
$evaluation->inapplicableEvaluations();
$evaluation->shadowedEvaluations();
$evaluation->conflictingEvaluations();
$evaluation->evaluationFor('austria.ev-price.2026');
$evaluation->hasWinner();
$evaluation->hasConflict();

How do statuses and reason codes work?

Each rule evaluation has one status: RuleEvaluationStatus::Applicable, RuleEvaluationStatus::DoesNotApply, or RuleEvaluationStatus::OutsideValidity. The last one means the rule was skipped and its application code did not run.

A human-readable reason is always present. Add an optional reason code when the application needs stable filtering, metrics, or localization.

php· Add a machine-readable reason code
return RuleResult::doesNotApply(
    reason: 'The vehicle is not electric.',
    reasonCode: 'vehicle_not_electric',
);

How do you keep the exact decision?

resolveAt() evaluates the currently deployed code and data at the date you supply. Create a snapshot when the decision is made to keep the result after code or data changes.

php· Create a JSON-compatible decision snapshot
$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);

The snapshot keeps the decision time, winning key, normalized outcome, rule priorities and date ranges. It also keeps statuses, reasons, and reason codes. Its top-level schema_version is 1.

A decision snapshot always has a winner. Snapshot an evaluation before resolution to record a missing match or conflict. Inspect it with winningRuleKey(), conflictingRuleKeys(), and evaluations().

Your application chooses where to store snapshots and which invoice, quote, or other business record they belong to. Snapshot creation throws UnportableSnapshotValue instead of silently keeping unsupported objects, resources, invalid UTF-8, non-finite numbers, cycles, or values nested too deeply.

Can you run a complete Laravel Rulebook example?

Yes. The Austrian EV pricing example is a console-only Laravel 12 application that installs Rulebook from Packagist. It compares three fictional yearly policies for the same 75 kWh vehicle.

PolicyBase priceIncentiveBattery feeResolved price
2025EUR 35,000EUR 4,000EUR 0/kWhEUR 31,000
2026EUR 35,000EUR 2,800EUR 4/kWhEUR 32,500
2027EUR 35,500EUR 1,000EUR 5/kWhEUR 34,875
bash· Run the same vehicle under three policy dates
composer install

php artisan vehicle:price 2025-06-15T10:00:00+02:00
php artisan vehicle:price 2026-06-15T10:00:00+02:00
php artisan vehicle:price 2027-06-15T10:00:00+02:00

The command also prints the winner, its reason, the fallbacks, and the rules skipped because they were not valid at that date.

How do validity periods behave?

Validity periods are half-open: the start is included and the end is excluded. A range can omit either end. Dates become immutable values, and comparisons use absolute points in time without changing timezones.

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

When should you use Laravel Rulebook?

Use it when policies change on known dates and you need to explain which rule produced a result.

  • Date checks keep accumulating in an application service.
  • An older policy must still be available when evaluating an earlier date.
  • Several rules can apply and you need a clear winner or fallback.
  • A decision varies by subject, context, and point in time.
  • Support needs to see why a result was chosen.

Rulebook is for decisions with one code-defined winning rule. It does not provide a DSL, database or visual rule editing, workflow orchestration, or a way to combine results from several rules.

How do you test your business rules?

Use RulebookAssertions inside your Pest or PHPUnit suite. Check an existing evaluation without running the rules again. Assert the expected winning key to catch unexpected fallbacks, then check the outcome.

php· Check the rule and its price
use MathiasOnea\Rulebook\Testing\RulebookAssertions;

$evaluation = $rulebook->evaluateAt(
    subject: $vehicle,
    at: new DateTimeImmutable('2027-06-15T10:00:00+02:00'),
    context: $pricingContext,
);

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

TemporalTestCases::around() supplies named before, at, and after datasets. The neighboring instants are one microsecond from the boundary. Each yearly rule in this example needs a stable key such as austria.ev-price.2027.

php· Test a policy change in Pest
use App\Models\Vehicle;
use App\Pricing\Country;
use App\Pricing\VehiclePricingContext;
use App\Pricing\VehiclePricingRulebook;
use MathiasOnea\Rulebook\Testing\RulebookAssertions;
use MathiasOnea\Rulebook\Testing\TemporalTestCases;

it('switches to the 2027 EV policy', function (
    DateTimeImmutable $at,
    string $expectedKey,
) {
    $evaluation = app(VehiclePricingRulebook::class)->evaluateAt(
        subject: new Vehicle(electric: true, batteryCapacityInKwh: 75),
        at: $at,
        context: new VehiclePricingContext(country: Country::Austria),
    );

    RulebookAssertions::assertWinner($evaluation, $expectedKey);
})->with(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',
));

For PHPUnit, return the same datasets from a static data provider. Create subjects and contexts inside the test method, after Laravel has booted. The helper preserves the supplied timezone and handles daylight-saving changes.

Use assertNoMatch() for an expected missing match, assertConflict() for a tie, and assertRuleStatus() to distinguish rejected rules from skipped ones. A failed assertion reports the time, actual winner or conflict, priorities, date ranges, statuses, and reasons.

Your normal CI test step runs these checks. No separate Rulebook command is needed. The assertions use your test project's PHPUnit installation and add no runtime dependency. Passing tests cover the scenarios you provide, not every possible subject and context.

The testing guide has complete Pest and PHPUnit examples and sample failure output.

The package's own suite checks resolution, boundary behavior, conflicts, snapshots, and these testing helpers. PHPStan checks the subject, context, and outcome types.

bash· Run the package test suite
composer test

Where can you follow Laravel Rulebook releases?

New versions are published through Packagist. Follow the GitHub releases for version notes.

Frequently asked questions

What is Laravel Rulebook?

Laravel Rulebook selects one code-defined business rule for a subject, optional context, and point in time. Date ranges decide which rules can run. The highest-priority applicable rule wins. The evaluation records the other rules and their reasons too.

Can Laravel Rulebook reproduce historical decisions?

resolveAt() and evaluateAt() apply the currently deployed code and data to the date you supply. Keep earlier rule versions to evaluate older dates. If code or data changes, the result can change too. Save a snapshot when the original result and explanation must survive.

What happens when two rules have the same highest priority?

Rulebook throws AmbiguousRuleMatch and includes the full evaluation. Registration order never breaks the tie, so the conflict is visible and testable.

Can context influence which rule applies?

Yes. Rules receive the subject, optional typed context, and decision time. Context can carry the country, market, tenant, customer type, channel, or other information that affects whether a rule applies.

Does Laravel Rulebook persist decisions?

No. It creates portable decision and evaluation snapshots. The application decides where to store them and which business record they belong to.

Can I test policy changes in my existing test suite?

Yes. RulebookAssertions checks winners, missing matches, conflicts, rule statuses, and outcomes in Pest or PHPUnit. TemporalTestCases supplies named cases before, at, and after a date boundary. Your normal CI test step runs them.

Credits and package links

I maintain Laravel Rulebook under the MIT license. Share a rule from your application in GitHub Discussions if the model is difficult to use or something is missing.