Питання на співбесіді: 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()) зручно інкапсулюють логіку відображення.
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.
Так. 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
Варіанти:
->valuebacked 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. Впаде не міграція, а звичайна сторінка.
Правильний порядок для перейменування:
- Додати новий кейс, лишивши старий.
- Міграцією перевести дані:
Vacancy::where('level', 'middle')->update(['level' => 'mid']). - Наступним релізом прибрати старий кейс.
Додати новий кейс - безпечно, якщо колонка 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плюс логування невідомого значення - надійніше.
Статус замовлення, заявки чи статті рідко може змінитися на будь-який інший: оплачене не стає новим, відправлене не скасовується. Ці правила - машина станів, і 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.