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

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

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

100 питань

Основні функції:

explode(',', 'php,laravel,vue');          // ['php', 'laravel', 'vue']
explode(',', 'a,b,c,d', 2);               // ['a', 'b,c,d'] - не більше 2 частин
implode(', ', ['php', 'laravel']);        // 'php, laravel'

str_split('abcdef', 2);                   // ['ab', 'cd', 'ef'] - байтами!
mb_str_split('привіт', 2);                // ['пр', 'ив', 'іт'] - символами

preg_split('/[\s,;]+/', 'php, laravel; vue', -1, PREG_SPLIT_NO_EMPTY);
// ['php', 'laravel', 'vue'] - кілька роздільників

Типові пастки:

  • Порожній рядок: explode(',', '') повертає [''] - масив з одним порожнім елементом, а не порожній масив. Для введення користувача часто потрібне array_filter(array_map('trim', explode(',', $input))).
  • Пробіли: explode(',', 'a, b') дає ['a', ' b'] - з пробілом. Звідси array_map('trim', ...).
  • Кирилиця: str_split ділить байти й ламає багатобайтові символи. Для тексту - mb_str_split.
  • CSV не розбирають через explode: значення можуть містити коми в лапках ("Київ, Україна"). Для цього str_getcsv() чи SplFileObject з READ_CSV.

У Laravel зручніше через Str і колекції:

Str::of('php, laravel , vue')->explode(',')->map(fn ($s) => trim($s))->filter();

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

  • $a ?? $b (null coalescing) - повертає $a, якщо воно існує і не null, інакше $b. Не кидає попереджень на неіснуючих змінних і ключах.
  • $a ?: $b (скорочений тернарний, «elvis») - повертає $a, якщо воно істинне, інакше $b. Для 0, '', '0', [], false теж поверне $b.
$page = $_GET['page'] ?? 1;      // ключа немає - 1, без попередження
$page = $_GET['page'] ?: 1;      // ключа немає - попередження "Undefined array key"

$discount = 0;
$discount ?? 10;    // 0  - нуль не null
$discount ?: 10;    // 10 - нуль хибний

$title = '';
$title ?? 'Без назви';   // ''
$title ?: 'Без назви';   // 'Без назви'

Як обирати:

  • ?? - «значення за замовчуванням, якщо нічого немає»: ключі масиву, необов'язкові параметри, конфігурація. Найчастіший випадок.
  • ?: - коли порожній рядок, нуль чи порожній масив справді треба замінити: показати «Без назви» замість порожнього заголовка.

Присвоєння з ??= (PHP 7.4):

$options['timeout'] ??= 30;   // присвоїти, лише якщо ключа немає чи там null

Ланцюжки: $a ?? $b ?? $c - перше значення, що не null. Разом із nullsafe-оператором: $user?->profile?->city ?? 'Невідомо'.

Докладніше в документації: Оператор null coalescing

match (PHP 8.0) - вираз, що повертає значення залежно від умови:

$label = match ($status) {
    'new', 'pending' => 'Очікує',
    'paid' => 'Оплачено',
    'cancelled' => 'Скасовано',
};

Відмінності від switch:

  • Строге порівняння ===. switch порівнює через ==, тож switch ('1') спрацює на case 1, а switch (0) у PHP 7 - навіть на case 'abc'.
  • Повертає значення - його можна присвоїти чи повернути одразу. switch - інструкція.
  • Немає провалювання (fall-through) і не потрібен break. Забутий break у switch - класичний баг.
  • Необроблене значення - виняток UnhandledMatchError. switch без default просто нічого не робить, і помилка залишається непомітною.
  • Кілька умов через кому в одній гілці.

match (true) - заміна ланцюжку if/elseif з довільними умовами:

$grade = match (true) {
    $score >= 90 => 'A',
    $score >= 75 => 'B',
    $score >= 60 => 'C',
    default => 'F',
};

Обмеження: у кожній гілці - один вираз. Якщо гілці потрібно кілька інструкцій, - викликати метод або лишити if/switch.

У поєднанні з enum match без default - спосіб гарантувати, що кожен варіант оброблено: новий case без гілки дасть UnhandledMatchError, а статичний аналізатор (PHPStan) попередить ще до запуску.

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

