Питання на співбесіді з PHP
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
Значення за замовчуванням роблять параметр необов'язковим:
function paginate(array $items, int $perPage = 15, int $page = 1): array
{
return array_slice($items, ($page - 1) * $perPage, $perPage);
}
paginate($items); // 15 на сторінку, перша сторінка
paginate($items, 50);
paginate($items, page: 3); // іменований аргумент пропускає $perPage
Правила:
- Значення за замовчуванням - константний вираз: літерал, константа,
new(з PHP 8.1), масив. Не виклик функції. - Необов'язкові параметри йдуть після обов'язкових. Обов'язковий параметр після необов'язкового - deprecated.
nullза замовчуванням вимагає nullable-типу:?string $name = null.
Variadic-параметр ...$name збирає всі решту аргументів у масив:
function sum(int ...$numbers): int
{
return array_sum($numbers);
}
sum(1, 2, 3); // 6
sum(...[1, 2, 3]); // розпаковка масиву в аргументи
- Може бути лише останнім параметром.
- Тип застосовується до кожного елемента:
int ...$numbers. - Іменовані аргументи, що не відповідають жодному параметру, потрапляють у variadic з рядковими ключами.
Пастки:
- Змінюване значення за замовчуванням через
new(Options $o = new Options()) створює новий об'єкт при кожному виклику - на відміну від Python, тут проблеми спільного стану немає. - Багато необов'язкових параметрів - запах. Якщо їх п'ять, краще об'єкт параметрів (DTO) чи кілька окремих методів.
- Булевий прапорець за замовчуванням (
bool $force = false) робить виклики незрозумілими - іменований аргумент рятує:delete(force: true).
За замовчуванням аргументи передаються за значенням: функція отримує копію, і зміна параметра не впливає на змінну викликача. Знак & у сигнатурі робить передачу за посиланням:
function addItem(array &$cart, string $item): void
{
$cart[] = $item;
}
$cart = [];
addItem($cart, 'книга');
// $cart === ['книга']
Де це в стандартній бібліотеці: sort(), array_push(), preg_match() (масив збігів), array_walk() - вони змінюють передану змінну.
Чому у власному коді це часто погана ідея:
- Прихований побічний ефект. З виклику
addItem($cart, 'книга')не видно, що$cartзміниться. Читач має знати сигнатуру. - Важче міркувати й тестувати: функція не просто повертає результат, а змінює стан ззовні.
- Не дає економії пам'яті. Масиви копіюються ліниво (copy-on-write), тож передача за значенням не копіює дані, доки їх не змінять.
- Не можна передати вираз:
addItem([], 'x')чиaddItem(getCart(), 'x')- помилка, потрібна саме змінна.
Краща альтернатива - повертати нове значення:
function withItem(array $cart, string $item): array
{
return [...$cart, $item];
}
$cart = withItem($cart, 'книга');
Або - об'єкт зі станом і методом: $cart->add('книга') - зміна очевидна з виклику.
Об'єкти й так не копіюються: функція, що отримала об'єкт, працює з тим самим об'єктом. & для об'єкта потрібен лише тоді, коли функція має замінити змінну іншим об'єктом.
Коли посилання виправдані: зміна великих вкладених структур на місці в гарячому коді та рідкісні API на кшталт «повернути кілька значень» - хоча тут читабельніше повернути масив чи об'єкт.
Докладніше в документації: Передача аргументів за посиланням
Два способи оголосити глобальну константу:
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.
Трейт - це шматок коду (методи, властивості), який підставляється в клас так, ніби його написали там. Механізм повторного використання без успадкування: клас може взяти кілька трейтів.
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.
Через 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']]).
- 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, щоб вони періодично перезапускалися.
Масив у 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-задачах, імпортах і алгоритмах, де елементів сотні тисяч і більше.
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"- вони підключаються на кожному запиті.
Обидва дозволяють оновлення, які за семантичним версіонуванням не мають ламати сумісність:
^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-підказками для інструментів.
Питання з реальних технічних співбесід - 100 питань у 9 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- ООП 12 Масиви й рядки 12 Типи й помилки 12 Пам'ять і продуктивність 12 Composer і PSR 12 Основи мови 12 Функції й замикання 10 PHP 8+ 10
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії