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.
composer require mathiasonea/laravel-rulebook
A resolved decision gives you the outcome, winning rule, reason, and decision time:
$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.
/** @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.
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.
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.
$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.
$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.
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.
$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.
| Policy | Base price | Incentive | Battery fee | Resolved price |
|---|---|---|---|---|
| 2025 | EUR 35,000 | EUR 4,000 | EUR 0/kWh | EUR 31,000 |
| 2026 | EUR 35,000 | EUR 2,800 | EUR 4/kWh | EUR 32,500 |
| 2027 | EUR 35,500 | EUR 1,000 | EUR 5/kWh | EUR 34,875 |
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.
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.