Fatal error: Allowed memory size of 134217728 bytes exhausted означає, що скрипт спробував використати більше пам'яті, ніж дозволяє налаштування memory_limit (тут 128 МБ). PHP зупиняє виконання, щоб один запит не з'їв пам'ять усього сервера.

Типові причини:

  • Усе завантажено одразу: User::all() на мільйоні рядків, file() для великого файлу, json_decode великої відповіді API.
  • Накопичення в циклі: масив, у який дописують результати обробки сотень тисяч записів.
  • Нескінченна рекурсія чи цикл, що безкінечно додає елементи.
  • Витік у довгому процесі: воркер черги, що накопичує дані між завданнями.

Як виправляти - не піднімати ліміт, а змінити підхід:

// Замість User::all()
User::query()->chunkById(1000, function ($users) {
    foreach ($users as $user) { /* ... */ }
});

// Або ліниво, по одному
foreach (User::query()->lazy() as $user) { /* ... */ }

// Великий файл - рядок за рядком
$file = new SplFileObject('huge.csv');
foreach ($file as $line) { /* ... */ }

Коли збільшити ліміт доречно: справді важка, але обмежена задача - генерація великого звіту в команді, обробка зображення. Тоді точково: ini_set('memory_limit', '512M') у конкретній команді, а не глобально для веб-запитів.

Поруч - max_execution_time: ліміт часу виконання (у CLI за замовчуванням вимкнений, для веб - зазвичай 30 с). Помилка Maximum execution time exceeded - сигнал перенести роботу в чергу.

Як знайти винуватця: memory_get_peak_usage(true) між етапами або профайлер (Xdebug, Blackfire, SPX).

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

Посилання - друге ім'я для тієї самої змінної. Зміна через одне ім'я видна через інше.

$a = 1;
$b = &$a;
$b = 2;
echo $a;   // 2

Де трапляються посилання:

  • Передача в функцію за посиланням - функція змінює змінну викликача:
function addTax(array &$order): void
{
    $order['total'] *= 1.2;
}
  • foreach за посиланням - зміна елементів масиву на місці:
foreach ($prices as &$price) {
    $price = round($price, 2);
}
unset($price);   // обов'язково!

Пастка з foreach: після циклу $price лишається посиланням на останній елемент. Наступний foreach ($prices as $price) (вже без &) запише в нього кожне значення по черзі - і останній елемент масиву стане копією передостаннього. Тому після циклу за посиланням - unset().

Чого посилання НЕ дають - економії пам'яті. Масиви й рядки в PHP копіюються ліниво (copy-on-write): передача у функцію за значенням не копіює дані, поки їх не змінять. «Передати за посиланням, щоб було швидше» нічого не прискорює, а інколи навпаки змушує PHP розділити масив.

Об'єкти й так передаються «за ідентифікатором»: функція, що отримала об'єкт, змінює той самий об'єкт. & для об'єктів потрібен лише щоб замінити саму змінну на інший об'єкт.

Порада: у сучасному коді посилання - рідкість. Зрозуміліше повернути нове значення ($order = addTax($order)) чи використати array_map, ніж змінювати аргументи непомітно для читача.

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

  • require - залежності, без яких застосунок не працює: фреймворк, бібліотеки для бізнес-логіки, SDK.
  • require-dev - потрібні лише розробникам і CI: тестовий фреймворк (Pest, PHPUnit), статичний аналіз (PHPStan, Larastan), форматування (Pint), Faker, налагоджувальні панелі.
composer require guzzlehttp/guzzle
composer require --dev pestphp/pest

На продакшені dev-залежності не ставлять:

composer install --no-dev --optimize-autoloader

Це:

  • зменшує розмір застосунку й образу;
  • прибирає з сервера інструменти, яких там не має бути (менше коду - менша поверхня атаки);
  • пришвидшує автозавантаження.

Типова помилка: клас з dev-пакета використовується в робочому коді. Локально все працює, бо dev-пакети встановлено, а на проді - Class not found. Приклад - фабрики й Faker у сидерах, які запускають на проді, або налагоджувальний пакет, підключений у сервіс-провайдері без перевірки оточення.

