Laravel package

Why I Built Laravel Rulebook

Business rules change. Old decisions still need to make sense. I kept running into dated conditionals in client projects that were easy to add and hard to explain later. Laravel Rulebook came out of that problem: pass it an object and a date, get back the winning rule and why it won.

The pattern I kept seeing

The first date check is usually harmless. A price changes next January, so another branch goes into the service. Then another market needs an exception. Then a fallback appears. After a while, changing the current rule also changes how an old invoice is calculated.

I have seen versions of this in pricing, billing, eligibility, commissions, and regional terms. The code still returns the right number today, but it becomes difficult to answer what should have happened six months ago.

That was the part I cared about. I wanted to add next year’s rule without editing last year’s rule, and I did not want array order to decide which one won.

php· Where this usually starts
if ($invoice->issued_at < new DateTimeImmutable('2026-01-01T00:00:00+01:00')) {
    return $this->priceUnder2025Policy($vehicle);
}

if ($invoice->issued_at < new DateTimeImmutable('2027-01-01T00:00:00+01:00')) {
    return $this->priceUnder2026Policy($vehicle);
}

return $this->currentPrice($vehicle);

The API I wanted

The call site should stay small. Pass the object the decision is about, the date, and any extra context. Get back the outcome, the winning rule, and the reason.

In the example below, the subject is a `Vehicle` and the context supplies the country and other pricing inputs. In another application, the subject might be an invoice while the context carries the customer type.

The rules are normal PHP classes and Laravel resolves them through the container, so constructor injection works as expected. I did not add a facade, global registry, or separate rule language.

php· Resolve the price that applied when the invoice was issued
$decision = $vehiclePricingRulebook->resolveAt(
    subject: $vehicle,
    at: $invoice->issued_at,
    context: $pricingContext,
);

$price = $decision->outcome();
$rule = $decision->winningRule();
$reason = $decision->winningResult()->reason();

A runnable Austrian EV example

For the demo, I chose a fictional Austrian EV pricing policy. The same 75 kWh vehicle is priced in 2025, 2026, and 2027, with a separate rule for each year.

This is the behavior I wanted: adding the 2027 policy does not hide or rewrite what applied in 2025. A general Austrian price and a global default remain available as lower-priority fallbacks.

The example is a small Laravel 12 console application. Run one command and it prints the winner, its reason, the fallbacks, and the rules skipped for that date.

  • 2025 policy: EUR 35,000 base − EUR 4,000 incentive = EUR 31,000.
  • 2026 policy: EUR 35,000 base − EUR 2,800 incentive + EUR 4 per kWh = EUR 32,500.
  • 2027 policy: EUR 35,500 base − EUR 1,000 incentive + EUR 5 per kWh = EUR 34,875.
bash· Run the same subject through three policy dates
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

How Rulebook picks a winner

Rulebook works in a fixed order. It checks the date, asks the remaining rules whether they apply to the subject and context, and then picks the applicable rule with the highest priority.

`resolveNow()` uses Laravel’s current clock. `resolveAt()` accepts an explicit date and evaluates the rules and data that are currently deployed for that date. It is useful for running an older policy version, but it is not an immutable record of a past execution.

I kept validity, applicability, and priority separate so changing registration order cannot change the answer. If two matching rules tie for the highest priority, Rulebook throws `AmbiguousRuleMatch` instead of guessing.

Validity periods include their start and exclude their end. A rule outside that range is recorded as skipped, but its application code is never called.

php· Give each policy version an explicit validity window
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'),
    );
}

Seeing why a rule won

When I investigate a price, the number alone is not very helpful. I want the rule that produced it, its reason, and the other rules it beat or skipped. Rulebook keeps those together for the current evaluation.

A global fallback can match and still lose to the more specific Austrian rule. It does not disappear from the result just because its priority is lower.

`resolveNow()` and `resolveAt()` require one winner. If I want to inspect the complete result even when nothing matches or two rules tie, I use `evaluateNow()` or `evaluateAt()`.

php· The details I want when debugging a decision
$decision = $rulebook->resolveAt($subject, $at, $context);

$decision->outcome();
$decision->winningRule();
$decision->winningRuleKey();
$decision->winningResult()->reason();
$decision->evaluations();
$decision->evaluationFor('austria.ev-price.2026');
$decision->shadowedEvaluations();

Keeping the decision that was actually made

There is an important difference between running today’s rules for an old date and keeping the result that was produced at the time. `resolveAt()` does the first. A snapshot handles the second.

Code and supporting data can change. A later `resolveAt()` call therefore uses the code and data deployed when that call runs, even if its decision date is in the past.

When I need an exact record, I call `snapshot()` when the decision is made and store the JSON-compatible value next to the invoice or quote. It keeps the decision time, winning rule key, normalized outcome, statuses, validity windows, reasons, and optional reason codes. Rulebook creates the record; the application decides where it belongs.

php· Create a portable record of the decision
$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);

Where Rulebook fits

I would use Rulebook when a business answer depends on the date and must be evaluated at an explicit date or explained later. Pricing, billing terms, eligibility, commissions, shipping terms, and regional policies are good examples.

You probably have this problem if an application service keeps collecting `if ($date >= ...)` branches while the old answers still matter. The same applies to a list of fallbacks where moving one class changes the result.

Not every conditional needs a package. I would keep a simple one-off check inline. I also did not try to turn Rulebook into a visual rule builder or workflow engine. It does one thing: choose one code-defined rule.

Try it on one real decision

Laravel Rulebook is available on Packagist. It supports PHP 8.3 and newer, Laravel 12 and 13, and is released under the MIT license.

The package page has the installation steps and API examples. You can also clone the Austrian EV example and run the three dates yourself.

If you try it on effective-date business logic, I would be interested to hear where the model feels useful and where the API feels heavy or breaks down.

bash· Install Laravel Rulebook from Packagist
composer require mathiasonea/laravel-rulebook

A few practical questions

Is Laravel Rulebook a general-purpose rules engine?

No. Laravel Rulebook selects exactly one code-defined rule. It does not provide a DSL, database or visual rule editing, workflow orchestration, or a way to run several matching actions.

Does rule registration order matter?

No. All applicable rules are compared by priority. Moving a rule up or down in the rules array does not change which rule wins.

What happens when two rules share the highest priority?

Rulebook throws AmbiguousRuleMatch and includes the full evaluation. Registration order never breaks an equal-priority tie, so the conflict is visible and testable.

Are rules stored in the database?

No. Rules are PHP classes resolved through Laravel’s container. Rulebook can create portable decision snapshots, but the application chooses whether and where to store them.

Can resolveAt() reproduce the exact historical execution?

Not by itself. It applies the currently deployed code and data to an explicit date. Persist a decision snapshot when the exact outcome and explanation must survive later code or data changes.

Try the package

Package, example app, and related work