`DateTime` змінює сам себе на кожному `modify()`/`add()`, тому одна змінна тихо міняється в усіх місцях, де на неї посилаються; `DateTimeImmutable` має той самий API, але повертає новий екземпляр. Час зберігають як UTC-мітку, а пояс користувача застосовують лише на виводі через `setTimezone()` і завжди за ідентифікатором зони, а не за зсувом.
Питання на співбесіду Junior PHP
Що реально питають на першій роботі: синтаксис і типи, основи ООП, базовий SQL, робота з запитами й простий Laravel або WordPress.
Eloquent і Query Builder генерують той самий SQL, різниця починається після того, як рядки прийшли: `get()` на моделі створює обʼєкт на рядок, копіює атрибути в `$original` і тримає масив `$relations`, а базовий білдер віддає `stdClass`. Для звітів і вивантажень беруть `toBase()` або `DB::table()`, агрегують у SQL, а великі набори проганяють через `chunkById()`/`lazyById()`, не через `chunk()`.
Працюють у короткій feature-гілці від main, підтягують чужі зміни через rebase локально, у main вливають через pull request і merge, а втрачені після rebase чи force push коміти дістають із git reflog.
Бізнес-логіка живе в шарі, який нічого не знає про HTTP: у моделі домену або в use case (сервісі), який модель оркеструє. Контролеру лишаються переклад запиту в дані, виклик одного use case і вибір відповіді, бо контролер прив'язаний до транспорту і тому непридатний для повторного використання й дорогий у тестуванні.
Тема відповідає за вивід, плагін - за дані й поведінку: усе, що має жити довше за оформлення (CPT, шорткоди, інтеграції, cron), винось у плагін, бо functions.php вимикається разом зі зміною теми.
composer.json описує допустимі діапазони версій, composer.lock фіксує точні версії, які реально встановлені; install ставить строго з lock і дає однакові залежності всюди, update перераховує діапазони й переписує lock.
Оновлення теми видаляє її теку цілком і розпаковує нову, тому будь-яка правка всередині батьківської теми зникає; дочірня тема живе в окремій теці, яку оновлювач не чіпає, і має містити мінімум власного коду, бо кожен скопійований шаблон стає форком, що застаріває.
Зовнішній ключ - це перевірка, яку робить сама база на кожному записі, тому посилання на неіснуючий рядок неможливе навіть із консолі чи з чужого скрипта. CASCADE беруть для рядків, які не живуть без батька, RESTRICT - коли видалення має бути помилкою, SET NULL - для необовʼязкового звʼязку в nullable-колонці.
Фасад це статичний фасад над контейнером: `__callStatic` бере обʼєкт із контейнера за рядковим акцесором і викликає на ньому звичайний метод, тому `swap()`, `shouldReceive()` і всі `fake()` працюють. У Laravel-склейці (контролери, middleware, роути, Blade) фасад економить друкування, а в доменному коді залежності мають бути в конструкторі, бо саме сигнатура класу є списком того, що йому потрібно.
WHERE фільтрує рядки до групування й не бачить агрегатів, GROUP BY згортає рядки в групи, HAVING фільтрує вже готові групи за значенням агрегату.
Усі вони test doubles, обʼєкти, що в тесті замінюють справжню залежність. Dummy лише заповнює параметр, stub повертає заготовлені відповіді, spy ще й записує виклики для перевірки після дії, mock отримує очікування до дії й сам падає, якщо їх не виконано, fake це робоча спрощена реалізація, як репозиторій на масиві. Stub і fake ведуть до перевірки стану, spy і mock до перевірки поведінки.
Оператор == порівнює значення з приведенням типів, === порівнює і значення, і тип без жодних перетворень.
INNER JOIN лишає лише ті рядки, для яких знайшлася пара в обох таблицях; LEFT JOIN лишає всі рядки лівої таблиці, підставляючи NULL там, де пари немає.
Action виконує побічну дію в певній точці життєвого циклу й нічого не повертає; filter отримує значення, змінює його й обовʼязково повертає.
Інтерфейс — це публічний контракт без стану, і їх можна реалізувати скільки завгодно; абстрактний клас — частково готова реалізація зі станом, конструктором і protected-методами, але успадкувати можна лише один.
Рядок у PHP - це послідовність байтів без інформації про кодування, тому `strlen` рахує байти, а кирилиця в UTF-8 займає по два байти на літеру. Символи рахують і ріжуть `mb_`-функції (`mb_strlen`, `mb_substr`, `mb_strtolower`), а регулярні вирази переводить у режим символів модифікатор `u`.
Float у PHP - це двійковий double за IEEE 754, у якому 0.1, 0.2 чи 19.99 не мають точного представлення, тому суми накопичують похибку. Гроші тримають у цілих копійках (int) або в рядках з DECIMAL/numeric у базі, рахують через bcmath чи `BcMath\Number` з PHP 8.4, а валюту й правило округлення вважають частиною значення.
CSRF змушує браузер залогіненої людини відправити запит на ваш сайт зі сторінки зловмисника: cookie сесії браузер додає сам, і сервер бачить легітимного користувача. Laravel перевіряє кожен POST, PUT, PATCH і DELETE у групі `web`: запит проходить, якщо `Sec-Fetch-Site` каже `same-origin` або якщо токен з `_token`, `X-CSRF-TOKEN` чи `X-XSRF-TOKEN` збігається з токеном сесії, інакше 419. Токен не потрібен там, де автентифікація не їде в cookie, наприклад з Bearer-токеном у заголовку.
XSS трапляється, коли дані користувача потрапляють у HTML-сторінку як розмітка або код і браузер їх виконує від імені того, хто сторінку відкрив. Лікується екрануванням на виводі під конкретний контекст: `{{ }}` у Blade викликає `htmlspecialchars()` з `ENT_QUOTES`, `{!! !!}` не екранує нічого, в атрибут `href` треба пускати лише `http(s)`-посилання, а в `<script>` дані передають через `Js::from()` або `@json`. CSP додає другий шар, якщо перший десь протік.
Enum (PHP 8.1+) — тип із фіксованим переліком case-ів; pure enum не має скалярного значення, backed enum привʼязаний до int або string і вміє from()/tryFrom().
Mass assignment трапляється, коли модель заповнюється масивом із запиту цілком: `User::create($request->all())` запише і `is_admin`, і `company_id`, якщо користувач їх дописав. Захист у два шари: модель дозволяє масове заповнення лише полів із `$fillable` (або `#[Fillable]` у Laravel 13), а контролер передає в неї `$request->validated()`, тобто лише поля, для яких є правила. Значення, які виставляє сервер (роль, власник, статус), присвоюють явно.
Prepared statement - це запит із плейсхолдерами (`?` або `:name`), у якому дані передаються окремо від тексту SQL, тому вони ніколи не парсяться як команда. Захист працює саме через це розділення, а не через екранування лапок; у PDO MySQL за замовчуванням увімкнена емуляція, тому перевіряйте `PDO::ATTR_EMULATE_PREPARES` і завжди задавайте `charset` у DSN.
readonly дозволяє записати типізовану властивість рівно один раз зі скоупу класу; з PHP 8.2 модифікатор можна поставити на весь клас.
Scope - це іменований шматок запиту: локальний ви викликаєте самі (`Post::published()`), глобальний Eloquent додає в кожен запит моделі, як `SoftDeletes`. Accessor через `Attribute::get()` і cast працюють уже після вибірки, у PHP: каст перетворює значення колонки в обидва боки, аксесор рахує похідний атрибут, якого в таблиці немає, тому фільтрувати й сортувати за ним не можна, а `$appends` додає його в `toArray()` і `toJson()`.
Контейнер відповідає за створення обʼєктів і підстановку залежностей: читає типи параметрів конструктора через рефлексію і рекурсивно створює їх.
Впровадження залежностей означає, що клас отримує готові співпрацівники ззовні (найчастіше через конструктор), а не створює їх сам. Контейнер це фабрика з реєстром, яка вміє зібрати граф обʼєктів за типами параметрів конструктора і підставити реалізацію там, де оголошено інтерфейс.
Транзакція - це група команд, які застосовуються цілком або не застосовуються зовсім; ACID гарантує атомарність, дотримання оголошених обмежень, ізоляцію на заданому рівні й збереження закомічених даних після падіння, але за умовчанням ізоляція не серіалізовна (PostgreSQL - READ COMMITTED, MySQL - REPEATABLE READ), а ROLLBACK не скасовує нічого поза базою.
Індекс - це окрема відсортована структура (зазвичай B-tree), яка дає базі знайти потрібні рядки без повного скану. Він виграє на селективних умовах і на ORDER BY, а програє на колонках з кількома значеннями, бо кожен індекс сповільнює запис і займає місце.
NULL — це «невідомо», а не значення: будь-яке порівняння з ним дає UNKNOWN і рядок не проходить WHERE, агрегати мовчки пропускають NULL замість того щоб рахувати їх нулями, а UNIQUE вважає різні NULL різними, тому дублікати проходять.
HttpKernel перетворює Request на Response, дорогою розсилаючи події: kernel.request (маршрутизація), kernel.controller і kernel.controller_arguments (резолвінг контролера й аргументів), kernel.view (якщо контролер повернув не Response), kernel.response, kernel.terminate після відправлення відповіді.
З PHP 7 усе, що можна кинути, реалізує інтерфейс `Throwable`, під яким дві незалежні гілки: `Exception` (очікувані ситуації рантайму) і `Error` (помилки програміста: `TypeError`, `ValueError`, `DivisionByZeroError`). `finally` виконується завжди, окрім `exit()` і фатального завершення, а warnings лишилися для ситуацій, які PHP історично вміє пережити, і перетворюються на винятки через `set_error_handler`.
Yii2-застосунок - це один обʼєкт `Yii::$app`, створений вхідним скриптом `web/index.php` з масиву конфігурації: він піднімає аліаси й error handler, виконує `bootstrap`, просить `UrlManager` розібрати URL на маршрут, за маршрутом знаходить модуль, контролер і дію, а результат віддає через компонент `response`.
Doctrine це Data Mapper: сутність - звичайний PHP-клас без save() і find(), а стан у базу переносить EntityManager (persist реєструє новий об'єкт, flush виконує SQL), тоді як репозиторій відповідає лише за запити. В Active Record модель сама знає про з'єднання, тому домен і таблиця злиті в один клас.
Compose описує сервіси локального стенду: PHP (php-fpm поруч з nginx або один контейнер FrankenPHP), база, кеш; код заходить через bind mount, а vendor і дані БД живуть у named volumes, бо саме bind mount повільний на macOS та Windows; Xdebug ставлять в образ, але тримають у режимі off і вмикають змінною XDEBUG_MODE на час сесії.
Команда - це сервіс з атрибутом #[AsCommand]: у Symfony 7.3+ достатньо класу з методом __invoke() і параметрами під #[Argument] та #[Option], вивід іде через SymfonyStyle, тест пишеться на CommandTester, а від паралельних запусків з cron захищає Lock.
Обидва шляхи запускають один і той самий Validator: `$request->validate()` робить це всередині методу контролера, Form Request — під час резолву аргументів, ще до входу в метод, і додає authorize(), prepareForValidation() і власні повідомлення. Інлайн — для двох-трьох простих правил, Form Request — щойно зʼявляються авторизація, нормалізація вхідних даних або повторне використання правил.
Хешувати треба `password_hash()` і перевіряти `password_verify()`: функція сама генерує криптостійку сіль і кладе її разом з алгоритмом і вартістю в один рядок. Окрема колонка для солі не потрібна, а параметри піднімають через `password_needs_rehash()` під час успішного логіну.
ActiveRecord у Yii2 — це надбудова над Query Builder: `ActiveQuery` успадковує `yii\db\Query`, будує той самий SQL, але замість масивів повертає обʼєкти моделей із валідацією, подіями, поведінками й звʼязками; Query Builder беруть там, де обʼєкти не потрібні — агрегати, звіти, масові операції.
Autowiring підставляє залежності за типом параметра конструктора, autoconfigure навішує теги за реалізованим інтерфейсом чи атрибутом; обидва мовчать там, де тип нічого не каже: скаляри й env, класи з vendor, дві реалізації одного інтерфейсу та теги з атрибутами вимагають явного запису в services.yaml або атрибута #[Autowire].
Замикання — це обʼєкт класу Closure; `function` захоплює зовнішні змінні лише явно через `use` (за значенням або за посиланням через `&`), а стрілочна функція `fn` (PHP 7.4+) захоплює їх автоматично й завжди за значенням.
`rules()` описує, що і як перевіряти, `scenarios()` - які атрибути активні в поточному сценарії й котрі з них safe; `load()` присвоює лише safe-атрибути, тому поле без жодного правила (або з `!` у сценарії) у модель просто не потрапить.
У cookie браузер тримає лише ідентифікатор (типово `PHPSESSID`), а самі дані `$_SESSION` лежать на сервері: у файлах `session.save_path`, у Redis або у власному `SessionHandlerInterface`. Cookie сесії має бути `HttpOnly`, `Secure` і `SameSite`, а API замість сесії краще авторизувати токеном у заголовку `Authorization`, бо cookie шлеться браузером автоматично й тягне за собою CSRF і прив'язку до сервера.
Composer реєструє свій завантажувач через `spl_autoload_register`, і при першій згадці класу PSR-4 перетворює його повне імʼя на шлях: найдовший збіг префікса замінюється базовою текою, решта імені стає підтеками й файлом. Classmap натомість заздалегідь сканує теки й будує таблицю «клас - файл», а `dump-autoload -o` перетворює PSR-4-правила на таку саму таблицю, щоб прибрати пошук по файловій системі.
Роутер зіставляє URI з маршрутами в порядку реєстрації, а перетворення `{post}` на об'єкт робить не контролер, а middleware `SubstituteBindings`: воно бере `getRouteKeyName()`, робить `firstOrFail()` і кидає 404 ще до вашого коду. Неявне зв'язування шукає по всій таблиці й ігнорує ієрархію URL, поки не ввімкнути `scopeBindings()`, а `SoftDeletes` мовчки вирізає видалені записи, доки на маршруті не стоїть `withTrashed()`.
Після резолву запиту wp-includes/template-loader.php перебирає умовні теги (is_404, is_search, is_front_page, is_home, is_singular, is_archive…) і для першого збігу будує список кандидатів від найконкретнішого до index.php; locate_template() шукає кожного кандидата спершу в child theme, потім у parent, тому перекриття — це файл із такою самою назвою в дочірній темі.
public/index.php підключає автолоадер і bootstrap/app.php, зібраний застосунок віддає запит HTTP-ядру: воно виконує бутстрапери (env, config, фасади, провайдери), проганяє запит через глобальні middleware, роутер знаходить маршрут, запускає його middleware й контролер, а відповідь повертається тим самим ланцюгом назад до send() і terminate().
Вид тесту визначає, скільки справжнього коду бере участь у перевірці: unit бере клас у памʼяті, integration додає одну справжню залежність (базу, кеш, файли), feature проганяє HTTP-запит крізь увесь застосунок, e2e клікає в браузері. Піраміда радить багато unit-тестів і мало повільних, трофей ставить у центр інтеграційні; пропорцію обирають за тим, де в проєкті живуть баги.
Магічні методи - це хуки, які движок кличе замість помилки: `__get`/`__set`/`__call` підміняють доступ до неоголошених членів класу, `__invoke` робить обʼєкт callable, `__toString` дає рядкове представлення, `__construct` і `__destruct` тримають життєвий цикл. За `__get`/`__set`/`__call` платять автодоповненням, типами й рефакторингом, тому їх лишають для проксі та ORM, а в звичайному коді оголошують властивості й методи явно.
Тип звʼязку визначає не «логіка», а те, де лежить ключ: hasOne/hasMany — ключ у дочірній таблиці, belongsTo — у поточній, belongsToMany — у проміжній, morph* — пара колонок *_id і *_type, коли дитина належить кільком типам батьків.
Робочий стандарт - 3НФ: кожен факт живе в одному місці, решта таблиць посилається на нього ключем. Денормалізують після 3НФ і під конкретний вимір читання, залишаючи нормалізовані дані джерелом істини, з якого дублікат можна перерахувати.
Pest це надбудова над PHPUnit: з кожного файлу з `it()`/`test()` він генерує клас, що успадковує `TestCase`, і запускає його раннером PHPUnit з тим самим `phpunit.xml`. Тому класові тести працюють поруч без змін, асерти PHPUnit доступні, а Pest додає `expect()`-ланцюжки, datasets, хуки, `arch()`, мутаційне тестування й профілювання.
`Collection` - це обгортка над масивом, який уже цілком у памʼяті, тож кожен `map()`/`filter()`/`groupBy()` робить повний прохід і створює новий масив, а `Model::all()->where(...)` спершу читає всю таблицю. Фільтрувати, рахувати й групувати має база; `LazyCollection` через `cursor()`, `lazy()` і `lazyById()` потрібна тоді, коли рядків більше, ніж влазить у памʼять.
Трейт — механізм горизонтального повторного використання коду: набір методів і властивостей, який компілятор копіює в клас.
Twig за замовчуванням екранує кожен вивід `{{ }}`, але стратегію обирає за розширенням файлу шаблону (`.html.twig` - html, `.js.twig` - js, `.txt.twig` - без екранування), а не за місцем виразу в розмітці; тому в атрибутах, URL-параметрах і в `<script>` потрібні явні `|e('html_attr')`, `|e('url')`, `|e('js')`, а `|raw` лишають тільки для HTML, який згенерував сам застосунок або пропустив через санітайзер.
Контент усіх типів лежить в одному wp_posts і розрізняється колонкою post_type, довільні поля в wp_postmeta за схемою EAV, налаштування в wp_options; рядки з autoload читаються одним запитом на кожен запит до сайту, тому роздутий autoload гальмує весь сайт.
Компонент - це ізольований шматок розмітки з явним контрактом: `@props` (або конструктор класу) описують очікувані дані й забирають ці ключі з `$attributes`, `$slot` приймає вкладений вміст, а решта переданих атрибутів осідає в `ComponentAttributeBag` і виводиться через `$attributes->merge()`, де `class` склеюється, а інші атрибути перекриваються викликом. `@include` навпаки не має ні контракту, ні слотів: він просто вставляє файл, якому видно всі змінні батьківської сторінки.
Масив передається за значенням, але фізична копія не створюється: PHP збільшує лічильник посилань на внутрішню структуру і копіює її лише при першому записі (copy-on-write). Обʼєкт copy-on-write не має - копіюється лише ідентифікатор, тому зміни властивостей видно з обох боків.
Роль - це лише іменований набір capabilities у таблиці options; перевіряти завжди треба capability через current_user_can, meta-можливості типу edit_post - разом з ID обʼєкта, а власні ролі додавати в register_activation_hook і прибирати в uninstall.php.
Git далеко не єдиний шлях, яким течуть секрети. `APP_DEBUG=true` показує будь-кому, хто спричинить помилку, заголовки запиту з cookies, тіло запиту з паролем, SQL з підставленими значеннями і код. Корінь сайту, налаштований на теку проєкту замість `public/`, віддає `.env` і `.git`. Логи і трекери помилок зберігають усе, що їм передали, а змінні з префіксом `VITE_` потрапляють у JavaScript-бандл. Захист: `APP_DEBUG=false`, корінь лише `public/`, явний перелік полів у логах, `#[\SensitiveParameter]` і жодних секретів у фронтенді.
Воркер - це звичайний PHP-процес, який рано чи пізно завершиться, тому поруч має бути менеджер процесів: supervisor із `autorestart=true` і `numprocs`, systemd-юніт із `Restart=always` або сам оркестратор контейнерів. Ключове налаштування - час очікування після SIGTERM (`stopwaitsecs`, `TimeoutStopSec`), більший за найдовшу задачу.
Хороший unit-тест перевіряє одну поведінку через публічний інтерфейс, складається з трьох частин (arrange, act, assert), має назву-речення про правило, не залежить від часу, бази й інших тестів і падає лише тоді, коли поведінка справді зламалась.