编程

Laravel Rulebook: 随日期变化的业务规则

12 2026-10-22 22:35:00

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 天标准。通话的其他条件均未改变。

你还可以查看评估的其余部分。每一项考量过的规则都附有状态和原因:

规则状态原因
NoRefundapplicable除非适用其他规定,否则门票不可退款。
GoodwillRefunddoes_not_apply取消是在30天“善意”宽限期内进行的。 [inside_goodwill_window]
FlexibleFareRefund2025outside_validity该规则在  2026-11-02T09:00:00.000000-05:00 期间不适用
FlexibleFareRefund2026does_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 上找到。