<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
ПОВЕДІНКОВИЙ ПАТЕРН

СтанState

Поведінка обʼєкта залежить від його стану, і кожен стан живе в окремому класі. Обʼєкт передає виклики поточному стану, а той знає, що дозволено й куди переходити далі.

Symfony складність 2 з 3 6 хв читання
ГЛІФ
01 · ПРОБЛЕМА

Кожен метод перевіряє статус по-своєму

Вакансія буває чернеткою, опублікованою й закритою. Опублікувати можна лише чернетку, закрити лише опубліковану, редагувати все, крім закритої. Ці правила розкидані по if у кожному методі, а коли додали статус «на модерації», його врахували не всюди.

src/Hiring/Vacancy.php
1final class Vacancy
2{
3 public function publish(): void
4 {
5 if ($this->status === 'draft' || $this->status === 'moderation') {
1Статус «на модерації» сюди додали, а що він дозволяє, доводиться збирати з усіх методів класу.
6 $this->status = 'published';
7 $this->publishedAt = new DateTimeImmutable();
8 } elseif ($this->status === 'published') {
2Для закритої вакансії гілки немає, тож publish() мовчки нічого не робить. Помилку ніхто не побачить.
9 throw new DomainException('Вакансію вже опубліковано');
10 }
11 }
12
13 public function close(): void
14 {
15 if ($this->status !== 'published') {
16 throw new DomainException('Закрити можна лише опубліковану вакансію');
17 }
18 $this->status = 'closed';
19 }
20
21 public function edit(string $title): void
22 {
23 if ($this->status === 'closed') {
3Правило про закриту вакансію повторюється в кожному методі, який її змінює.
24 throw new DomainException('Закриту вакансію не редагують');
25 }
26 $this->title = $title;
27 }
28}
02 · ІДЕЯ РІШЕННЯ

Кожен статус окремим класом

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
$statepublish()close()контекстVacancypublish() · close()«interface»VacancyStatepublish() · close()DraftPublishedClosed
контекстVacancypublish() · close()«interface»VacancyStatepublish() · close()DraftPublishedClosed
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Клієнт реалізує · - - ▷
  • Vacancy використовує VacancyState ($state).
  • Draft реалізує VacancyState.
  • Published реалізує VacancyState.
  • Closed реалізує VacancyState.
  • Дані йдуть від Draft до Published.
  • Дані йдуть від Published до Closed.
03 · КОД

Запустіть і подивіться

PHP 8.5
1final class Vacancy
2{
3 public function publish(): void
4 {
5 if ($this->status === 'draft' || $this->status === 'moderation') {
6 $this->status = 'published';
7 $this->publishedAt = new DateTimeImmutable();
8 } elseif ($this->status === 'published') {
9 throw new DomainException('Вакансію вже опубліковано');
10 }
11 }
12
13 public function close(): void
14 {
15 if ($this->status !== 'published') {
16 throw new DomainException('Закрити можна лише опубліковану вакансію');
17 }
18 $this->status = 'closed';
19 }
20
21 public function edit(string $title): void
22 {
23 if ($this->status === 'closed') {
24 throw new DomainException('Закриту вакансію не редагують');
25 }
26 $this->title = $title;
27 }
28}
1<?php
2
31interface VacancyState
4{
5 public function publish(): VacancyState;
6
7 public function close(): VacancyState;
8
9 public function name(): string;
10}
11
122final class Draft implements VacancyState
13{
14 public function publish(): VacancyState { return new Published(); }
15 public function close(): VacancyState { throw new DomainException('Чернетку не закривають, її видаляють'); }
16 public function name(): string { return 'чернетка'; }
17}
18
19final class Published implements VacancyState
20{
21 public function publish(): VacancyState { throw new DomainException('Вакансію вже опубліковано'); }
22 public function close(): VacancyState { return new Closed(); }
23 public function name(): string { return 'опублікована'; }
24}
25
26final class Closed implements VacancyState
27{
28 public function publish(): VacancyState { throw new DomainException('Закриту вакансію не публікують знову'); }
29 public function close(): VacancyState { throw new DomainException('Вакансію вже закрито'); }
30 public function name(): string { return 'закрита'; }
31}
32
333final class Vacancy
34{
35 private VacancyState $state;
36
37 public function __construct(public readonly string $title)
38 {
39 $this->state = new Draft();
40 }
41
42 public function publish(): void { $this->state = $this->state->publish(); }
43 public function close(): void { $this->state = $this->state->close(); }
44 public function status(): string { return $this->state->name(); }
45}
46
47$vacancy = new Vacancy('Middle Laravel Developer, Київ');
48echo "{$vacancy->title}: {$vacancy->status()}\n";
49
504foreach (['publish', 'publish', 'close', 'publish'] as $action) {
51 try {
52 $vacancy->{$action}();
53 echo " {$action}() → {$vacancy->status()}\n";
54 } catch (DomainException $e) {
55 echo " {$action}() відхилено: {$e->getMessage()}\n";
56 }
57}
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–10

    Кожна дія повертає наступний стан. Що можна зробити з вакансією, видно з одного інтерфейсу.

  2. 2 рядки 12–31

    Правила кожного статусу зібрані в його класі. Новий статус «на модерації» стане ще одним класом, а не гілкою в кожному методі.

  3. 3 рядки 33–45

    Vacancy не містить жодного if по статусу. Вона просто передає виклик поточному стану й запамʼятовує новий.

  4. 4 рядки 50–57

    Заборонений перехід кидає виняток, а не мовчки нічого не робить.

  5. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · У ВАШОМУ ФРЕЙМВОРКУ