Як ловити: CI-крок, що встановлює залежності з --no-dev і запускає застосунок (або хоча б php artisan чи тести, що не потребують dev-пакетів), а також composer-require-checker для перевірки, що весь код покритий оголошеними залежностями.

Для бібліотек require-dev ще важливіший: споживачі бібліотеки отримують лише її require, тож тестові інструменти бібліотеки до них не потрапляють.

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

Скрипти в composer.json - іменовані команди, які запускають через composer run (або коротко composer <назва>), і хуки на події Composer.

"scripts": {
    "test": "pest --parallel",
    "lint": ["pint --test", "phpstan analyse"],
    "dev": [
        "Composer\\Config::disableProcessTimeout",
        "npx concurrently \"php artisan serve\" \"php artisan queue:listen\" \"npm run dev\""
    ],
    "post-autoload-dump": [
        "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
        "@php artisan package:discover --ansi"
    ]
}
composer test
composer lint
composer dev

Навіщо:

  • Єдині команди для команди й CI. Не треба пам'ятати прапорці кожного інструмента - composer test однаковий локально й у пайплайні.
  • Хуки на події: post-install-cmd, post-update-cmd, post-autoload-dump, post-create-project-cmd. Laravel через post-autoload-dump виявляє пакети (package:discover), а через post-create-project-cmd - генерує APP_KEY.

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

  • @php у скрипті використовує той самий PHP, яким запущено Composer; @composer - той самий Composer; @other-script - викликати інший скрипт.
  • Виконувані файли з vendor/bin доступні в скриптах без повного шляху.
  • За замовчуванням скрипт обмежений 300 секундами; для довгих (dev-сервер) - Composer\Config::disableProcessTimeout.
  • composer install --no-scripts вимикає хуки - корисно в Docker-збиранні, поки код застосунку ще не скопійовано.

Безпека: скрипти пакетів-залежностей Composer не виконує - лише скрипти кореневого composer.json. Плагіни ж пакетів потребують явного дозволу в allow-plugins.

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

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 мають бути унікальними й одного типу.

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

Усі чотири конструкції підключають і виконують інший PHP-файл. Різниця - у реакції на відсутній файл і в повторному підключенні:

  • require - файлу немає: фатальна помилка, виконання зупиняється.
  • include - файлу немає: попередження, виконання продовжується.
  • require_once / include_once - якщо файл уже підключали, повторно не підключають.
require __DIR__ . '/vendor/autoload.php';   // без нього застосунок не має сенсу
include __DIR__ . '/partials/banner.php';   // банер необов'язковий

Коли що:

  • require - для того, без чого код не може працювати: автозавантажувач, конфігурація.
  • *_once - для файлів з оголошеннями функцій і класів: повторне оголошення - фатальна помилка.
  • include - для справді необов'язкових фрагментів. На практиці рідко: краще явно перевірити file_exists.

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

  • Підключений файл бачить змінні з області видимості, де стоїть include, і може повернути значення: $config = require 'config.php'; - так влаштовані конфіг-файли Laravel.
  • Відносні шляхи розв'язуються відносно include_path і поточного робочого каталогу, а не файлу, що підключає. Тому завжди __DIR__ . '/...'.
  • Шлях від користувача в include - вразливість (Local/Remote File Inclusion): include $_GET['page'] . '.php'.

У сучасному коді ручні require майже зникли: класи завантажує автозавантажувач Composer (PSR-4), а require лишається в точці входу (public/index.php) і для файлів, що повертають масиви.

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

У PHP функція не бачить змінних ззовні - на відміну від JavaScript, де внутрішня функція бачить змінні зовнішньої.

$rate = 1.2;

function withTax(int $price): float
{
    return $price * $rate;   // Warning: Undefined variable $rate
}

Як передати значення у функцію:

  • Параметром - правильний спосіб: withTax(100, $rate).
  • global $rate; чи $GLOBALS['rate'] - доступ до глобальної змінної. Працює, але робить функцію залежною від прихованого глобального стану: її важко тестувати й переносити.
  • Замикання з use - захоплює значення змінної в момент створення:
