Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама
Новини 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 разом із повною документацією.

6

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

Коментарі

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

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

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

PayZephyr
Новини 12 вересня 2026

PayZephyr: Єдиний API для роботи з Stripe, Paystack та PayPal

PayZephyr - Laravel-пакет від Nwaneri Chukwunyere Kenneth, який об'єднує вісім платіжних провайдерів під одним зручним API. Підтримує автоматичне перемикання, захист від подвійної оплати, підписки та повернення коштів.

2
Laravel Telescope
Новини 11 вересня 2026

Artisan-команди для дебагу в Laravel Telescope 5.24.0

Laravel Telescope 5.24.0 додає дві нові Artisan-команди для роботи із записами із терміналу. Тепер можна переглядати запити, винятки, джоби та запити до бази даних без відкриття веб-інтерфейсу, а також отримувати дані у форматі JSON для скриптів та AI-агентів.

3

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

Full Stack Developer (PHP, React, Middle, Middle+)

Full Stack розробник для підтримки та розвитку аналітичного продукту перевірки контрагентів. Робота зі складною бізнес-логікою, базами даних та інтеграцією AI-рішень. Стек: PHP 8.x (Laravel/Symfony), React, MySQL, REST API. Вимоги: 3+ років комерційного досвіду, глибоке розуміння SQL, Git, Docker, CI/CD, Linux, OWASP.

Програміст PHP (інтерн)

Вакансія на посаду інтерна PHP розробника для початківців з теоретичною базою ООП та базовими знаннями PHP. Потрібні навички Git/GitHub, власні проєкти. Стажування в офісі під керівництвом менторів з перспективою переходу на посаду Junior разробника.

Starlight Media Нова
2 дні тому

Senior Full-Stack Developer (PHP, Go, Vue.js)

Senior Full-Stack розробник для розвитку та підтримки веб-платформ. Основні завдання: розробка на PHP (Yii2, Laravel), Go, Vue.js, MySQL оптимізація, REST API, рефакторинг коду. Вимоги: 3+ років досвіду, PHP 8.x ООП, досвід Go або готовність його розвивати, JavaScript ES6+, Git, Docker, англійська B1-B2.

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

Bagisto

bagisto/bagisto

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

28,086 v2.5.0-beta1 13 25

Lang

laravel-lang/lang

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

7,774 15.34.8 11