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

PHP: питання на співбесіді рівня Middle

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

35 питань

Два способи оголосити глобальну константу:

const MAX_UPLOAD_MB = 20;              // на етапі компіляції
define('MAX_UPLOAD_MB', 20);           // під час виконання, звичайна функція
const define()
коли визначається під час компіляції файлу під час виконання
усередині if, циклу, функції ні так
ім'я з виразу ні так: define("LIMIT_{$plan}", 10)
у класах і інтерфейсах так ні
простір імен враховує поточний namespace ім'я задається повністю рядком

На практиці в сучасному коді define() майже не потрібен: глобальні константи взагалі краще замінити конфігурацією чи константами класу.

Константи класу - значення, що належать класу й не змінюються:

final class Invoice
{
    public const int PAYMENT_TERM_DAYS = 14;
    protected const string NUMBER_PREFIX = 'INV-';

    public function dueDate(): CarbonImmutable
    {
        return $this->issued_at->addDays(self::PAYMENT_TERM_DAYS);
    }
}
  • видимість (public, protected, private) з PHP 7.1;
  • типізовані константи (const int) з PHP 8.3 - нащадок не може перевизначити константу значенням іншого типу;
  • final const (PHP 8.1) - заборонити перевизначення в нащадках;
  • static::CONST замість self::CONST - взяти значення з класу-нащадка (пізнє статичне зв'язування).

Магічні значення - числа й рядки без пояснення посеред коду:

if ($user->status === 3 && $order->total > 50000) { ... }   // що таке 3? чому 50000?

Замінювати їх варто так:

  • набір пов'язаних варіантів (статуси, типи, ролі) - enum, а не група констант: тип перевіряється, варіанти можна перебрати, у них можуть бути методи;
  • одиночне незмінне значення предметної області (термін оплати, максимальна довжина) - константа класу, де воно використовується;
  • значення, що відрізняється між середовищами чи може змінитися без деплою (ліміти, ключі, адреси) - конфігурація (config('billing.limit')), а не константа.

Константи в інтерфейсах допустимі, але вони потрапляють у кожен клас, що реалізує інтерфейс. Інтерфейс описує поведінку, тож набір значень для нього - ознака, що потрібен enum.

Динамічний доступ: constant(Invoice::class . '::PAYMENT_TERM_DAYS') чи з PHP 8.3 Invoice::{$name} - корисно в рідкісних випадках, але ламає пошук використань в IDE.

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

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: мутатори та касти

Трейт - це шматок коду (методи, властивості), який підставляється в клас так, ніби його написали там. Механізм повторного використання без успадкування: клас може взяти кілька трейтів.

trait HasSlug
{
    public function slug(): string
    {
        return Str::slug($this->title);
    }
}

class Post
{
    use HasSlug;
}

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

  • Неявні залежності. Трейт вище звертається до $this->title, але ніде цього не оголошує. Клас без title зламається лише під час виконання. Частково рятує абстрактний метод у трейті: abstract public function title(): string;.
  • Конфлікти імен. Два трейти з однаковим методом - фатальна помилка, доки не розв'язати її через insteadof і as.
  • Трейт - не тип. Не можна написати function f(HasSlug $x): перевірити, що об'єкт має поведінку трейту, можна лише через інтерфейс.
  • Прихована складність. Клас із п'ятьма трейтами важко читати: щоб зрозуміти, що в ньому є, треба відкрити шість файлів.

Добре працюють трейти для вузької допоміжної поведінки, як SoftDeletes чи HasFactory у Laravel. Погано - як спосіб розкидати велику модель по файлах: складність лишається, вона просто гірше видна.

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

self:: завжди вказує на клас, у якому написано код. static:: - на клас, через який метод викликали насправді. Друге називають пізнім статичним зв'язуванням.

class Model
{
    public static function create(): static
    {
        return new static();   // клас виклику
    }

    public static function make(): self
    {
        return new self();     // завжди Model
    }
}

class User extends Model {}

User::create(); // User
User::make();   // Model

Де це важливо:

  • Фабричні методи в базовому класі. Eloquent User::create() повертає саме User, бо всередині new static.
  • Перевизначені константи й методи. static::TABLE візьме константу нащадка, self::TABLE - базового класу.
  • Тип повернення static (PHP 8.0) каже аналізатору й IDE, що метод повертає клас виклику - зручно для fluent-інтерфейсів.

Коли self: коли поведінка не має змінюватися в нащадках, наприклад виклик приватного методу. static:: до приватного методу нащадка не дістанеться.

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

Обидва об'єднують масиви, але по-різному поводяться з однаковими ключами:

  • $a + $b - бере все з $a, а з $b додає лише ключі, яких у $a немає. Перемагає лівий.
  • array_merge($a, $b) - для рядкових ключів перемагає правий, а числові ключі перенумеровує з нуля й дописує в кінець.
$defaults = ['color' => 'red', 'size' => 'M'];
$options  = ['color' => 'blue'];

$options + $defaults;            // ['color' => 'blue', 'size' => 'M']
array_merge($defaults, $options); // ['color' => 'blue', 'size' => 'M']

[0 => 'a', 1 => 'b'] + [0 => 'c', 1 => 'd', 2 => 'e']; // ['a', 'b', 'e']
array_merge(['a', 'b'], ['c', 'd', 'e']);              // ['a', 'b', 'c', 'd', 'e']

Коли що:

  • + - накласти значення за замовчуванням: «мої опції, а чого бракує - з дефолтів».
  • array_merge або spread [...$a, ...$b] - склеїти списки.

Пастка: числові ключі, які насправді ідентифікатори ([15 => 'Київ']), array_merge перенумерує й загубить. Для таких масивів потрібен + або array_replace.

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

Через usort і оператор <=> («космічний корабель»), який повертає -1, 0 або 1. Масиви теж можна порівнювати через <=> - поелементно, зліва направо, і це дає сортування за кількома полями одним рядком:

usort($users, fn (array $a, array $b): int =>
    [$b['score'], $a['name']] <=> [$a['score'], $b['name']]
);

Тут спершу за score за спаданням (тому $b і $a поміняні місцями), а за однакового рахунку - за name за зростанням.

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

  • usort скидає ключі. Щоб зберегти їх, потрібен uasort.
  • З PHP 8.0 сортування стабільне: рівні за критерієм елементи лишаються в початковому порядку. На цьому можна будувати сортування в кілька проходів.
  • Рядки <=> порівнює побайтово, тож «Ярослав» і «Андрій» стануть не за абеткою. Для тексту людською мовою - Collator з розширення intl.

У Laravel колекції роблять те саме читабельніше: collect($users)->sortBy([['score', 'desc'], ['name', 'asc']]).

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

  • Nullable ?T - значення типу T або null: ?string.
  • Union A|B (PHP 8.0) - будь-який з перелічених типів: int|string. ?string - це скорочення для string|null.
  • mixed (PHP 8.0) - будь-який тип, включно з null. Явно каже: «тут справді що завгодно».
  • Intersection A&B (PHP 8.1) - об'єкт, що реалізує всі перелічені інтерфейси.
function findUser(int|string $id): ?User
{
    return is_int($id) ? User::find($id) : User::firstWhere('slug', $id);
}

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

  • Union-тип змушує код усередині розрізняти варіанти (is_int, instanceof). Якщо гілок стає багато, це сигнал, що функція робить дві різні речі.
  • mixed не означає «тип не вказали»: він дозволяє все і забирає в аналізатора можливість допомогти. Використовуйте його лише там, де значення справді довільне - наприклад, у кеші.
  • Під declare(strict_types=1) скалярні типи не приводяться: findUser('5') передасть рядок, а не число.
  • PHP 8.2 дозволив і DNF-типи: (A&B)|null.

Докладніше в документації: Оголошення типів

finally виконується завжди: після успішного try, після обробленого винятку, після необробленого і навіть після return у try чи catch. Це місце для прибирання - закрити файл, зняти блокування, відкотити стан.

$lock = Cache::lock('import', 60);
$lock->block(5);

try {
    $this->import();
} finally {
    $lock->release(); // і після помилки теж
}

Пастка з return: якщо finally сам робить return, він перекриває все попереднє - і значення з try, і виняток, що летів:

function f(): string
{
    try {
        throw new RuntimeException('збій');
    } finally {
        return 'ok'; // виняток загублено без сліду
    }
}

Тому в finally не повертають значень і не кидають нових винятків - лише прибирають.

Альтернатива в багатьох випадках - готові обгортки, які роблять try/finally за вас: $lock->get(fn () => ...), DB::transaction(fn () => ...).

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

Перший крок - виміряти. memory_get_usage() показує поточне споживання, memory_get_peak_usage() - максимум за весь час. Розставивши їх між етапами, швидко видно, де стрибок:

$before = memory_get_usage();
$rows = $repository->all();
logger()->info('rows loaded', ['mb' => (memory_get_usage() - $before) / 1024 / 1024]);

Далі - профайлер, який покаже пам'ять у розрізі функцій: Xdebug-профайлер, Blackfire, SPX, Tideways. Вони відповідають на питання «яка функція виділила ці 300 МБ».

Звичайні винуватці:

  • Завантаження всього одразу: Model::all(), file() для великого файлу, json_decode величезної відповіді. Лікується порціями: chunkById(), lazy(), генератори, потоковий парсер.
  • Накопичення в довгому процесі: статичний масив-кеш, логер, що тримає записи, DB::enableQueryLog() у воркері.
  • Циклічні посилання між об'єктами, які звільняє лише збирач сміття, а не лічильник посилань.

У довгоживучих процесах (воркери черг, Octane) навіть невеликий витік на кожному завданні з часом вбиває процес. Тому воркерам ставлять --max-jobs чи --memory, щоб вони періодично перезапускалися.

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

Масив у PHP - це впорядкована хеш-таблиця: універсальна, але не найекономніша. SPL пропонує спеціалізовані структури для конкретних задач:

  • SplFixedArray - масив фіксованої довжини лише з цілими індексами. Займає помітно менше пам'яті за звичайний масив на мільйонах елементів, бо не зберігає хеш-таблицю.
  • SplObjectStorage - відображення «об'єкт → дані». Ключем може бути сам об'єкт, без вигадування рядкових ідентифікаторів. З PHP 8 для цього є й WeakMap, який не тримає об'єкти в пам'яті.
  • SplQueue / SplStack - черга й стек із чіткою семантикою. array_shift() на великому масиві повільний, бо перенумеровує ключі; SplQueue::dequeue() - ні.
  • SplPriorityQueue, SplMinHeap - коли потрібно щоразу брати найменший чи найважливіший елемент, без повного сортування.
$queue = new SplPriorityQueue();
$queue->insert('звичайний лист', 1);
$queue->insert('скидання пароля', 10);
$queue->extract(); // 'скидання пароля'

На практиці: у веб-запиті зі сотнею елементів різниці не буде, і звичайний масив читабельніший. SPL має сенс у CLI-задачах, імпортах і алгоритмах, де елементів сотні тисяч і більше.

Докладніше в документації: Стандартна бібліотека PHP (SPL)

PSR-4 зіставляє префікс простору імен з каталогом. Решта імені класу перетворюється на шлях до файлу:

"autoload": {
    "psr-4": {
        "App\\": "app/"
    }
}

App\Services\Billing\Invoice → app/Services/Billing/Invoice.php.

Як це працює: vendor/autoload.php реєструє функцію через spl_autoload_register(). Коли код уперше звертається до невідомого класу, PHP викликає її, вона обчислює шлях і робить require. Класи, які не знадобилися в запиті, не завантажуються взагалі.

Що треба знати:

  • Регістр важливий: на Linux invoice.php для класу Invoice не знайдеться, хоча на macOS усе працюватиме. Класична причина «на проді клас не знайдено».
  • Один клас - один файл, ім'я файлу збігається з ім'ям класу.
  • Після зміни секції autoload у composer.json потрібен composer dump-autoload. Нові класи в уже налаштованому каталозі підхоплюються без нього.
  • Для файлів з функціями (не класами) є секція "files" - вони підключаються на кожному запиті.

Докладніше в документації: PSR-4 у composer.json

Обидва дозволяють оновлення, які за семантичним версіонуванням не мають ламати сумісність:

  • ^1.2.3 - від 1.2.3 до (не включно) 2.0.0. Дозволені всі мінорні й патч-оновлення.
  • ~1.2.3 - від 1.2.3 до (не включно) 1.3.0. Дозволені лише патчі.
  • ~1.2 - від 1.2 до 2.0. Остання вказана цифра може рости.

Нюанс з нулем: для версій 0.x мінорна версія вважається ламкою. ^0.3.1 - це від 0.3.1 до 0.4.0, а не до 1.0.

Що обирати:

  • ^ - стандарт для більшості залежностей. Саме його пише composer require.
  • Точна версія (1.2.3) - лише коли пакет відомий тим, що ламає сумісність у мінорних версіях, і оновлювати його хочеться тільки вручну.
  • * чи dev-master у застосунку - ні: сьогоднішній composer update може принести будь-що.

Важливо: обмеження в composer.json визначають, що дозволено. Що встановлено - записано в composer.lock, і на сервер їде саме воно.

Докладніше в документації: Версії й обмеження

Магічні методи - методи з подвійним підкресленням, які PHP викликає сам у певних ситуаціях:

  • __construct, __destruct - створення й знищення об'єкта;
  • __get, __set, __isset, __unset - звернення до недоступної (неіснуючої чи приватної) властивості;
  • __call, __callStatic - виклик недоступного методу;
  • __toString - перетворення на рядок;
  • __invoke - виклик об'єкта як функції: $validator($value);
  • __clone - після clone;
  • __serialize, __unserialize - серіалізація;
  • __debugInfo - що показувати у var_dump().

__get і __call - основа «магії» Eloquent: $user->email читає атрибут з масиву, $user->posts() - зв'язок, User::where(...) через __callStatic перенаправляється в query builder.

Чим вони небезпечні:

  • Опечатки не ловляться. $user->emial не дасть помилки компіляції - лише null чи виняток під час виконання.
  • IDE й статичний аналіз сліпі. Автодоповнення, «перейти до визначення», перевірка типів - не працюють без додаткових підказок (@property, @method у PHPDoc, ide-helper, Larastan).
  • Складно налагоджувати: незрозуміло, звідки береться значення.
  • Продуктивність: магічний виклик повільніший за прямий (рідко має значення, але в гарячих циклах помітно).

Правило: у власному коді віддавати перевагу явним методам і властивостям. Магію - лише там, де вона дає великий виграш у зручності API (як ORM), і з PHPDoc-підказками для інструментів.

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

  • final class - від класу не можна успадкуватися.
  • final public function - метод не можна перевизначити в нащадку.
  • final const (PHP 8.1) - константу не можна перевизначити.
final class InvoiceNumberGenerator
{
    public function next(): string { /* ... */ }
}

class Report extends InvoiceNumberGenerator {}  // Fatal error

Чому final за замовчуванням:

  • Успадкування - найсильніший зв'язок. Нащадок залежить від усіх деталей предка. Будь-яка зміна в класі може зламати чужого нащадка, про якого автор навіть не знає.
  • Свобода змінювати клас. Якщо клас final, його внутрішню будову (protected-методи, порядок викликів) можна змінювати вільно: зовні доступний лише публічний API.
  • Свідомі рішення. Прибрати final, коли з'явилася реальна потреба в успадкуванні, - одна правка. Повернути final, коли на клас уже успадковуються, - ламаюча зміна.
  • Підштовхує до композиції й інтерфейсів замість ієрархій.

Типове заперечення - «а як мокати в тестах?». Мокати слід інтерфейси, а не конкретні класи. Якщо клас треба підмінити, - він реалізує інтерфейс, і тест підставляє іншу реалізацію. Для зовнішніх final-класів бібліотек є обхідні шляхи (пакет dg/bypass-finals), але це сигнал щодо дизайну.

Де final не ставлять: базові класи, призначені для успадкування (Model, Controller, абстрактні класи), і класи, які фреймворк проксує чи розширює під час виконання (наприклад, моделі Doctrine з лінивими проксі).

Докладніше в документації: Ключове слово final

PHP використовує бібліотеку PCRE: функції preg_* приймають шаблон з роздільниками (/.../, ~...~, #...#) і модифікаторами після них.

preg_match('/^\d{4}-\d{2}-\d{2}$/', $date);                  // 1, 0 або false при помилці
preg_match('/(?<year>\d{4})-(?<month>\d{2})/', $s, $m);      // $m['year'], $m['month']
preg_match_all('/#(\w+)/u', $text, $matches);               // усі хештеги
preg_replace('/\s+/', ' ', $text);                          // стиснути пробіли
preg_replace_callback('/\d+/', fn ($m) => $m[0] * 2, $s);
preg_split('/[,;]\s*/', $list);

Модифікатор u вмикає режим UTF-8:

  • шаблон і рядок обробляються як UTF-8, а не як байти;
  • . відповідає одному символу, а не байту;
  • працюють Unicode-класи: \p{L} (будь-яка літера), \p{Lu} (велика), \p{Cyrillic}.

Без u шаблон /^.{3}$/ для «кіт» не спрацює (там 6 байтів), а \w не вважає кирилицю літерами.

preg_match('/^[\p{L}\s\'-]+$/u', "Мар'яна Іваненко");   // 1

Інші корисні модифікатори: i - без урахування регістру, m - ^/$ для кожного рядка, s - . охоплює й переведення рядка, x - пробіли й коментарі в шаблоні для читабельності.

Пастки:

  • Помилка ≠ збіг: preg_match повертає false при помилці (зламаний UTF-8 у рядку з u, перевищення ліміту backtracking). Причина - у preg_last_error_msg().
  • Катастрофічний backtracking: шаблони на кшталт (a+)+$ на зловмисному введенні виконуються експоненційно довго (ReDoS). Уникайте вкладених квантифікаторів.
  • Екранування введення користувача в шаблоні - через preg_quote($input, '/').
  • Для простих випадків швидші й зрозуміліші str_contains, str_starts_with, str_replace.

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

Питання рівня Middle з реальних технічних співбесід - 35 питань у 9 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.

Інші рівні
Junior 32 Senior 33

Готуєтесь до співбесіди не просто так: зараз на сайті 78 відкритих вакансій рівня Middle. Переглянути вакансії