Mathias Onea випустив Laravel Rulebook - пакет для роботи з бізнес-правилами, які змінюються з часом. Правила визначаються як звичайні PHP-класи, а їх розв'язання повертає єдиного переможця на конкретний момент часу разом із обґрунтуванням рішення.
Пакет призначений для рішень, які потрібно пояснити постфактум. Наприклад, відшкодування, розраховане в березні за умовами, які вже змінилися, або рахунок, виставлений за минулорічною ставкою комісії. Якщо політику редагували на місці, єдиний запис про те, як працював код раніше, - це історія git, а її не вставиш у відповідь клієнту.
Основні Можливості
- Кожне правило оголошує вікно свого застосування через
always(), from(), until() або between(), тому торішня політика залишається в кодовій базі поруч із цьогорічною. Вікна напіввідкриті, тому послідовні роки ніколи не перекриваються.
- Розв'язання повертає рівно одного переможця, обраного за
priority(), а не за позицією в масиві правил.
- Кожне правило повертається зі статусом та причиною - чи воно перемогло, програло, або опинилося поза своїм вікном. Причини можуть містити
reasonCode для фільтрації.
- Коли рішення прийняти неможливо, викидається виняток:
NoMatchingRule, коли нічого не підходить, та AmbiguousRuleMatch, коли два правила отримують однаковий пріоритет.
snapshot() може заморозити рішення в JSON-запис, який можна зберегти разом із відповідним записом.
- Правила беруться з service container, а rulebook є узагальненим над своїм subject, context та outcome.
- Без фасадів, реєстру, конфігураційного файлу чи міграцій.
Пакет вимагає PHP 8.3 та Laravel 12 або 13:
composer require mathiasonea/laravel-rulebook
Створення Rulebook для Відшкодувань
Розглянемо платформу для подій. До кінця 2025 року гнучкий квиток повністю відшкодовувався за умови повідомлення за сім днів. З січня 2026 року термін повідомлення зріс до чотирнадцяти днів, а також було введено збір за обробку $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 року закривається першого січня, а правило 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,
];
}
}
Використання Rulebook
Тепер запитаємо відшкодування для квитка вартістю $89.00, скасованого за десять днів, у листопаді 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.
Змініть дату на 2026 рік, і відшкодування повернеться як $0.00, оскільки десять днів менше чотирнадцяти днів, які вимагає новіша політика. Більше нічого у виклику не змінилося.
Ви також отримуєте решту оцінки. Кожне правило, яке було розглянуто, має статус та причину. Наприклад, NoRefund має статус applicable, GoodwillRefund - does_not_apply з причиною "Скасування всередині 30-денного вікна доброї волі", FlexibleFareRefund2025 - outside_validity з причиною "Правило недійсне на 2026-11-02", а FlexibleFareRefund2026 - does_not_apply з причиною "Гнучкий тариф потребує 14 днів повідомлення".
NoRefund перемагає, оскільки це останнє правило, що залишилося, а таблиця пояснює чому. Правило з міткою outside_validity було пропущено без виконання evaluate(), що дозволяє відрізнити "ця політика ще не існувала" від "ця політика переглянула квиток і сказала ні".
Збереження Рішень
Щоб зберегти цей запис, викличте snapshot() на рішенні. Скаляри, масиви, backed enum та JsonSerializable outcomes проходять як є, а все інше потребує callback:
$snapshot = $decision->snapshot(
normalizeOutcome: static fn (Refund $r): array => ['amount_in_cents' => $r->amountInCents],
);
$refund->update(['policy_snapshot' => json_encode($snapshot)]);
Snapshot реалізує JsonSerializable, тому ви можете використовувати json_encode() на ньому. Ви також можете використовувати toArray(), коли стовпець бази даних має cast array на моделі, і ви хочете, щоб Eloquent закодував його при збереженні.
Ось скорочений запис для відшкодування 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. За замовчуванням воно відповідає імені класу, тому якщо ви перейменуєте правило, ідентифікатор зміниться в кожному вже збереженому записі. Дайте кожному правилу власний key(), щось на кшталт refunds.flexible-fare.2026, до того, як будь-які з них потраплять до бази даних.
Коли Не Використовувати Rulebook
Для однієї перевірки дати в одному сервісі вираз match буде зрозумілішим, ніж чотири класи та rulebook. Використовуйте Rulebook, коли хтось запитає про те саме рішення через місяці, і вам потрібно показати, як ви дійшли до числа.
Немає DSL, немає правил, збережених у базі даних або відредагованих через адмін-панель, немає workflow або поведінки state machine, і немає результату, складеного з кількох переможців. Розв'язання старої дати відтворює політику так, як її виражають сьогоднішні класи, що не є повторним відтворенням оригінального виконання, тому це менше, ніж повний audit trail.
Вихідний код та приклад застосунку доступні на GitHub разом із повною документацією.