Ви вже цим користуєтесь

LARAVEL

Enum статусу з переходами

Окремого компонента станів Laravel не має. Статус зазвичай роблять backed enum з методом, що знає дозволені переходи, а Eloquent перетворює стовпець на enum через casts(). Коли поведінка в статусах суттєво різна, кожен статус стає класом, як вище.

enum VacancyStatus: string
{
    case Draft = 'draft';
    case Published = 'published';
    case Closed = 'closed';
 
    public function canBecome(self $next): bool
    {
        return match ($this) {
            self::Draft => $next === self::Published,
            self::Published => $next === self::Closed,
            self::Closed => false,
        };
    }
}
 
// у моделі Vacancy
protected function casts(): array
{
    return ['status' => VacancyStatus::class];
}

Ви вже користуєтесь цим, коли пишете $vacancy->status->canBecome(VacancyStatus::Closed) і отримуєте enum замість рядка зі стовпця.

SYMFONY

Компонент Workflow

Workflow описує стани й переходи в конфігу, а в коді лишається apply(). state_machine дозволяє вакансії бути лише в одному стані, marking_store записує його у властивість status. Перехід, якого немає в конфігу, кидає виняток.

# config/packages/workflow.yaml
framework:
    workflows:
        vacancy:
            type: state_machine
            marking_store: { type: method, property: status }
            supports: [App\Entity\Vacancy]
            initial_marking: draft
            places: [draft, published, closed]
            transitions:
                publish: { from: draft, to: published }
                close: { from: published, to: closed }
 
// сервіс отримує WorkflowInterface $vacancyStateMachine
$this->vacancyStateMachine->apply($vacancy, 'publish');

Ви вже користуєтесь цим, коли перевіряєте can($vacancy, 'close') перед тим, як показати кнопку, або вішаєте слухача на подію переходу.

✓ ЯК ЦЕ ТЕСТУВАТИ

Переходи зручно перевірити одним тестом з набором даних: стан на вході, дія, стан або помилка на виході. Кожен клас стану це звичайний unit-тест без бази.

it('allows only valid transitions', function (array $actions, string $status) {
    $vacancy = new Vacancy('PHP Developer');
 
    foreach ($actions as $action) {
        $vacancy->{$action}();
    }
 
    expect($vacancy->status())->toBe($status);
})->with([
    [['publish'], 'опублікована'],
    [['publish', 'close'], 'закрита'],
]);
05 · КОЛИ НЕ ТРЕБА

Прапорець не потребує класів

Якщо статусів два, а поведінка в них майже однакова, вистачить bool або enum. Окремі класи окупаються, коли статусів кілька й кожен дозволяє різне.

// активна чи ні: тут вистачить прапорця
public function deactivate(): void
{
    $this->active = false;
}
06 · СУСІДИ

Схожі на вигляд, різні за змістом

07 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з архітектури →

Правила кожного статусу лежать в одному класі. Новий статус додає клас, а не гілку в усіх методах, і забути гілку вже неможливо: інтерфейс змусить реалізувати кожну дію.

Тут сам стан: Draft::publish() повертає Published. Так переходи видно поруч з правилами. Інший варіант, коли таблицю переходів тримає контекст або конфіг, як у Symfony Workflow.

У базі лежить рядок або enum у стовпці status. Під час завантаження з нього відновлюють обʼєкт стану, а під час збереження записують назву назад. Сам обʼєкт стану не серіалізують.