15 отобранных вопросов для собеседования по бэкенду на PHP, сгруппированных по уровням квалификации. Используйте их для повторения основ, понимания практических компромиссов и рассуждений на уровне Senior-разработчика о производственных системах.
1Чем в системе типов PHP отличаются нестрогая и строгая типизация и что меняется при включении declare(strict_types=1)?
По умолчанию PHP допускает совместимые преобразования скалярных значений для объявленных типов параметров и результата, например передачу числовой строки параметру int. declare(strict_types=1) включает строгую проверку: несовместимый аргумент или возвращаемое значение вызывает TypeError, хотя int по-прежнему допустим для float. Для аргументов пользовательской функции строгость определяет вызывающий файл; проверку результата определяет файл с объявлением функции. У вызовов из внутренних функций PHP есть особенности: их нельзя просто считать подчинёнными strict_types внешнего вызывающего кода. Директива должна находиться в начале своего PHP-файла. Она не делает PHP статически типизированным и не отключает преобразования в операторах по всему языку.
// THREE SEPARATE FILES: save each PHP block under its indicated filename.
// File: lib.php
<?php
function takesInt(int $x): int { return $x; }
?>
// File: weak.php
<?php
require __DIR__ . '/lib.php';
echo takesInt('123'); // 123: coercive caller
?>
// File: strict.php
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
try {
echo takesInt('123');
} catch (TypeError $e) {
echo 'TypeError';
}
?>
2Объясните разницу между == и === в PHP, включая особенности числовых строк, массивов, объектов и in_array().
В PHP == выполняет нестрогое сравнение с преобразованием типов, где оно применимо; === требует совпадения типов и значений без скалярного приведения. Числовые строки могут сравниваться как числа, поэтому нестрогое сравнение не подходит там, где нужна точная идентичность с учётом типов. Массивы равны по ==, если содержат одинаковые пары ключ/значение согласно правилам сравнения, независимо от порядка пар. Для === дополнительно требуются одинаковый порядок и совпадение типов ключей и значений. У объектов == сравнивает свойства экземпляров одного класса, а === требует тот же самый экземпляр. in_array по умолчанию сравнивает нестрого; для строгой проверки передайте true третьим аргументом. Нестрогое сравнение чисел со строками изменилось в PHP 8, поэтому старые примеры приведения нужно рассматривать с указанием версии.
3Чем различаются возвращаемые типы mixed, void и never в PHP и какие гарантии они дают?
mixed допускает любое возвращаемое значение, включая null. void означает отсутствие содержательного возвращаемого значения: функция может дойти до конца или выполнить return;, но не может вернуть выражение. Обычный вызов void-функции имеет значение null. never, доступный с PHP 8.1, означает, что функция никогда не возвращает управление вызывающему коду обычным образом. Она может выбросить исключение, завершить выполнение через exit или остаться в бесконечном цикле; завершать процесс когда-либо не обязательно. Функция never не может содержать оператор return. Главное различие: void-функция может нормально закончиться, после чего вызывающий код продолжит работу; у never-функции нормального продолжения нет.
<?php
function parse(string $raw): mixed {
return json_decode($raw, true);
}
function logMessage(string $message): void {
error_log($message);
return; // allowed
}
function fail(string $message): never {
throw new RuntimeException($message);
}
logMessage('starting');
fail('stop');
echo 'unreachable';
4Что такое iterable в PHP и как он связан с array, Traversable, Iterator и Generator?
iterable означает array|Traversable. Generator реализует Iterator, расширяющий Traversable, поэтому удовлетворяет iterable. Пользовательские классы предоставляют поведение Traversable через Iterator или IteratorAggregate, а не напрямую реализуя Traversable в конкретном классе. Iterator задаёт методы управления перебором; IteratorAggregate предоставляет итератор. Функция-генератор, содержащая yield, возвращает Generator. Важное различие: foreach также может перебирать видимые свойства обычного объекта без Traversable, но от этого объект не становится допустимым значением для параметра типа iterable.
function printItems(iterable $items): void {
foreach ($items as $item) {
echo $item, PHP_EOL;
}
}
printItems(['a', 'b']);
function letters(): Generator {
yield 'x';
yield 'y';
}
printItems(letters());
5Каковы правила видимости public, protected и private в PHP и как они взаимодействуют с наследованием и трейтами?
Члены public доступны всюду, где доступен объект или класс. Члены protected доступны внутри объявляющего класса и связанной иерархии родительских и дочерних классов, но не из произвольного внешнего кода. Члены private доступны внутри объявившего их класса; подкласс не может использовать закрытый член родителя как унаследованный API. Обычный переопределяющий метод не может сузить унаследованную видимость, однако у конструкторов особые правила наследования, поэтому переносить на них это правило безусловно нельзя. Члены трейта включаются в использующий класс и подчиняются его правилам видимости. Адаптация as может создать псевдоним и/или изменить видимость метода трейта; добавление псевдонима само по себе не удаляет исходное имя.
<?php
class Base {
public function a() {}
protected function b() {}
private function c() {}
}
trait Logs {
public function log(string $message): void {}
}
class Child extends Base {
use Logs { log as protected writeLog; }
public function test(): void {
$this->a(); // OK
$this->b(); // OK
// $this->c(); // Not accessible: private to Base
$this->writeLog('ok'); // OK inside class, protected alias
}
}
$child = new Child();
$child->a();
// $child->b(); // Not accessible from outside
// $child->writeLog('x'); // Not accessible from outside
6Как работают трейты PHP и как разрешаются конфликты, когда несколько трейтов определяют один метод?
Трейты обеспечивают горизонтальное повторное использование кода: класс импортирует их члены через use, а создать экземпляр самого трейта нельзя. Метод, объявленный в классе, имеет приоритет над методами трейтов; методы трейтов — над унаследованными методами родителя. Если несколько трейтов предоставляют один метод и конфликт не разрешён собственным методом класса, insteadof позволяет выбрать реализацию. Через as методу трейта можно дать дополнительное имя и при необходимости другую видимость. Псевдоним не переименовывает и не удаляет исходный метод. Разрешение конфликтов и поддержание понятного публичного API — отдельные задачи.
<?php
trait JsonLogger {
public function log(string $message): void {
echo "json:$message\n";
}
}
trait FileLogger {
public function log(string $message): void {
echo "file:$message\n";
}
}
class Service {
use JsonLogger, FileLogger {
JsonLogger::log insteadof FileLogger;
FileLogger::log as logToFile;
JsonLogger::log as protected logJson;
}
}
$service = new Service();
$service->log('hello');
$service->logToFile('hello');
// $service->logJson('hello'); // not accessible from outside; protected alias
7Что такое перечисления PHP, чем различаются чистые и перечисления со скалярным значением и как они помогают моделировать предметную область?
Перечисление задаёт закрытый набор именованных вариантов, например статусы заказа. Чистое перечисление содержит варианты без связанного скалярного значения. У перечисления со значениями (backed enum) каждый вариант имеет уникальное значение int или string, удобное для хранения и обмена. Перечисления заменяют произвольные строки и числа типобезопасными вариантами. Они могут иметь методы и реализовывать интерфейсы, cases() перечисляет варианты, а у backed enum предусмотрено преобразование между вариантом и его значением.
enum OrderState
{
case Draft;
case Paid;
case Shipped;
}
enum PaymentStatus: string
{
case Pending = 'pending';
case Complete = 'complete';
case Failed = 'failed';
}
$status = PaymentStatus::tryFrom('complete');
var_dump($status === PaymentStatus::Complete);
8Как скалярные типы, nullable, объединения, пересечения и DNF выражают контракты PHP API и когда применять каждый вариант?
Объявления PHP описывают принимаемые и возвращаемые значения. Скалярные объявления int, float, string и bool выбирают категорию значения; допустимость совместимых преобразований также зависит от strict_types и контекста. ?T или T|null допускает null. Объединения, доступные с PHP 8.0, допускают альтернативы вроде int|string. Чистые пересечения, доступные с PHP 8.1, требуют, чтобы объект удовлетворял всем перечисленным типам классов или интерфейсов, например Cacheable&JsonSerializable; int&string недопустим. DNF — дизъюнктивная нормальная форма типов, доступная с PHP 8.2, — объединяет альтернативы и пересечения в скобках, например (Cacheable&JsonSerializable)|string. Выбирайте наиболее узкий понятный контракт, представляющий допустимые входные данные, без лишних альтернатив.
<?php
interface Cacheable {}
class Report implements Cacheable, JsonSerializable {
public function jsonSerialize(): mixed { return []; }
}
function findName(int $id): ?string {
return $id > 0 ? 'Alice' : null;
}
function normalize(int|string $id): string {
return (string) $id;
}
function store(Cacheable&JsonSerializable $item): void {
// must satisfy both interfaces
}
function handle((Cacheable&JsonSerializable)|string $input): void {
// either an object satisfying both contracts, or a string
}
9Чем в PHP различаются callable, Closure, вызываемые значения первого класса, вызываемые объекты и массивы-ссылки на методы?
callable описывает вызываемые значения: строки с именами функций, объекты Closure, ссылки на доступные методы и объекты с __invoke. Closure — конкретный вызываемый объект: его источником может быть анонимная функция либо именованная функция или метод, преобразованные в Closure. Синтаксис вызываемых значений первого класса, например strlen(...) или $object->method(...), создаёт Closure без вызова целевой функции. Массив [$object, 'method'] ссылается на доступный метод экземпляра; [ClassName::class, 'method'] в современном PHP требует подходящего доступного статического метода. is_callable проверяет возможность вызова в соответствующей области видимости. callable — широкий контракт, а Closure — конкретное представление.
class Greeter {
public function hello(string $name): string { return "Hello $name"; }
public function __invoke(string $name): string { return "Hi $name"; }
}
$g = new Greeter();
$closure = fn(string $name) => "Hey $name";
$arrayCallable = [$g, 'hello'];
$firstClass = $g->hello(...);
$invokable = $g;
foreach ([$closure, $arrayCallable, $firstClass, $invokable] as $cb) {
echo $cb('Sam'), PHP_EOL;
}
10Как PHP обрабатывает переполнение целых, точность чисел с плавающей запятой и точные десятичные расчёты?
Размер int зависит от платформы; верхняя граница доступна как PHP_INT_MAX. При переполнении результат может стать float и потерять точность; PHP не переходит автоматически на целые произвольной точности. Двоичные float приближённо представляют многие десятичные дроби, например 0.1. Для денег и точных десятичных расчётов используйте целые в минимальных единицах, когда это подходит, или BCMath. GMP подходит для целых произвольной точности.
11Как работают ссылки PHP (&) и копирование при записи и откуда берутся неожиданные побочные эффекты?
Внутренне значения представлены zval. При обычном $b = $a массивы и строки могут разделять представление до изменения, после которого PHP отделяет копию. Ссылка через & делает переменные именами одной привязки, и присваивание видно через обе. Передача по ссылке позволяет изменить переменную вызывающего. После foreach ($array as &$value) переменная остаётся ссылкой на последний элемент; unset($value) разрывает её и защищает элемент от случайной перезаписи.
12Как в PHP работают идентификаторы объектов, присваивание, clone и __clone() и чем различаются поверхностная и глубокая копии?
$b = $a копирует идентификатор объекта, поэтому обе переменные обращаются к одному экземпляру и видят изменения без &. clone $a создаёт отдельный объект с поверхностной копией свойств. Вложенные объекты при этом остаются общими, если их не скопировать. __clone() выполняется на новом объекте после копирования и позволяет сбросить идентификатор, отделить ресурсы или скопировать нужные вложенные объекты. Глубокое копирование рекурсивно отделяет изменяемые данные, которые не должны быть общими.
class Address {
public function __construct(public string $city) {}
}
class User {
public function __construct(public string $name, public Address $address) {}
public function __clone() {
$this->address = clone $this->address; // make nested object independent
}
}
$u1 = new User('Ann', new Address('Paris'));
$u2 = $u1; // same object handle
$u2->name = 'Beth';
echo $u1->name, PHP_EOL;
$u3 = clone $u1; // new User object; __clone deep-clones address
$u3->address->city = 'Rome';
echo $u1->address->city, PHP_EOL;
echo $u3->address->city, PHP_EOL;
13Как расследовать исчерпание соединений с базой и управлять подключениями PHP-FPM и долгоживущих обработчиков?
Сравните лимит базы и число сессий с совокупным параллелизмом FPM, хостов, очередей, планировщика и развёртываний. Посмотрите состояния сессий, незавершённые транзакции, запросы, блокировки, журналы и ожидание пула. Настройте pm.max_children по общему бюджету, своевременно фиксируйте или откатывайте короткие транзакции и не удерживайте соединение на медленных внешних вызовах. Постоянный PDO уменьшает стоимость подключения, но может удерживать сессии и состояние. Совместимый прокси, например PgBouncer, может обслуживать клиентов меньшим числом серверных соединений. Для долгоживущих процессов явно задайте проверку, переподключение после простоя, перезапуска или переключения базы, сброс состояния и безопасные повторы. После fork создавайте отдельные соединения.
Connection-budget example:
- 6 application servers
- 2 PHP-FPM pools on EACH server
- pm.max_children = 40 for EACH pool
- Assume at most 1 database connection per child
Potential request connections = 6 * 2 * 40 = 480.
If 40 were the TOTAL children per server across both pools, it would be 240.
Budget separately for queue workers, cron, deployments and administrators.
A limit of 300 database connections cannot accommodate 480 simultaneous connections.
14Как организовать в PHP работу с репликами для чтения, разделение чтения и записи, задержку репликации и переключение основной БД при отказе?
Разделяйте соединения с основной БД и репликами по смыслу операции, а не только по наличию SELECT в начале SQL: блокирующие чтения, записи и транзакции, требующие согласованности с основной БД, должны выполняться на ней. Транзакции только для чтения могут использовать подходящую реплику, если это допускают требования к согласованности. Асинхронная репликация может отставать. Настройка sticky в Laravel после записи направляет последующие чтения текущего запроса на соединение записи; фиксированный период привязки между запросами лишь снижает риск, но не даёт безусловной гарантии чтения собственных записей. Для гарантии читайте с основной БД либо дожидайтесь применения репликой требуемой позиции записи, задавая тайм-аут и переход к основной БД. Поведение прокси различается: RDS Proxy использует отдельные точки подключения для чтения и записи, а не универсальное автоматическое разделение отдельных SQL-операторов. При отказе отбрасывайте устаревшие соединения и переподключайтесь с ограниченными задержками повторов. Потеря ответа на commit оставляет исход неопределённым: не повторяйте вслепую неидемпотентную запись; используйте ключи идемпотентности или сначала выясните результат.
15Как безопасно развёртывать миграции и изменения схемы БД с нулевым или минимальным простоем PHP-приложения?
Применяйте расширение с последующим сокращением схемы, совместимое со старой и новой версиями приложения во время постепенного развёртывания. Сначала добавьте совместимые структуры, затем код, способный работать с обоими представлениями. Если нужна двойная запись, обеспечьте её согласованность и возобновляемое заполнение старых данных с ограничением нагрузки, не перезаписывающее более свежие записи. Проверьте перенесённые данные и переключите чтение до удаления старых ветвей кода. Онлайн-изменения DDL снижают отдельные риски блокировок, но не гарантируют полного их отсутствия: учитывайте блокировки конкретной БД и версии, ограничения транзакций, ресурсы и влияние на репликацию. Например, PostgreSQL CREATE INDEX CONCURRENTLY имеет ограничения и после сбоя может оставить невалидный индекс. Удаляйте старые столбцы только после остановки старых экземпляров приложения и окончания необходимого окна отката. После разрушительного сокращения схемы откат к коду, которому нужны удалённые данные, уже не является автоматически безопасным.
<?php
// Step 1: Migration (Expand) -> Add new column as nullable
Schema::table('users', function (Blueprint $table) {
$table->string('full_name')->nullable()->after('name');
});
// Step 2: Code deployment -> Write to both, read with fallback
class User extends Model {
public function setNameAttribute($value) {
$this->attributes['name'] = $value;
$this->attributes['full_name'] = $value; // Dual write
}
}
// Step 3: Background worker backfills existing rows in small batches
// Step 4: Code deployment -> Read/write exclusively from 'full_name'
// Step 5: Migration (Contract) -> Drop legacy 'name' column