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

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

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

8 питань

Backed enum - перелік зі скалярним значенням, прив'язаним до кожного кейса:

enum Status: string
{
    case Draft = 'draft';
    case Published = 'published';
}

Інтеграція з Laravel:

// каст у моделі - атрибут стає об'єктом enum
protected $casts = ['status' => Status::class];

// валідація
$request->validate(['status' => [Rule::enum(Status::class)]]);

// Route Model Binding теж резолвить enum з URL

Enum робить «магічні рядки» типобезпечними, а методи на enum (label(), color()) зручно інкапсулюють логіку відображення.

Докладніше в документації: Валідація enum

Pure enum - набір іменованих варіантів без значень:

enum Suit
{
    case Hearts;
    case Spades;
}

Backed enum - кожен варіант має скалярне значення (int або string), яке можна зберегти в базу чи передати в JSON:

enum Status: string
{
    case Draft = 'draft';
    case Published = 'published';
}

Status::Published->value;          // 'published'
Status::from('draft');             // Status::Draft
Status::tryFrom('unknown');        // null

Як обрати: якщо значення має покидати PHP (база, API, кеш, черга) - backed. Pure - для внутрішніх станів, що ніде не зберігаються.

Усі варіанти - cases():

Status::cases();                                     // [Status::Draft, Status::Published]
array_column(Status::cases(), 'value');              // ['draft', 'published']
array_map(fn (Status $s) => $s->name, Status::cases()); // ['Draft', 'Published']

Це зручно для випадаючих списків, правил валідації (in:draft,published чи Rule::enum(Status::class) у Laravel), документації API.

Що ще варто знати:

  • name є в будь-якого варіанта, value - лише в backed.
  • Варіанти - синглтони: Status::Draft === Status::Draft, тож порівнюють через ===.
  • Enum не можна створити через new, успадкувати чи додати йому стан (властивості) - лише методи, константи й реалізацію інтерфейсів.
  • Значення backed enum мають бути унікальними й одного типу.

Докладніше в документації: Перелічення: основи

Backed enum кастується в моделі, і колонка починає повертати обʼєкт замість рядка:

enum VacancyLevel: string
{
    case Junior = 'junior';
    case Middle = 'middle';
    case Senior = 'senior';

    public function label(): string
    {
        return match ($this) {
            self::Junior => 'Junior',
            self::Middle => 'Middle',
            self::Senior => 'Senior',
        };
    }
}

class Vacancy extends Model
{
    protected function casts(): array
    {
        return ['level' => VacancyLevel::class];
    }
}

Тепер $vacancy->level - це enum, а не рядок:

$vacancy->level->label();
$vacancy->level === VacancyLevel::Senior;
$vacancy->update(['level' => VacancyLevel::Middle]);   // у базу піде 'middle'

Переваги над константами класу:

  • Обмежена множина. Значення поза переліком не існує, тоді як константа не заважає передати будь-який рядок.
  • Типізація. function assign(VacancyLevel $level) не прийме випадковий рядок, і редактор підкаже варіанти.
  • Поведінка поруч зі значенням. Enum має методи, тож підпис, колір чи іконка живуть там само, а не в розкиданих match по шаблонах.
  • match без default підсвітить пропущений випадок, коли додасте новий кейс.

Дві практичні деталі. У валідації є правило Rule::enum(VacancyLevel::class). А tryFrom() повертає null замість винятку - саме він потрібен, коли значення приходить від користувача чи з URL.

Докладніше в документації: Eloquent: мутатори та касти

Так. Enum у PHP - повноцінний тип: він може мати методи (звичайні й статичні), константи, реалізовувати інтерфейси й використовувати трейти (без властивостей).

interface HasLabel
{
    public function label(): string;
}

enum OrderStatus: string implements HasLabel
{
    case New = 'new';
    case Paid = 'paid';
    case Shipped = 'shipped';

    public const DEFAULT = self::New;

    public function label(): string
    {
        return match ($this) {
            self::New => 'Нове',
            self::Paid => 'Оплачене',
            self::Shipped => 'Відправлене',
        };
    }

    public function isFinal(): bool
    {
        return $this === self::Shipped;
    }

    public static function forSelect(): array
    {
        return array_combine(
            array_column(self::cases(), 'value'),
            array_map(fn (self $s) => $s->label(), self::cases()),
        );
    }
}

$order->status->label();       // 'Оплачене'
OrderStatus::DEFAULT;          // OrderStatus::New

Навіщо це: вся поведінка, пов'язана з переліком, живе в одному місці. Замість if ($status === 'paid' || $status === 'shipped'), розкиданих по коду, - $status->isFinal(). Додали новий варіант - match без default змусить оновити кожен метод.

Інтерфейси дозволяють використовувати різні enum однаково: Filament і багато бібліотек розпізнають інтерфейси на кшталт HasLabel, HasColor, HasIcon і самі показують мітку, колір і іконку.

Обмеження:

  • Немає властивостей (стану) - лише константи.
  • Не можна успадкувати один enum від іншого.
  • Трейти - лише без властивостей.
  • Магічні методи заборонені, крім __call, __callStatic і __invoke.

Докладніше в документації: Методи перелічень

Варіант enum - це об'єкт, а ключем масиву в PHP може бути лише int чи string. Спроба дає помилку:

$limits = [Plan::Free => 3, Plan::Pro => 100];
// TypeError: Cannot access offset of type Plan on array

Варіанти:

  • ->value backed enum як ключ:
$limits = [Plan::Free->value => 3, Plan::Pro->value => 100];
$limits[$user->plan->value];

Просто, але втрачається тип: ключ знову рядок, і опечатка в ключі не ловиться.

  • Метод на самому enum - найчастіше найкращий варіант:
