Laravel Packages

Laravel Rulebook: Business Rules That Change by Date

Published
Laravel Rulebook: Business Rules That Change by Date image

Laravel Rulebook is a package from Mathias Onea for business rules that change over time. Rules are ordinary PHP classes, and resolving one gives you a single winner at a point in time, along with the reasoning behind it.

The package is for decisions that have to be explained after the fact. A refund quoted in March under terms that have since been rewritten, or an invoice raised on last year's commission rate. If the policy was edited in place, the only record of what the code used to do is the git history, and you can't paste that into a reply to a customer.

Main Features

  • Each rule declares the window it was in force with always(), from(), until(), or between(), so last year's policy stays in the codebase beside this year's. Windows are half-open, so consecutive years never overlap.
  • Resolution returns exactly one winner, picked by priority() and never by position in the rules array.
  • Every rule comes back with a status and a reason, whether it won, lost, or fell outside its window. Reasons can carry a reasonCode you filter on.
  • When a decision cannot be made, it throws: NoMatchingRule when nothing applies and AmbiguousRuleMatch when two rules tie.
  • A snapshot() can freeze a decision into a JSON record that you can store with the record it applies to.
  • Rules come from the service container, and a rulebook is generic over its subject, context, and outcome.
  • No facade, registry, config file, or migration.

It needs PHP 8.3 and Laravel 12 or 13:

composer require mathiasonea/laravel-rulebook

Building a Refund Rulebook

Take an events platform. Until the end of 2025, a flexible ticket was refunded in full with seven days' notice. From January 2026, the notice period went up to fourteen days, and a $3.50 handling fee was introduced. Two more rules have lower priority: a goodwill refund of half the ticket when someone cancels more than 30 days out, and a default of no refund at all.

The yearly policies share their eligibility checks, so the parent holds the logic and each year supplies its own numbers:

abstract class FlexibleFareRefund extends Rule
{
public function priority(): int
{
return 100;
}
 
public function evaluate(RuleInput $input): RuleResult
{
$ticket = $input->subject(Ticket::class);
$cancellation = $input->context(Cancellation::class);
 
if ($cancellation->fare !== 'flexible') {
return RuleResult::doesNotApply(
reason: 'The ticket was sold on a saver fare.',
reasonCode: 'fare_not_flexible',
);
}
 
if ($cancellation->daysBeforeEvent < $this->noticeInDays()) {
return RuleResult::doesNotApply(
reason: "A flexible fare needs {$this->noticeInDays()} days of notice.",
reasonCode: 'insufficient_notice',
);
}
 
return RuleResult::applies(
outcome: new Refund($ticket->priceInCents - $this->handlingFeeInCents()),
reason: "Refunded under the {$this->policyYear()} flexible fare policy.",
);
}
 
abstract protected function policyYear(): int;
 
abstract protected function noticeInDays(): int;
 
abstract protected function handlingFeeInCents(): int;
}

The 2025 rule closes on the first of January, and the 2026 rule opens at the same instant:

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 policyYear(): int { return 2025; }
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 policyYear(): int { return 2026; }
protected function noticeInDays(): int { return 14; }
protected function handlingFeeInCents(): int { return 350; }
}

The rulebook itself only names the rules:

/** @extends Rulebook<Ticket, Cancellation, Refund> */
final class RefundRulebook extends Rulebook
{
protected function rules(): array
{
return [
NoRefund::class,
GoodwillRefund::class,
FlexibleFareRefund2025::class,
FlexibleFareRefund2026::class,
];
}
}

Now ask for the refund on an $89.00 ticket cancelled with ten days' notice, in November 2025:

$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
$decision->winningResult()->reason(); // Refunded under the 2025 flexible fare policy.

Change the date to 2026, and the refund comes back as $0.00 because ten days is short of the fourteen days the newer policy requires. Nothing else about the call changed.

You also get the rest of the evaluation. Every rule that was considered has a status and a reason:

Rule Status Reason
NoRefund applicable Tickets are non-refundable unless another policy applies.
GoodwillRefund does_not_apply The cancellation is inside the 30-day goodwill window. [inside_goodwill_window]
FlexibleFareRefund2025 outside_validity The rule is not valid at 2026-11-02T09:00:00.000000-05:00.
FlexibleFareRefund2026 does_not_apply A flexible fare needs 14 days of notice. [insufficient_notice]

NoRefund wins because it's the last one standing, and the table above explains why. A rule marked outside_validity was skipped without evaluate() running at all, which is how you tell "this policy did not exist yet" apart from "this policy looked at the ticket and said no".

To keep that record, call snapshot() on the decision. Scalars, arrays, backed enums, and JsonSerializable outcomes go through as they are, and anything else takes a callback:

$snapshot = $decision->snapshot(
normalizeOutcome: static fn (Refund $r): array => ['amount_in_cents' => $r->amountInCents],
);
 
$refund->update(['policy_snapshot' => json_encode($snapshot)]);

The snapshot implements JsonSerializable, so you can use json_encode() on it. You can also use toArray() when the database column you are storing the snapshot in has an array cast on the model, and you want Eloquent to encode it on save. Here is a condensed record for the 2025 refund, with three of its four evaluations left out:

{
"schema_version": 1,
"evaluated_at": "2025-11-02T09:00:00.000000-05:00",
"winning_rule_key": "App\\FlexibleFareRefund2025",
"outcome": { "amount_in_cents": 8900 },
"evaluations": [
{
"key": "App\\GoodwillRefund",
"rule_class": "App\\GoodwillRefund",
"priority": 50,
"valid_from": null,
"valid_until": null,
"status": "does_not_apply",
"reason": "The cancellation is inside the 30 day goodwill window.",
"reason_code": "inside_goodwill_window"
}
]
}

Note the key field. It defaults to the class name, so if you rename a rule, the identifier changes in every record you have already stored. Give each rule a key() of its own, something like refunds.flexible-fare.2026, before any of these reach a database.

When Not to Use Rulebook

For a single date check in one service, a match expression is clearer than four classes and a rulebook. Use Rulebook when someone will ask about the same decision months later, and you have to show how you reached the number.

There is no DSL, no rules stored in a database or edited through an admin screen, no workflow or state machine behaviour, and no outcome composed from several winners. Resolving an old date reproduces the policy as today's classes express it, which is not a replay of the original execution, so this is less than a full audit trail.

The source and a runnable example application are on GitHub, along with the full documentation.

Yannick Lyn Fatt photo

Staff Writer at Laravel News and Full stack web developer.

Sponsored

serpapi logo
SerpApi

The Web Search API for Your LLM and AI Applications

Visit SerpApi

The latest

View all →
Taylor disabled GitHub Issues on most Laravel open-source packages. image

Taylor disabled GitHub Issues on most Laravel open-source packages.

Read article
Exclude Vendor and Default Commands in `php artisan dev` image

Exclude Vendor and Default Commands in `php artisan dev`

Read article
The Laracon Archive image

The Laracon Archive

Read article
Group Adjacent Collection Items in Laravel with chunkBy() image

Group Adjacent Collection Items in Laravel with chunkBy()

Read article
Laravel queue:work Now Prints Why the Worker Stopped image

Laravel queue:work Now Prints Why the Worker Stopped

Read article
Sidecar Brings Statamic's Control Panel to Your Existing Markdown Sites image

Sidecar Brings Statamic's Control Panel to Your Existing Markdown Sites

Read article