What Is Laravel Rulebook?
Laravel Rulebook by Mathias Onea is a package for encoding business rules that change over time. Each rule is a plain PHP class with a declared validity window. When you resolve a rulebook at a specific date, you get exactly one winning rule, the outcome it produced, and a full explanation of every rule that was considered.
The core use case is decisions that must be explained after the fact — a refund calculated under last March's terms, or an invoice raised at last year's commission rate. Editing a policy in place destroys that history; Rulebook keeps every version of the policy alive in the codebase.
Key Features
- Validity windows — declare a rule's active period with
always(),from(),until(), orbetween(). Windows are half-open, so consecutive periods never overlap. - Priority-based resolution — exactly one winner is chosen by
priority(), never by array position. - Rich result objects — every rule returns a status (
applicable,does_not_apply,outside_validity) and a human-readable reason with an optionalreasonCode. - Explicit failure modes — throws
NoMatchingRulewhen nothing applies, andAmbiguousRuleMatchwhen two rules tie. - Snapshots — freeze a decision into a JSON-serializable record you can store alongside the data it governs.
- No magic — no facade, no config file, no migration, no database-stored rules.
Requires PHP 8.3 and Laravel 12 or 13:
composer require mathiasonea/laravel-rulebook
Building a Time-Versioned Refund Policy
Consider an events platform with two flexible-fare refund policies. Until end of 2025: full refund with 7 days' notice, no fee. From January 2026: 14 days' notice required and a $3.50 handling fee.
A shared abstract class holds the evaluation logic; each year's rule supplies its own numbers and validity window:
final class FlexibleFareRefund2025 extends FlexibleFareRefund
{
public function validity(): ValidityPeriod
{
return ValidityPeriod::between(
from: new DateTimeImmutable('2025-01-01T00:00:00-05:00'),
until: new DateTimeImmutable('2026-01-01T00:00:00-05:00'),
);
}
protected function noticeInDays(): int { return 7; }
protected function handlingFeeInCents(): int { return 0; }
}
final class FlexibleFareRefund2026 extends FlexibleFareRefund
{
public function validity(): ValidityPeriod
{
return ValidityPeriod::from(new DateTimeImmutable('2026-01-01T00:00:00-05:00'));
}
protected function noticeInDays(): int { return 14; }
protected function handlingFeeInCents(): int { return 350; }
}
Resolving at a specific point in time is a single call:
$decision = $rulebook->resolveAt(
subject: new Ticket(reference: 'TCK-4193', priceInCents: 89_00),
at: new DateTimeImmutable('2025-11-02T09:00:00-05:00'),
context: new Cancellation(fare: 'flexible', daysBeforeEvent: 10),
);
$decision->outcome()->formatted(); // $89.00
class_basename($decision->winningRule()); // FlexibleFareRefund2025
Shift the date to 2026 and the same call returns $0.00 — ten days falls short of the 2026 policy's fourteen-day requirement. The FlexibleFareRefund2025 rule shows status outside_validity, making it clear the policy simply did not exist at that date rather than actively rejecting the claim.
Storing Decisions with Snapshots
$snapshot = $decision->snapshot(
normalizeOutcome: static fn (Refund $r): array => ['amount_in_cents' => $r->amountInCents],
);
$refund->update(['policy_snapshot' => json_encode($snapshot)]);
The snapshot is JsonSerializable and also exposes toArray() for Eloquent models with an array cast. One important detail: the rule identifier defaults to the class name. Rename a class and every stored snapshot loses its link. Assign a stable key() — such as refunds.flexible-fare.2026 — before any snapshots reach a database.
When Not to Use Rulebook
A single date check in one service is clearer as a match expression. Rulebook pays off when:
- Decisions span multiple policy eras.
- Someone will ask months later why a specific number was calculated.
- You need a stored, human-readable explanation alongside the record.
It is not a DSL, not a workflow engine, and not a full audit trail — resolving an old date reproduces the policy as today's classes express it, not a replay of the original execution.
Real Takeaways
- Keep every policy version in code; validity windows prevent overlap without extra logic.
resolveAt()makes time-travel testing trivial — change one argument, get a different winner.- The full evaluation table (not just the winner) is available on every decision object.
- Assign stable
key()values to rules before persisting any snapshots. - The package has zero infrastructure requirements: no migrations, no config, no facade.
Source: Laravel Rulebook: Business Rules That Change by Date — Laravel News