Laravel Rulebook: 随日期变化的业务规则
Laravel Rulebook 是由 Mathias Onea 开发的一个软件包,用于处理随时间变化的业务规则。规则本质上是普通的 PHP 类;解析规则时,系统会在特定时间点确定唯一的“胜出”规则,并提供相应的判定依据。
该软件包适用于那些事后需要解释缘由的决策场景。例如:根据已修订条款计算出的三月份退款,或是基于去年的佣金费率开具的发票。如果直接修改现有策略代码,关于旧逻辑的唯一记录便只存在于 Git 历史中,而你显然无法将 Git 记录直接复制粘贴给客户看。
主要特性
- 每条规则都通过
always()、from()、until()或between()声明其生效时段,因此去年的策略代码可以与今年的策略代码共存于同一代码库中。生效时段采用“半开区间”设计,确保连续年份之间不会发生重叠。 - 解析过程仅返回一个胜出结果,该结果由
priority()(优先级)决定,而非取决于规则在数组中的排列顺序。 - 无论规则是胜出、落选还是处于生效时段之外,系统都会返回包含状态(status)和理由(reason)的结果。理由中可包含
reasonCode,便于进行筛选。 - 当无法做出决策时,系统会抛出异常:无匹配规则时抛出
NoMatchingRule,两项规则优先级持平时抛出AmbiguousRuleMatch。 - 支持使用
snapshot()将决策结果固化为 JSON 记录,以便将其与关联的业务数据一并存储。 - 规则由服务容器(Service Container)解析,且 Rulebook 针对其主体(subject)、上下文(context)和结果(outcome)采用泛型设计。
- 无需使用 Facade、注册表(Registry)、配置文件或数据库迁移(Migration)。
要求 PHP 8.3 及 Laravel 12 或 13:
composer require mathiasonea/laravel-rulebook制定退款规则体系
以某活动平台为例:在 2025 年底之前,若提前七天通知,灵活票可获全额退款。自 2026 年 1 月起,通知期限延长至十四天,并增收 3.50 美元的手续费。此外还有两条优先级较低的规则:若在活动开始前 30 天以上取消,可作为“善意退款”退还票价的一半;若不符合上述条件,则默认不予退款。
各年度的政策共用同一套资格校验机制,因此由父级逻辑统一处理校验流程,而各年度政策仅需提供各自的参数值:
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;
}2025 年规则于 1 月 1 日结束,2026 年规则同时开始:
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; }
}Rulebook 本身只命名规则:
/** @extends Rulebook<Ticket, Cancellation, Refund> */
final class RefundRulebook extends Rulebook
{
protected function rules(): array
{
return [
NoRefund::class,
GoodwillRefund::class,
FlexibleFareRefund2025::class,
FlexibleFareRefund2026::class,
];
}
}现在,试着申请退还一张在 2025 年 11 月提前十天通知取消的 89 美元机票的费用:
$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.如果将日期改为 2026 年,退款金额就会变为 0.00 美元,因为 10 天的时长未达到新政策要求的 14 天标准。通话的其他条件均未改变。
你还可以查看评估的其余部分。每一项考量过的规则都附有状态和原因:
| 规则 | 状态 | 原因 |
|---|---|---|
| NoRefund | applicable | 除非适用其他规定,否则门票不可退款。 |
| GoodwillRefund | does_not_apply | 取消是在30天“善意”宽限期内进行的。 [inside_goodwill_window] |
| FlexibleFareRefund2025 | outside_validity | 该规则在 2026-11-02T09:00:00.000000-05:00 期间不适用 |
| FlexibleFareRefund2026 | does_not_apply | 灵活票价需提前 14 天通知。 [insufficient_notice] |
NoRefund 胜出是因为它是最终留下的结果,上表解释了其中缘由。标记为 outside_validity 的规则在 evaluate() 根本未执行的情况下就被跳过了;正是通过这种方式,你可以区分“该策略尚不存在”与“该策略已评估工单并给出了否定结果”这两种情况。
若要保留该记录,请对决策对象调用 snapshot()。标量、数组、支持枚举(backed enums)以及实现了 JsonSerializable 接口的结果会按原样处理,而其他类型则需要提供回调函数:
$snapshot = $decision->snapshot(
normalizeOutcome: static fn (Refund $r): array => ['amount_in_cents' => $r->amountInCents],
);
$refund->update(['policy_snapshot' => json_encode($snapshot)]);该快照实现了 JsonSerializable 接口,因此你可以对其使用 json_encode()。此外,如果你将快照存储在数据库列中,且模型已将该列转换为数组类型(array cast),并希望 Eloquent 在保存时自动进行编码,则可以使用 toArray() 方法。以下是 2025 年退款记录的精简版,其中省略了四项评估中的三项:
{
"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"
}
]
}请注意其中的 key 字段。该字段默认使用类名,这意味着如果你重命名了某个规则,那么所有已存储记录中的标识符也会随之改变。因此,在将规则存入数据库之前,应为每条规则指定一个独立的键(例如 refunds.flexible-fare.2026)。
何时不宜使用 Rulebook
如果只是在某项服务中针对单个日期进行检查,使用匹配表达式(match expression)会比定义四个类加一个 Rulebook 更清晰直观。只有当你预计未来(比如几个月后)有人会询问某项决策的依据,并且你需要展示该结果是如何得出的时候,才适合使用 Rulebook。
该方案不涉及 DSL(领域特定语言),不将规则存储在数据库中或通过管理界面进行编辑,也不包含工作流或状态机逻辑,更不会将多个“胜出”结果组合成最终输出。针对过去的日期重新进行解析时,系统会依据当前的类定义来重现策略,这与原始执行过程并不完全一致,因此无法提供完整的审计追踪功能。
源代码、可运行的示例应用程序以及完整文档均可在 GitHub 上找到。