Skip to content

Laravel Rulebook: Manage Business Rules That Change by Date

Laravel Rulebook is a package that lets you model time-bound business rules as plain PHP classes, resolve exactly one winner at any point in time, and snapshot the full decision for later explanation.

Published · Updated · 4 min read

Laravel Rulebook: Manage Business Rules That Change by Date

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(), or between(). 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 optional reasonCode.
  • Explicit failure modes — throws NoMatchingRule when nothing applies, and AmbiguousRuleMatch when 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

Frequently asked questions

How does Laravel Rulebook handle two rules with overlapping validity periods?

Validity windows are half-open intervals, so a rule closing on 2026-01-01 and a rule opening on 2026-01-01 never overlap. If two rules do match the same point in time and share the same priority, Rulebook throws an `AmbiguousRuleMatch` exception rather than silently picking one.

Can I replay an old decision exactly as it was originally executed?

Not exactly. Resolving at a past date reproduces the policy as today's classes express it. If you have since changed the logic inside a rule class, the re-resolution will use the updated code. For a true replay you need to store a snapshot at decision time and read that back instead.

What happens if I rename a rule class after snapshots have been stored?

By default the rule's identifier is its fully-qualified class name, so renaming the class breaks the link in every stored snapshot. To avoid this, override the `key()` method on each rule with a stable string such as `refunds.flexible-fare.2026` before any snapshots reach the database.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project