enum Plan: string
{
    case Free = 'free';
    case Pro = 'pro';

    public function projectLimit(): int
    {
        return match ($this) {
            self::Free => 3,
            self::Pro => 100,
        };
    }
}

Знання про ліміт живе разом із варіантами, а новий варіант без гілки в match не пройде непомітно.

  • SplObjectStorage чи WeakMap - коли потрібна справжня мапа «об'єкт → значення», наприклад, для підрахунку: $counts[$status] ??= 0 з WeakMap працює з enum як ключами.

Ще про порівняння: < і > між варіантами не мають сенсу - результат завжди false, бо порядку між об'єктами немає. Якщо порядок потрібен, - метод order(): int чи порівняння ->value для числових backed enum. Рівність перевіряють через ===: варіанти - синглтони.

Докладніше в документації: Відмінності перелічень від об'єктів

Enum у коді змінюється легко, а от рядки, які вже лежать у базі, - ні. Саме тут зʼявляються помилки після деплою.

Найнебезпечніше - перейменувати кейс:

// було
case Middle = 'middle';

// стало
case Mid = 'mid';

Код збереться, а всі наявні рядки зі значенням middle перестануть кастуватися: ValueError: "middle" is not a valid backing value. Впаде не міграція, а звичайна сторінка.

Правильний порядок для перейменування:

  1. Додати новий кейс, лишивши старий.
  2. Міграцією перевести дані: Vacancy::where('level', 'middle')->update(['level' => 'mid']).
  3. Наступним релізом прибрати старий кейс.

Додати новий кейс - безпечно, якщо колонка varchar. Але коли в базі використано нативний тип enum, потрібна ще й міграція самої колонки, а Schema::table()->change() для нативних enum працює не в усіх драйверах - подекуди доводиться писати DB::statement().

Тому колонку під enum майже завжди роблять string: перелік живе в PHP, база зберігає рядок, і зміни не потребують ALTER на великій таблиці.

Захист від падіння на невідомому значенні:

// null замість винятку, коли в базі щось несподіване
$level = VacancyLevel::tryFrom($vacancy->getRawOriginal('level'));

І ще одне: якщо enum використовується у валідації через Rule::enum(), видалений кейс одразу зробить старі збережені записи невалідними при редагуванні - про це згадують уже після скарг користувачів.

Докладніше в документації: Міграції

Значення з запиту, бази, черги чи стороннього API - це рядок або число, яке може не відповідати жодному варіанту.

  • Status::from($value) - повертає варіант або кидає ValueError, якщо такого немає.
  • Status::tryFrom($value) - повертає варіант або null.
$status = Status::tryFrom($request->input('status'))
    ?? throw ValidationException::withMessages(['status' => 'Невідомий статус']);

Коли що:

  • from - коли невідоме значення означає баг чи пошкоджені дані, і правильна реакція - голосно впасти: значення з власної бази, з внутрішньої черги.
  • tryFrom - на межі з зовнішнім світом, де невідоме значення - нормальна ситуація, яку треба обробити: введення користувача, вебхуки, сторонні API.

На межі застосунку - валідація, а не винятки:

$request->validate([
    'status' => ['required', Rule::enum(Status::class)],
]);

$status = $request->enum('status', Status::class);

Rule::enum можна ще обмежити: ->only([Status::Draft, Status::Published]) чи ->except(...).

Підводні камені:

  • Тип значення: tryFrom('1') для int-backed enum під strict_types - TypeError, а не null. Значення з запиту спершу приводять до потрібного типу.
  • Видалений варіант при наявних даних у базі: після деплою from() при читанні моделі кидатиме ValueError на старих рядках. Спершу мігрують дані, потім прибирають варіант.
  • Зовнішні API додають нові значення без попередження. Код, що робить from() на їхній відповіді, падає в продакшені наступного ранку. tryFrom плюс логування невідомого значення - надійніше.

Докладніше в документації: BackedEnum::tryFrom

Статус замовлення, заявки чи статті рідко може змінитися на будь-який інший: оплачене не стає новим, відправлене не скасовується. Ці правила - машина станів, і enum - зручне місце, щоб описати її в одному місці.

enum OrderStatus: string
{
    case New = 'new';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

    /** @return list<self> */
    public function allowedTransitions(): array
    {
        return match ($this) {
            self::New => [self::Paid, self::Cancelled],
            self::Paid => [self::Shipped, self::Cancelled],
            self::Shipped, self::Cancelled => [],
        };
    }

    public function canTransitionTo(self $next): bool
    {
        return in_array($next, $this->allowedTransitions(), true);
    }
}

Застосування в моделі:

public function transitionTo(OrderStatus $next): void
{
    if (! $this->status->canTransitionTo($next)) {
        throw new InvalidStateTransition($this->status, $next);
    }

    $this->update(['status' => $next]);
    event(new OrderStatusChanged($this, $next));
}

Що це дає:

  • Правила переходів - в одному місці, а не розкидані if-ами по контролерах.
  • match без default змушує описати переходи для кожного нового статусу.
  • Легко тестувати: таблиця «з якого - в який - дозволено».
  • UI може показувати лише доступні дії: $order->status->allowedTransitions().

Коли enum уже замало: переходи залежать від даних (оплатити можна, лише якщо сума збігається), потрібні дії при вході й виході зі стану, історія переходів, паралельні стани. Тоді - окремі класи станів (патерн State) чи бібліотеки на кшталт spatie/laravel-model-states, а для довгих бізнес-процесів - workflow-рушії.

Конкурентність: перевірка й оновлення мають бути атомарними - UPDATE ... SET status = 'paid' WHERE id = ? AND status = 'new' чи блокування рядка, інакше два паралельні запити обидва «побачать» статус New.

Докладніше в документації: Методи перелічень