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.
Трейт - це шматок коду (методи, властивості), який підставляється в клас так, ніби його написали там. Механізм повторного використання без успадкування: клас може взяти кілька трейтів.
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-підказками для інструментів.
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 з лінивими проксі).
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.
Питання рівня Middle з реальних технічних співбесід - 35 питань у 9 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
- Теми
- ООП 4 Масиви й рядки 4 Типи й помилки 4 Пам'ять і продуктивність 4 Composer і PSR 4 PHP 8+ 4 Функції й замикання 4 Основи мови 4
Готуєтесь до співбесіди не просто так: зараз на сайті 78 відкритих вакансій рівня Middle. Переглянути вакансії