$withTax = function (int $price) use ($rate): float {
    return $price * $rate;
};
  • Стрілкова функція захоплює зовнішні змінні автоматично (за значенням):
$withTax = fn (int $price): float => $price * $rate;

Статичні змінні зберігають значення між викликами функції:

function counter(): int
{
    static $count = 0;
    return ++$count;
}

Корисно для мемоізації, але це теж прихований стан, що живе весь процес.

Блоки не створюють області видимості: змінна, оголошена в if чи foreach, видна після нього до кінця функції - наприклад, $item після foreach ($items as $item) дорівнює останньому елементу.

Методи класу бачать лише свої параметри й $this, тож стан об'єкта - явна альтернатива глобальним змінним.

Докладніше в документації: Область видимості змінних

Класичний PHP працює за моделлю «нічого не спільного» (shared-nothing): кожен запит починає з чистого аркуша.

Життєвий цикл запиту:

  1. Веб-сервер (Nginx, Caddy) передає запит у PHP-FPM.
  2. Вільний воркер FPM запускає скрипт-точку входу (public/index.php).
  3. Підключається автозавантажувач, створюється застосунок, завантажується конфігурація, реєструються сервіс-провайдери - на кожному запиті заново.
  4. Обробляється запит, формується відповідь.
  5. Після відповіді уся пам'ять звільняється: змінні, об'єкти, з'єднання (крім постійних), статичні властивості.

Що з цього випливає:

  • Витоки пам'яті майже не шкодять: усе звільниться в кінці запиту.
  • Стан між запитами живе лише зовні: в базі, кеші (Redis), сесії, файлах. Статична змінна, змінена в одному запиті, у наступному знову має початкове значення.
  • Помилка в одному запиті не впливає на інші - кожен ізольований.
  • Ціна - повторна ініціалізація. Завантажити фреймворк з сотнями класів на кожен запит дорого. Частково це компенсують OPcache (готовий байт-код) і кешування конфігурації й маршрутів (php artisan optimize).

Інша модель - довгоживучі воркери (Laravel Octane на FrankenPHP, RoadRunner чи Swoole): застосунок завантажується один раз і обслуговує тисячі запитів. Це швидше, але стан зберігається між запитами: статичні властивості, синглтони, кеш у пам'яті сервісу. Код, написаний з розрахунку на shared-nothing, може «протікати» даними одного користувача в запит іншого.

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

Іменовані аргументи (PHP 8.0) дозволяють передавати аргументи за іменем параметра, а не лише за позицією.

// Позиційно: що означає true? а 0?
htmlspecialchars($text, ENT_QUOTES, 'UTF-8', false);

// Іменовано
htmlspecialchars($text, double_encode: false);

str_pad(string: 'x', length: 5, pad_type: STR_PAD_LEFT);

Переваги:

  • Читабельність для булевих прапорців і «магічних» чисел: new Invoice(paid: true, sendEmail: false) замість new Invoice(true, false).
  • Пропуск необов'язкових параметрів: передати лише останній, не перелічуючи значення за замовчуванням для всіх попередніх.
  • Будь-який порядок: іменовані аргументи можна передавати не в порядку оголошення.
  • Разом з constructor promotion дають зручні value objects і DTO: new Address(city: 'Київ', street: 'Хрещатик').

Правила:

  • Спершу позиційні, потім іменовані: f(1, b: 2) - так; f(a: 1, 2) - ні.
  • Один параметр не можна передати двічі.
  • Невідоме ім'я - помилка.
  • Для variadic-параметрів (...$args) іменовані аргументи збираються в масив з рядковими ключами.

Застереження для авторів бібліотек: з іменованими аргументами назва параметра стає частиною публічного API. Перейменування $str на $string у новій версії зламає код, що викликав функцію іменовано. Тому в бібліотеках назви параметрів змінюють так само обережно, як і назви методів.

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

First-class callable syntax (PHP 8.1) створює замикання (Closure) з функції чи методу, записавши (...) замість аргументів.

$length = strlen(...);
$length('привіт');                       // 12

