Open-source Laravel package

Laravel Rulebook

Business rules change. Old decisions still need to make sense. Laravel Rulebook selects which code-defined business rule applies at a given point in time and explains why it won.

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

From a subject and date to one explained result

Rulebook checks the date first, then the subject and context. If several rules apply, the one with the highest priority wins. Every evaluation records a reason, including rules skipped by validity.

  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, resolves rule dependencies 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. Rulebook keeps each policy version in its own class so earlier dates can be evaluated against the currently deployed policy set.

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 an explicit business 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 its own validity period and behavior. 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· One explicit policy version
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 evaluates every in-window rule, including lower-priority fallbacks. I therefore treat evaluate() as a deterministic, side-effect-free operation.

  • 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 operational exceptions bubble instead of turning them into a domain rejection.

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 an explicit date. When the exact result must survive later changes, create a portable snapshot when the decision is made.

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, captured winning key, normalized outcome, captured priorities and validity periods, statuses, reasons, and reason codes. Its top-level schema_version is 1.

A decision snapshot always has a winner. An evaluation can also be snapshotted before resolution, so a no-match or conflict can be retained with winningRuleKey(), conflictingRuleKeys(), and evaluations().

Snapshots are transport records, not persistence. The application chooses where to store them 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 exactly one policy should win, rules change over time, fallbacks matter, and you need to explain the result later.

  • Date checks keep accumulating in an application service.
  • An older policy must still be available when evaluating an explicit date.
  • Several rules can apply and the winner or fallback must be explicit.
  • 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 is Laravel Rulebook tested?

The package uses Pest, Orchestra Testbench, Larastan, PHPStan deprecation rules, architecture tests, and Laravel Pint. Tests cover resolution and conflicts, validity boundaries, stable and invalid keys, captured rule metadata, structured statuses, reason codes, snapshots, custom outcome normalization, unsupported values, and invalid UTF-8.

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 rule versions can run, priority decides the winner, and every evaluation records what happened.

Can Laravel Rulebook reproduce historical decisions?

resolveAt() and evaluateAt() apply the currently deployed code and data to an explicit date. This can run an earlier policy version, but it cannot prove the exact result produced in the past if code or mutable data changed. Persist a decision snapshot when the exact record 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.

Credits and package links

Laravel Rulebook is maintained by Mathias Onea and released under the MIT license. If you maintain effective-date business logic, I would be interested to hear where this model feels useful and where the API feels heavy or breaks down.