Laravel 包

Laravel Rulebook: Business Rules That Change by Date

发布
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.

主要特点

  • Each rule declares the window it was in force with always() , from() , until() , 或者 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:

作曲家 要求 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:

抽象的 班级 FlexibleFareRefund 延伸 规则
{
民众 功能 优先事项 () : 整数
{
返回 100 ;
}
民众 功能 evaluate RuleInput $input) : RuleResult
{
$ticket = $输入 -> 主题 ::班级 (英文):
$cancellation = $输入 -> 语境 Cancellation ::班级 (英文):
如果 ($cancellation -> fare !== 'flexible' ){
返回 RuleResult :: doesNotApply
原因 : 'The ticket was sold on a saver fare.' ,
reasonCode : 'fare_not_flexible' ,
(英文):
}
如果 ($cancellation -> daysBeforeEvent < $this -> noticeInDays ()){
返回 RuleResult :: doesNotApply
原因 : "A flexible fare needs { $this -> noticeInDays ()} days of notice." ,
reasonCode : 'insufficient_notice' ,
(英文):
}
返回 RuleResult :: applies
结果 : 新的 Refund ($ticket -> priceInCents - $this -> handlingFeeInCents ())
原因 : "Refunded under the { $this -> policyYear ()} flexible fare policy." ,
(英文):
}
抽象的 受保护 功能 policyYear () : 整数 ;
抽象的 受保护 功能 noticeInDays () : 整数 ;
抽象的 受保护 功能 handlingFeeInCents () : 整数 ;
}

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

最终的 班级 FlexibleFareRefund2025 延伸 FlexibleFareRefund
{
民众 功能 validity () : ValidityPeriod
{
返回 ValidityPeriod :: 之间
: 新的 日期时间不可变 '2025-01-01T00:00:00-05:00' ),
直到 : 新的 日期时间不可变 '2026-01-01T00:00:00-05:00' ),
(英文):
}
受保护 功能 policyYear () : 整数 { 返回 2025 ; }
受保护 功能 noticeInDays () : 整数 { 返回 7 ; }
受保护 功能 handlingFeeInCents () : 整数 { 返回 0 ; }
}
最终的 班级 FlexibleFareRefund2026 延伸 FlexibleFareRefund
{
民众 功能 validity () : ValidityPeriod
{
返回 ValidityPeriod :: 新的 日期时间不可变 '2026-01-01T00:00:00-05:00' ));
}
受保护 功能 policyYear () : 整数 { 返回 2026 ; }
受保护 功能 noticeInDays () : 整数 { 返回 14 ; }
受保护 功能 handlingFeeInCents () : 整数 { 返回 350 ; }
}

The rulebook itself only names the rules:

/** @extends Rulebook<Ticket, Cancellation, Refund> */
最终的 班级 RefundRulebook 延伸 Rulebook
{
受保护 功能 规则 () : 大批
{
返回 [
NoRefund ::班级 ,
GoodwillRefund ::班级 ,
FlexibleFareRefund2025 ::班级 ,
FlexibleFareRefund2026 ::班级 ,
];
}
}

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

$decision = $rulebook -> resolveAt
主题 : 新的 参考 : 'TCK-4193' , priceInCents : 89_00 ),
: 新的 日期时间不可变 '2025-11-02T09:00:00-05:00' ),
语境 : 新的 Cancellation fare : 'flexible' , daysBeforeEvent : 10 ),
(英文):
$decision -> 结果 () -> 格式化 (); // $89.00
类基名称 ($decision -> winningRule ()); // FlexibleFareRefund2025
$decision -> winningResult () -> 原因 (); // 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:

规则 地位 原因
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 : 静止的 fn Refund $r) : 大批 => [ 'amount_in_cents' => $r -> amountInCents],
(英文):
$refund -> 更新 ([ '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" : “应用程序 \\ FlexibleFareRefund2025" ,
"outcome" :{ "amount_in_cents" : 8900 },
"evaluations" :[
{
“钥匙” : “应用程序 \\ GoodwillRefund" ,
"rule_class" : “应用程序 \\ GoodwillRefund" ,
“优先事项” : 50 ,
"valid_from" : 无效的 ,
"valid_until" : 无效的 ,
“地位” : "does_not_apply" ,
"reason" : "The cancellation is inside the 30 day goodwill window." ,
"reason_code" : "inside_goodwill_window"
}
]
}

请注意 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 的照片

Laravel News 的特约撰稿人和全栈 Web 开发人员。

Filed in

赞助

masteringlaravel logo
Laravel 代码审查

几天内即可获得 Laravel 代码审查方面的专家指导

访问 Laravel 代码审查
Find Unexpected Test Inputs with Fuzz for Pest image

Find Unexpected Test Inputs with Fuzz for Pest

阅读文章
Taylor disabled GitHub Issues on most Laravel open-source packages. image

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

阅读文章
Exclude Vendor and Default Commands in `php artisan dev` image

Exclude Vendor and Default Commands in `php artisan dev`

阅读文章
The Laracon Archive image

The Laracon Archive

阅读文章
Group Adjacent Collection Items in Laravel with chunkBy() image

Group Adjacent Collection Items in Laravel with chunkBy()

阅读文章
Laravel queue:work Now Prints Why the Worker Stopped image

Laravel queue:work Now Prints Why the Worker Stopped

阅读文章