---
title: "Laravel Rulebook: Бізнес-Правила, Що Змінюються з Часом"
url: https://laravelukraine.com/blog/laravel-rulebook-biznes-pravila-shho-zminiuiutsia-z-casom
date: 2026-09-08
source: https://laravel-news.com/laravel-rulebook?utm_medium=feed&utm_source=feedpress.me&utm_campaign=Feed%3A+laravelnews
---

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

[Mathias Onea](https://github.com/mathiasonea) випустив Laravel Rulebook - пакет для роботи з бізнес-правилами, які змінюються з часом. Правила визначаються як звичайні PHP-класи, а їх розв'язання повертає єдиного переможця на конкретний момент часу разом із обґрунтуванням рішення.

Пакет призначений для рішень, які потрібно пояснити постфактум. Наприклад, відшкодування, розраховане в березні за умовами, які вже змінилися, або рахунок, виставлений за минулорічною ставкою комісії. Якщо політику редагували на місці, єдиний запис про те, як працював код раніше, - це історія git, а її не вставиш у відповідь клієнту.

## Основні Можливості

- Кожне правило оголошує вікно свого застосування через `always()`, `from()`, `until()` або `between()`, тому торішня політика залишається в кодовій базі поруч із цьогорічною. Вікна напіввідкриті, тому послідовні роки ніколи не перекриваються.
- Розв'язання повертає рівно одного переможця, обраного за `priority()`, а не за позицією в масиві правил.
- Кожне правило повертається зі статусом та причиною - чи воно перемогло, програло, або опинилося поза своїм вікном. Причини можуть містити `reasonCode` для фільтрації.
- Коли рішення прийняти неможливо, викидається виняток: `NoMatchingRule`, коли нічого не підходить, та `AmbiguousRuleMatch`, коли два правила отримують однаковий пріоритет.
- `snapshot()` може заморозити рішення в JSON-запис, який можна зберегти разом із відповідним записом.
- Правила беруться з [service container](https://laravel-news.com/service-container-management), а rulebook є узагальненим над своїм subject, context та outcome.
- Без фасадів, реєстру, конфігураційного файлу чи міграцій.

Пакет вимагає PHP 8.3 та Laravel 12 або 13:

```
composer require mathiasonea/laravel-rulebook
```

## Створення Rulebook для Відшкодувань

Розглянемо платформу для подій. До кінця 2025 року гнучкий квиток повністю відшкодовувався за умови повідомлення за сім днів. З січня 2026 року термін повідомлення зріс до чотирнадцяти днів, а також було введено збір за обробку $3.50. Ще два правила мають нижчий пріоритет: відшкодування доброї волі в розмірі половини вартості квитка при скасуванні більш ніж за 30 днів, та правило за замовчуванням - відсутність відшкодування.

Річні політики поділяють перевірки придатності, тому батьківський клас містить логіку, а кожен рік надає власні числа:

```php
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 року відкривається в той самий момент:

```php
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 лише перераховує правила:

```php
/** @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 року:

```php
$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:

```php
$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 року:

```json
{
  "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](https://laravel-news.com/yammi-audit-log-track-who-really-made-a-change-across-jobs-and-queues).

Вихідний код та приклад застосунку доступні на [GitHub](https://github.com/mathiasonea/laravel-rulebook) разом із повною документацією.
