Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама
Новини 08 вересня 2026

Laravel Rulebook: Бізнес-Правила, Що Змінюються з Часом

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 разом із повною документацією.

26

Читати в документації

Коментарі

Увійдіть, щоб залишити коментар

Будьте першим, хто залишить коментар!

Читайте також

Filament v5.10.0
Новини 12 жовтня 2026

Filament v5.10.0: сітка в rich editor, скелетони таблиць і десятки виправлень

Вийшов Filament v5.10.0: елементи керування сіткою в rich editor, допоміжний контент для віджетів-графіків, скелетони завантаження таблиць, submitOnCompletion() для OneTimeCodeInput та багато виправлень авторизації, доступності й стану.

15
Laravel AI SDK на практиці
Новини 09 жовтня 2026

Laravel AI SDK на практиці: агент, що стоїть за There There

Фрік Ван дер Гертен розповідає, як у Spatie побудували AI-шар хелпдеска There There на Laravel AI SDK: клас агента, інструменти з типізованою JSON-схемою та делегування роботи звичайним domain-діям.

11

Вакансії за темою

Senior PHP Backend Developer

Розробник створюватиме та підтримуватиме інтерфейси веб-застосунку на PHP, розширюватиме наявні модулі, писатиме модульний і масштабований код та бере участь в оцінці й пріоритизації завдань. Ключовий стек: PHP, Laravel, SQL (PostgreSQL, MySQL), NoSQL (MongoDB, Redis), Docker, CI/CD. Вимоги: понад 5 років досвіду PHP backend-розробником, написання тестів (PHPUnit), якісна документація, англійська B1.

SevenPro Нова
Вчора

PHP Full-stack Engineer (part-time)

Розробник PHP full-stack на неповний робочий день для підтримки та поетапної модернізації існуючої PHP-платформи для HR. Завдання: аналіз legacy-коду, перенесення частини адміністративних функцій на самообслуговування клієнта та розробка модулів Time Tracking, підготовки розрахунку зарплат і ESG, а також інтеграції із зовнішніми сервісами. Вимоги: понад 3 роки комерційного досвіду з PHP, досвід роботи з legacy-системами, знання MySQL або MariaDB, HTML, CSS, Bootstrap, jQuery, AJAX, англійська B2

Програміст PHP Full stack Developer

Розробник PHP (фулстек) для розробки та підтримки веб-додатків. Основний стек: PHP 7x/8x, MySQL, Yii 2.0, JavaScript, Vue.js, REST API. Вимоги: 3+ років досвіду на PHP, 2+ років з Yii 2.0, знання MySQL на просунутому рівні, Git, CSS, jQuery. Будуть плюсом: Laravel, Redis, WebSocket, RabbitMQ, Firebase. Робота над власними продуктами компанії (CRM логістичних процесів), робота у команді, code review.

Пакети за темою

Bagisto

bagisto/bagisto

Bagisto — це платформа для електронної комерції, побудована на Laravel. Вона надає готове рішення для створення та управління інтернет-магазинами з підтримкою каталогу товарів, замовлень, платежів та клієнтів.

28,224 v2.5.0 13 26

Lang

laravel-lang/lang

Список 126 мов для Laravel Framework, Laravel Jetstream, Laravel Fortify, Laravel Breeze, Laravel Cashier, Laravel Nova, Laravel Spark та Laravel UI.

7,779 15.37.4 12