$format = $this->formatPrice(...);       // метод об'єкта
$create = User::create(...);             // статичний метод

array_map(trim(...), $lines);
array_filter($users, $this->isActive(...));

Чим краще за старі способи передати функцію:

array_map('trim', $lines);                        // рядок
array_filter($users, [$this, 'isActive']);        // масив
array_filter($users, fn ($u) => $this->isActive($u));   // обгортка
  • Перевіряється одразу: якщо методу не існує чи він недоступний, помилка виникне в момент створення, а не десь пізніше при виклику.
  • Рефакторинг і IDE: «перейти до визначення» і перейменування методу працюють - на відміну від імені в рядку 'isActive'.
  • Видимість: $this->privateMethod(...) усередині класу створює замикання, яке можна передати назовні й викликати, - а [$this, 'privateMethod'] поза класом не спрацює.
  • Статичний аналіз бачить сигнатуру й перевіряє типи.

Обмеження: не працює з nullsafe ($obj?->method(...) - помилка) і з new Foo(...).

PHP 8.5 доповнив це оператором конвеєра |>, який передає значення в callable праворуч: $text |> trim(...) |> strtoupper(...).

Докладніше в документації: First-class callable syntax

PHP 8.0 (листопад 2020) - один з найбільших релізів мови. Головне:

Мова:

  • Іменовані аргументи - f(limit: 10).
  • Constructor property promotion - властивості в параметрах конструктора.
  • Union-типи - int|string, плюс тип mixed і тип повернення static.
  • match - вираз зі строгим порівнянням і без провалювання.
  • Nullsafe-оператор ?->.
  • Атрибути - #[Route('/users')] замість анотацій у PHPDoc.
  • throw як вираз - $user ?? throw new NotFound().
  • catch без змінної - catch (TimeoutException).

Суворіша поведінка:

  • Порівняння числа з рядком: 0 == 'abc' тепер false.
  • Помилки замість попереджень: багато внутрішніх функцій кидають TypeError чи ValueError при неправильних аргументах, а арифметика з нечисловим рядком - TypeError.
  • Узгоджені сигнатури вбудованих функцій і перевірка сумісності методів при успадкуванні.

Нові функції: str_contains(), str_starts_with(), str_ends_with(), get_debug_type(), fdiv().

Продуктивність: JIT-компілятор (для обчислювальних задач; типовому веб-застосунку дає небагато) і загальні оптимізації рушія.

Чому це важливо на співбесіді: більшість сучасного PHP-коду спирається саме на ці можливості, а суворіші правила PHP 8 - часта причина проблем при оновленні старих застосунків з PHP 7.

Докладніше в документації: Реліз PHP 8.0

Обидві створюють об'єкт Closure, але по-різному працюють із зовнішніми змінними.

Анонімна функція (function () use (...)) бачить лише ті зовнішні змінні, які явно перелічені в use, і захоплює їх за значенням у момент створення:

$rate = 1.2;
$withTax = function (int $price) use ($rate): float {
    return $price * $rate;
};

Щоб змінювати зовнішню змінну, - захоплення за посиланням: use (&$total).

Стрілкова функція (fn, PHP 7.4) автоматично захоплює всі зовнішні змінні, які використовує, за значенням:

$withTax = fn (int $price): float => $price * $rate;

Відмінності:

  • Лише один вираз. Тіло стрілкової функції - один вираз, результат якого повертається. Кілька інструкцій - тільки анонімна функція.
  • Не можна змінити зовнішню змінну. fn () => $count++ змінить копію, а не $count ззовні. Для накопичення - анонімна функція з use (&$count).
  • use не потрібен - менше шуму в коді.

Де що використовують:

  • fn - короткі колбеки: array_map(fn ($u) => $u->email, $users), ->filter(fn ($o) => $o->isPaid()), ->when($search, fn ($q) => $q->where(...)).
  • function - колбеки з кількома кроками, розгалуженнями чи зміною зовнішнього стану.

Обидві автоматично прив'язують $this усередині класу. Щоб уникнути цього (і зайвого утримання об'єкта в пам'яті), - static fn чи static function.

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

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

Рівні
Junior 32 Middle 35 Senior 33

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