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.
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 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.
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 the date you supply. Create a snapshot when the decision is made to keep the result after code or data changes.
$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.
| 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 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.
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.
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.
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.