Питання на співбесіді: Основи мови
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
12 питань
PHP дозволяє оголошувати типи аргументів, повернення та властивостей - і перевіряє їх у рантаймі.
class VacancyService
{
public function __construct(
private VacancyRepository $repository,
) {
}
public function publish(int $id, ?string $comment = null): Vacancy
{
// ...
}
}
Що дає: помилка ловиться в момент виклику, а не через три шари; редактор підказує методи; статичний аналіз бачить невідповідності до запуску.
declare(strict_types=1) змінює поведінку перевірки. Без нього PHP приводить типи мовчки:
function repeat(int $times): string { /* ... */ }
repeat('5'); // без strict_types: '5' стане 5, викликається нормально
repeat('5'); // зі strict_types: TypeError
Саме мовчазне приведення небезпечне: 'abc' перетвориться на 0, а '5 котів' - на 5, і помилка проявиться далеко від місця, де виникла.
Важлива деталь: директива діє на файл, де вона написана, і стосується викликів з нього, а не в нього. Тому її ставлять у кожен файл, першим рядком після <?php.
Корисні типи PHP 8:
?string- рядок абоnull.int|string- обʼєднання типів.never- функція не повертає керування (кидає виняток чи завершує процес).
У Laravel-проєктах declare(strict_types=1) зазвичай вимагається стилем коду й перевіряється Pint.
Усі чотири конструкції підключають і виконують інший 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) і для файлів, що повертають масиви.
У 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): кожен запит починає з чистого аркуша.
Життєвий цикл запиту:
- Веб-сервер (Nginx, Caddy) передає запит у PHP-FPM.
- Вільний воркер FPM запускає скрипт-точку входу (
public/index.php). - Підключається автозавантажувач, створюється застосунок, завантажується конфігурація, реєструються сервіс-провайдери - на кожному запиті заново.
- Обробляється запит, формується відповідь.
- Після відповіді уся пам'ять звільняється: змінні, об'єкти, з'єднання (крім постійних), статичні властивості.
Що з цього випливає:
- Витоки пам'яті майже не шкодять: усе звільниться в кінці запиту.
- Стан між запитами живе лише зовні: в базі, кеші (Redis), сесії, файлах. Статична змінна, змінена в одному запиті, у наступному знову має початкове значення.
- Помилка в одному запиті не впливає на інші - кожен ізольований.
- Ціна - повторна ініціалізація. Завантажити фреймворк з сотнями класів на кожен запит дорого. Частково це компенсують OPcache (готовий байт-код) і кешування конфігурації й маршрутів (
php artisan optimize).
Інша модель - довгоживучі воркери (Laravel Octane на FrankenPHP, RoadRunner чи Swoole): застосунок завантажується один раз і обслуговує тисячі запитів. Це швидше, але стан зберігається між запитами: статичні властивості, синглтони, кеш у пам'яті сервісу. Код, написаний з розрахунку на shared-nothing, може «протікати» даними одного користувача в запит іншого.
Два способи оголосити глобальну константу:
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.
Простір імен - префікс до назв класів, функцій і констант, щоб різні бібліотеки могли мати класи з однаковими іменами: App\Models\User і Laravel\Socialite\Two\User - різні класи.
namespace App\Services\Billing;
use App\Models\Order;
use Illuminate\Support\Facades\Log;
use Stripe\Invoice as StripeInvoice; // псевдонім, щоб не конфліктувати з нашим Invoice
final class Invoice
{
public function sync(Order $order, StripeInvoice $remote): void { /* ... */ }
}
use не підключає файл і нічого не завантажує - лише створює коротке ім'я для повного. Клас завантажить автозавантажувач у момент першого використання.
Як PHP розв'язує ім'я класу:
- Повне ім'я з
\на початку - як є:\DateTime. - Ім'я з
use- підставляється повне. - Інакше - до імені додається поточний простір імен:
new DateTime()уApp\ServicesшукатимеApp\Services\DateTime. Звідси класична помилка «Class App\Services\DateTime not found» - потрібенuse DateTime;чи\DateTime.
Функції й константи розв'язуються інакше: якщо в поточному просторі функції немає, PHP шукає глобальну (strlen працює без \). Явний \strlen() чи use function strlen; трохи пришвидшує виклик, бо рушій не перевіряє простір імен, і дозволяє OPcache оптимізувати вбудовані функції.
Простори імен і PSR-4: простір імен відповідає каталогу (App\Services\Billing → app/Services/Billing), а ім'я класу - файлу. На цьому збігу тримається автозавантаження.
::class дає повне ім'я класу рядком: Invoice::class → 'App\Services\Billing\Invoice' - так посилаються на класи в конфігурації без помилок у рядках.
Одинарні лапки - рядок «як є». Змінні не підставляються, з екранувань працюють лише \' і \\.
'Привіт, $name\n'; // буквально: Привіт, $name\n
Подвійні лапки - з інтерполяцією змінних і екрануваннями (\n, \t, \u{1F600}, \$):
"Привіт, $name\n";
"Сума: {$order->total} грн"; // складні вирази - у фігурних дужках
"Перший: {$items[0]['title']}";
Heredoc - багаторядковий рядок з інтерполяцією, як подвійні лапки:
$html = <<<HTML
<p>Привіт, {$user->name}</p>
<p>Замовлень: {$count}</p>
HTML;
Nowdoc - багаторядковий без інтерполяції, як одинарні лапки (мітка в лапках):
$template = <<<'SQL'
SELECT * FROM users WHERE email = :email
SQL;
З PHP 7.3 закривальну мітку можна відступати - відступ прибирається з усіх рядків, що зручно для коду всередині класів.
Що варто знати:
- Інтерполяція
${name}застаріла з PHP 8.2 - лише{$name}. - Швидкодія однакова: історична порада «одинарні лапки швидші» сьогодні не має значення - рядки компілюються один раз і кешуються в OPcache.
- Виклики методів і функцій у рядку не працюють без змінної:
"{$user->name()}"- так,"{strtoupper($name)}"- ні. - Nowdoc зручний для SQL, шаблонів і регулярних виразів, де
$має бути буквальним. - Інтерполяція - не для HTML і SQL з даними користувача: екранування (
htmlspecialchars) і прив'язка параметрів обов'язкові незалежно від способу складання рядка.
Суперглобальні змінні доступні в будь-якому місці коду без global:
$_GET,$_POST- параметри рядка запиту й тіла форми;$_COOKIE,$_FILES,$_REQUEST(суміш перших трьох);$_SERVER- заголовки, метод, шлях, IP, змінні середовища сервера;$_SESSION,$_ENV,$GLOBALS.
Чому не брати дані напряму:
- Усе - від користувача і все - рядки чи масиви.
$_GET['id']може бути'5','5 OR 1=1', масивом['x'](?id[]=x) чи взагалі відсутнім. Без валідації це шлях до SQL-ін'єкцій, XSS і помилок типів. - Глобальний стан. Код, що читає
$_POSTу глибині сервісу, неможливо протестувати без підробки глобальних змінних і неможливо перевикористати в консольній команді чи черзі. $_REQUESTзмішує джерела, і порядок пріоритету залежить від налаштувань (request_order) - незрозуміло, звідки прийшло значення.$_SERVERтеж від клієнта:HTTP_HOST,HTTP_X_FORWARDED_FOR, будь-якіHTTP_*- це заголовки, які можна підробити.
У фреймворку - об'єкт запиту і валідація:
public function update(Request $request, Post $post)
{
$data = $request->validate([
'title' => ['required', 'string', 'max:200'],
'published' => ['boolean'],
]);
$post->update($data);
}
Запит передається явно (його легко підробити в тесті), дані проходять перевірку, а $request->ip() враховує налаштування довірених проксі замість сирого заголовка.
У довгоживучих серверах (Octane) суперглобальні змінні й зовсім ненадійні: фреймворк сам формує об'єкт запиту для кожного запиту.
PHP використовує підрахунок посилань + збирач циклічних посилань. У звичайному веб-запиті пам'ять звільняється наприкінці запиту, тож витоки малопомітні. Але у довготривалих процесах (черги, Octane) пам'ять накопичується.
Як уникати:
- Не зберігати стан у статичних властивостях/синглтонах між завданнями.
unset()великих структур, скидати накопичувачі (логи запитівDB::flushQueryLog()).- Обробляти дані порціями (
chunk,lazy), не тримати все в пам'яті. - Перезапускати воркери за лімітом:
queue:work --max-jobs=1000 --max-time=3600або при досягненні--memory.
Octane має gc_collect_cycles()-хуки; Horizon автоматично перезапускає воркери, що «розпухли».
Налаштування PHP задаються на кількох рівнях, і не кожне можна змінити будь-де. Для кожної директиви в документації вказано режим:
INI_SYSTEM- лише вphp.iniчи конфігурації сервера:opcache.memory_consumption,opcache.preload,disable_functions.INI_PERDIR- ще й у.user.ini(для FPM/CGI) чи.htaccess(Apache з mod_php):upload_max_filesize,post_max_size.INI_USER- ще й у коді черезini_set().INI_ALL- усюди:memory_limit,max_execution_time,display_errors,date.timezone.
ini_set('memory_limit', '512M'); // працює
ini_set('upload_max_filesize', '50M'); // не подіє: тіло запиту вже розібране
Звідки PHP бере налаштування:
php --ini # які файли завантажено
php -i | grep memory # поточні значення (CLI!)
CLI і FPM часто мають різні php.ini (/etc/php/8.4/cli/php.ini і /etc/php/8.4/fpm/php.ini). Класична пастка: в консолі memory_limit=-1, а веб-запити падають на 128 МБ. Значення для FPM дивляться через phpinfo() у веб-запиті чи статусну сторінку.
Пули PHP-FPM можуть перевизначати налаштування: php_admin_value[memory_limit] = 256M (не можна змінити з коду) і php_value[...] (можна).
У Docker налаштування кладуть окремим файлом у /usr/local/etc/php/conf.d/ - він перевизначає дефолти без редагування основного php.ini.
Рекомендації для проду: display_errors=Off, log_errors=On, expose_php=Off, налаштований date.timezone, OPcache з validate_timestamps=0 і скиданням при деплої. Зміни в php.ini вимагають перезапуску FPM.
DateTime змінюється на місці: modify(), add(), setTime() змінюють сам об'єкт і повертають його ж.
DateTimeImmutable ніколи не змінюється: ті самі методи повертають новий об'єкт, а оригінал лишається без змін.
Класичний баг з DateTime:
$start = new DateTime('2026-10-01');
$end = $start->modify('+7 days');
echo $start->format('Y-m-d'); // 2026-10-08 - початок теж зсунувся!
$start і $end - той самий об'єкт. Особливо підступно, коли дату передали у функцію чи вона - властивість моделі: виклик modify() усередині методу непомітно змінює стан десь в іншому місці.
З DateTimeImmutable:
$start = new DateTimeImmutable('2026-10-01');
$end = $start->modify('+7 days'); // $start лишився 2026-10-01
Що ще варто знати про дати в PHP:
- Часовий пояс - явно:
new DateTimeImmutable('now', new DateTimeZone('Europe/Kyiv')). Без нього беретьсяdate.timezone, яке на різних серверах різне. - Зберігати в UTC, показувати в поясі користувача -
->setTimezone(...)при виведенні. modify('+1 month')31 січня дає 3 березня (переповнення дня). Для «наступного місяця» -modify('last day of next month')чиfirst day of next month.- Порівняння - звичайними операторами:
$a < $bпрацює для дат. - Для інтервалів -
diff()повертаєDateInterval, для ітерації -DatePeriod.
У Laravel Carbon (на основі DateTime) змінюваний, а CarbonImmutable - ні. Laravel дозволяє зробити immutable за замовчуванням: Date::use(CarbonImmutable::class) у сервіс-провайдері - тоді now() і дати моделей стають незмінними.
Не всі генератори випадкових чисел однакові. Для безпеки потрібен криптографічно стійкий генератор (CSPRNG), результат якого неможливо передбачити.
Для токенів, паролів, кодів підтвердження - лише CSPRNG:
random_int(100000, 999999); // 6-значний код підтвердження
bin2hex(random_bytes(32)); // 64-символьний токен
Str::random(40); // у Laravel - теж на random_bytes
Чого не використовувати для безпеки:
rand(),mt_rand()- Mersenne Twister: швидкий, але передбачуваний. Знаючи кілька результатів, можна відновити внутрішній стан і передбачити наступні.uniqid()- це час з мікросекундами, а не випадковість.md5(time()),sha1(microtime())- хеш передбачуваного значення лишається передбачуваним.shuffle(),array_rand(),str_shuffle()- використовують нестійкий генератор.
Розширення Random (PHP 8.2) - об'єктний API з явним вибором рушія:
use Random\Randomizer;
use Random\Engine\Secure;
use Random\Engine\Mt19937;
$secure = new Randomizer(new Secure()); // CSPRNG за замовчуванням
$secure->getBytesFromString('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', 8); // код без схожих символів
$secure->shuffleArray($items); // безпечне перемішування
$seeded = new Randomizer(new Mt19937(42)); // відтворюваний: та сама послідовність з тим самим seed
$seeded->getInt(1, 100);
Відтворювана (seeded) випадковість корисна поза безпекою: тести, генерація даних, детерміновані вибірки («та сама випадкова добірка для того самого користувача сьогодні»).
Порівняння токенів - через hash_equals(), а зберігати токени доступу в базі краще хешованими (як паролі, але достатньо hash('sha256', ...), бо токен і так довгий і випадковий).