PHPСправочник PHP 8+27 узлов · 408 возможностейизучено 0 из 27на главную
Сверху — путь кода: PHP поднимается, компилирует файл в опкоды, держит их
в кэше и выполняет. Ниже — четыре семейства возможностей, которые этот код дёргает,
а внизу то, без чего на PHP всё равно не пишут. Нажмите на блок, чтобы открыть разбор;
при наведении мышью — короткая справка.
Esc
php.ini · OPcache
Запуск и настройка
Каждый запрос начинается с чистого листа: PHP поднимается, компилирует файлы в опкоды, выполняет их и всё забывает. Кэш опкодов и настройки решают, сколько это стоит.
Главная особенность модели PHP — shared nothing: ни один запрос не видит память предыдущего. Глобальные переменные, статические свойства, открытые соединения — всё создаётся заново и уничтожается в конце. Отсюда простота отладки и отсюда же цена: код компилируется на каждом запросе, если его не закэшировать.
PHP стартует в выбранном SAPI — php-fpm для веба, cli для консоли, встроенный сервер для разработки.
Читается php.ini, подключаются расширения, применяются настройки пула.
Входной файл разбирается и компилируется в опкоды — низкоуровневые инструкции Zend VM.
OPcache кладёт опкоды в общую память: со второго запроса шаг компиляции пропускается.
Опкоды выполняются; классы подтягиваются автозагрузчиком по мере первого обращения.
Вывод уходит клиенту, память освобождается, всё состояние процесса сбрасывается.
советJIT нужен не всем
JIT компилирует горячие опкоды в машинный код и заметно ускоряет вычисления: обработку изображений, математику, разбор данных. Типичное веб-приложение упирается не в процессор, а в базу и сеть, поэтому там выигрыш от JIT обычно в пределах шума, а от OPcache — кратный.
Консоль
php-v# версия и включённые SAPI-расширенияphp-m# список загруженных расширенийphp--ini# какие ini-файлы реально прочитаныphp-i | grep opcache # что с кэшем опкодовphp-l file.php # проверить синтаксис, ничего не выполняяphp-r'echo PHP_INT_MAX;'# выполнить строкуphp-a# интерактивная оболочкаphp-S localhost:8000-t public # встроенный сервер: только для разработки
Настройки, которые приходится трогать чаще всего, и способы узнать, что происходит с окружением.
display_errors = On
error_reporting = E_ALL
zend.assertions = 1
opcache.enable = 1opcache.validate_timestamps = 1opcache.revalidate_freq = 0 ; изменения видны сразу
граблиЗабытый opcache.validate_timestamps=0 после деплоя
С отключённой проверкой времени PHP не заметит, что файлы изменились: сайт продолжит работать на старом коде, пока не перезапустят FPM или не позовут opcache_reset(). Если деплой раскладывает файлы в тот же каталог — это классические «полчаса странного поведения» после выката.
Проверить, что окружение действительно такое, как думаете
Имя класса — это путь к файлу, по которому его найдут в момент первого обращения. Вся схема держится на одном соглашении и одной функции.
Пространство имён — это префикс к именам, не более: он не создаёт области видимости для переменных и не влияет на выполнение. Зато по полному имени класса автозагрузчик вычисляет путь к файлу — и подключает его ровно тогда, когда класс впервые понадобился.
Не нашёлся файл или имя класса внутри не совпало с ожидаемым — будет «Class not found».
src/Mail/Sender.phpPHP
<?phpdeclare(strict_types=1);
namespaceApp\Mail; // одна на файл — и первой инструкцией после declareuseApp\Config\Settings; // импорт классаuseApp\Mail\Transport\{Smtp, Sendmail}; // групповой импортuseApp\Support\StrasStrHelper; // псевдонимusefunctionApp\Support\slugify; // импорт функцииuseconstApp\Support\VERSION; // импорт константыfinalclassSender
{
publicfunctionsend(): void
{
$t = newSmtp(); // короткое имя — из-за use$x = new \DateTimeImmutable(); // обратный слеш: глобальное пространствоstrlen('...'); // встроенные функции ищутся и в глобальном тоже
}
}
граблиВнутри пространства имён \ обязателен для классов
В файле с namespace App; запись new DateTime() означает App\DateTime — и падает с «Class not found». Для функций правило мягче: если функции нет в текущем пространстве, PHP посмотрит в глобальном. Для классов такого запасного пути нет.
if (class_exists($handler)) {
$instance = new$handler();
}
опции: растёт по мере автозагрузки
count(get_declared_classes());
опции: namespace\Foo::bar()
$class = __NAMESPACE__ . '\\' . $name;
пример
namespace
Объявляется первой инструкцией файла (после declare). Фигурная форма с несколькими пространствами в одном файле существует, но в нормальном коде не встречается.
Проверьте регистр: на macOS файл sender.php найдётся, на боевом Linux — нет.
Сверьте имя пространства внутри файла с путём: App\Mail\Sender → src/Mail/Sender.php при префиксе App\ → src/.
Убедитесь, что каталог вообще указан в autoload composer.json, а не только в autoload-dev.
После добавления новой секции автозагрузки выполните composer dump-autoload.
Если класс из пакета — проверьте, что пакет в require, а не только в require-dev.
Оптимизация автозагрузки на проде
Консоль
composer install --no-dev--optimize-autoloader# таблица «класс → файл» вместо поискаcomposer dump-autoload --classmap-authoritative# неизвестный класс даже не ищем на диске
советИмя класса — это адрес файла
Единственная причина, по которой автозагрузка вообще работает, — соглашение. Как только в файле оказываются два класса, или имя файла не совпадает с именем класса, или пространство имён не соответствует каталогу, автозагрузчик перестаёт находить код. Отсюда правило «один класс — один файл», не связанное ни с какой эстетикой.
Throwable
Ошибки и исключения
В PHP 8 почти всё, что раньше было предупреждением, стало исключением. Ловить их принято точечно, а не одним catch на весь файл.
Всё, что можно бросить, реализует Throwable. Дальше развилка: Error — это ошибки самого языка (не тот тип, деление на ноль, нет метода), Exception — ошибки приложения. Ловить Error обычно не нужно: он означает, что код написан неверно.
Свой класс исключения наследуют от Exception или его потомков: реализовать Throwable напрямую язык не разрешает.
PHP
try {
$order = $repo->find($id) ?? thrownewNotFound("заказ $id"); // throw — выражение с 8.0$order->pay($amount);
} catch (NotFound | AccessDenied$e) { // несколько типов одним блокомreturn$this->error(404, $e->getMessage());
} catch (PaymentFailed) { // без переменной, если она не нужна (8.0)return$this->error(402, 'платёж отклонён');
} catch (Throwable$e) {
$this->log->error($e->getMessage(), ['exception' => $e]);
throw$e; // не проглатываем: пробрасываем выше
} finally {
$this->lock->release(); // выполнится в любом случае
}
Предупреждения PHP — это не исключения
Обращение к несуществующему ключу, include пропавшего файла, деление на ноль через % — часть из этого по-прежнему диагностика движка, а не Throwable. Чтобы такие места тоже попадали в catch, их превращают в исключения обработчиком.
граблиcatch (Throwable) на каждом шаге прячет ошибки
Перехват всего подряд превращает TypeError и опечатку в имени метода в «ошибку сервиса»: приложение продолжает работать со сломанными данными. Ловите то, что умеете обработать, а общий catch оставьте одному месту — на границе, где формируется ответ.
Слева — классы и конструкции. Из встроенных классов исключений в приложении обычно бросают четыре-пять; остальные полезно узнавать в чужих трассировках.
// одно базовое на приложение — по нему ловят «всё наше»interfaceAppExceptionextendsThrowable {}
// на домен — от LogicException или RuntimeException, по смыслуclassOrderExceptionextendsRuntimeExceptionimplementsAppException {}
finalclassOrderNotFoundextendsOrderException
{
publicstaticfunctionbyId(int$id): self
{
returnnewself("заказ $id не найден");
}
}
finalclassOrderAlreadyPaidextendsOrderException {}
// на границе HTTP ловим свои и переводим в коды ответаtry {
$this->handler->run($request);
} catch (OrderNotFound$e) {
returnnewResponse(404, $e->getMessage());
} catch (AppException$e) {
returnnewResponse(409, $e->getMessage());
}
советИсключение с именованным конструктором читается как предложение
throw OrderNotFound::byId($id) говорит и что случилось, и с чем. Заодно формулировка сообщения лежит в одном месте — и её можно поменять, не бегая по всему проекту.
Ресурсы освобождает finally, а не последняя строка
PHP
$fh = fopen($path, 'r');
try {
foreach (readRows($fh) as$row) {
if ($row['broken']) {
returnnull; // ранний выход — файл всё равно закроется
}
}
} finally {
fclose($fh);
}
вниманиеСообщение исключения видит не только разработчик
Текст getMessage() часто оказывается в ответе API или на экране пользователя. Пути к файлам, SQL-запросы и содержимое переменных туда попадать не должны: детали — в лог через контекст, пользователю — что случилось и что делать.
get_debug_type()
Типы и приведение
Тип есть у значения, а не у переменной. Объявленный тип — это проверка на входе, и строгость этой проверки выбирает файл, из которого пришёл вызов.
В PHP тип принадлежит значению: переменная просто держит то, что в неё положили. Зато у параметров, свойств, возвращаемых значений и констант класса можно объявить тип — и тогда PHP проверяет значение на входе.
Что он сделает с неподходящим значением, решает не объявление, а файл, из которого сделан вызов: с declare(strict_types=1) в его первой строке несоответствие сразу даёт TypeError, без него PHP сначала попробует привести значение к нужному типу.
Объявление типа одно и то же — разойдутся только вызовы из файлов с разным режимом.
src/Price.phpPHP
<?phpdeclare(strict_types=1); // первая строка файла, иначе фатальная ошибкаfinalclassPrice
{
publicfunction__construct(
privatereadonlyint$amount, // тип свойстваprivatereadonlystring$currency = 'RUB',
) {}
publicfunctionwithVat(float$rate): static// тип аргумента и возврата
{
returnnewstatic((int) round($this->amount * (1 + $rate)), $this->currency);
}
}
$p = newPrice(1000);
$p->withVat(0.2); // 1200$p->withVat('0.2'); // TypeError: строка не float, режим строгий
Какие вообще бывают значения
null
единственное значение «ничего»; переменная без значения тоже null
bool
true / false; приведение к нему — самая частая причина неожиданностей
int
целое, 64 бита на обычной сборке; при переполнении молча становится float
float
число с плавающей точкой; 0.1 + 0.2 !== 0.3 — это не баг PHP, а IEEE 754
string
последовательность байтов, а не символов; длина в strlen() — в байтах
array
упорядоченная хэш-таблица: и список, и словарь одновременно
object
экземпляр класса; единственный тип, который передаётся по ссылке на объект
callable
то, что можно вызвать: имя функции, замыкание, [$obj, 'method']
iterable
array или Traversable — всё, что можно обойти в foreach
resource
открытый файл, соединение; в PHP 8 их массово заменили объектами
граблиЦелое переполнилось и стало дробным
PHP_INT_MAX + 1 не упадёт и не обрежется — результат станет float, потеряв точность. Для денег и идентификаторов это тихая порча данных: считайте в копейках или храните строкой.
что сделает объявленный тип
проверяемый вызов
Слева — то, что можно написать в объявлении типа. Версия у пункта означает, с какой версии PHP это объявление вообще принимается.
get_debug_type(1.0); // 'float' — так же пишется в объявлении типаget_debug_type(newDateTime); // 'DateTime'get_debug_type(null); // 'null'get_debug_type([]); // 'array'gettype(1.0); // 'double' — историческое название, в объявление не вставитьgettype(newDateTime); // 'object' — какой именно класс, не скажет// проверки по одному типуis_int($v); is_float($v); is_string($v); is_bool($v);
is_array($v); is_object($v); is_callable($v); is_iterable($v);
is_numeric($v); // число или строка, которую PHP считает числом$vinstanceofCountable; // объект нужного класса или интерфейса
советget_debug_type() вместо gettype()
Появилась в 8.0 и возвращает ровно те названия, которые пишут в объявлениях типов: int, float, bool, null, имя класса. Поэтому её удобно подставлять в сообщения об ошибках: там будет float, а не загадочное double.
Приведение руками
PHP
(int) '42abc'; // 42 — читает, пока читается
(int) 'abc'; // 0
(int) 12.9; // 12 — отбрасывает дробную часть, а не округляет
(int) true; // 1
(bool) '0'; // false — единственная непустая строка, которая ложна
(bool) '0.0'; // true
(bool) []; // false
(string) 3.0; // '3'
(array) $object; // свойства в массив: приватные получат служебные ключи
(object) ['a' => 1];// stdClass с полем aintdiv(7, 2); // 3 — целочисленное деление без приведения7 <=> 2; // 1round(2.5); // 3.0 — половина от нуля
(int) round(2.5); // 3
граблиСтрока «0» — единственная ложная непустая строка
Ложны: false, 0, 0.0, -0.0, '', '0', [], null. Строка '0.0', строка с пробелом и пустой объект — истинны. Поэтому if ($_GET['page']) теряет страницу «0», а if (count($rows)) честнее, чем if ($rows).
Нормализация входа на границе приложения
src/Http/Input.phpPHP
declare(strict_types=1);
finalclassInput
{
/** Всё, что пришло по HTTP, — строки. Превращаем их в типы один раз и в одном месте. */publicstaticfunctionint(array$src, string$key, ?int$default = null): ?int
{
$raw = $src[$key] ?? null;
if (!is_string($raw) || !preg_match('/^-?\d+$/', $raw)) {
return$default;
}
return (int) $raw; // приведение уже безопасно: формат проверен
}
}
$page = Input::int($_GET, 'page', 1);
советstrict_types ставят во все файлы проекта
Режим действует на файл с вызовом, а не на файл с объявлением, поэтому «включить строгость проекту» нельзя одной настройкой — только строкой в каждом файле. Зато в строгом режиме f('5') падает там, где написана ошибка, а не тремя слоями ниже.
match · ?? · |>
Операторы и выражения
Сравнение, выбор ветки и короткие записи «если пусто». Здесь же главное изменение PHP 8: число и нечисловая строка больше не равны.
Два оператора сравнения делают разное. === проверяет тип и значение, == сначала приводит операнды к общему типу и только потом сравнивает. Правила приведения PHP 8 переписал: раньше число сравнивалось со строкой как число, теперь — наоборот.
Число со числовой строкой по-прежнему сравнивается как число: 0 == '0.0' — правда.
PHP
0 == 'abc'; // PHP 8: false, PHP 7: true'1' == '01'; // true — обе строки числовые, сравниваются как числа'10' == '1e1'; // true — то же самое100 == '1e2'; // true'abc' == 0; // falsenull == false; // true
[] == false; // true'' == null; // true0 === '0'; // false — === не приводит вообще ничего$a <=> $b; // -1, 0 или 1: меньше, равно, больше
Выбор ветки: match вместо switch
PHP
$label = match (true) {
$n < 0 => 'отрицательное',
$n === 0 => 'ноль',
default => 'положительное',
};
// match: строгое сравнение (===), возвращает значение, без провала в следующую ветку,// при непопадании и без default — UnhandledMatchError$code = match ($status) {
'new', 'draft' => 201, // несколько условий на ветку'done' => 200,
default => 400,
};
// switch: нестрогое сравнение (==), ничего не возвращает, без break падает дальшеswitch ($status) {
case'new':
$code = 201;
break;
default:
$code = 400;
}
граблиswitch сравнивает нестрого
switch (0) войдёт в case 'abc' в PHP 7 и в case '' — в PHP 8. match этого не умеет по определению: он сравнивает через ===, поэтому ветка срабатывает только на точное совпадение типа и значения.
сравните два значения
Операторы по убыванию того, как часто их пишут. Приоритет здесь не указан: когда он неочевиден, скобки дешевле, чем таблица приоритетов в голове.
20 шт.
опции: единственное сравнение без сюрпризов
$a === $b;
$obj1 === $obj2; // тот же объект, а не такой же
Каждая ветка — самостоятельное условие, первое истинное выигрывает. Это та же лестница if / elseif, но без повторения переменной и с обязательным результатом: забыть ветку и получить null не выйдет.
Конвейер вместо матрёшки
PHP
// до 8.5: читается изнутри наружу, а пишется снаружи внутрь$slug = str_replace(' ', '-', mb_strtolower(trim($title)));
// 8.5: тот же порядок, в котором думаешь$slug = $title
|> trim(...)
|> mb_strtolower(...)
|> fn(string$s): string => str_replace(' ', '-', $s);
вниманиеЕсли целевая версия ниже 8.5
Конвейер — синтаксис, а не функция: код с |> не запустится на 8.4 и ниже, и заменить его полифилом нельзя. Проверяйте минимальную версию в composer.json прежде, чем переписывать цепочки.
string
Строки
Строка в PHP — это последовательность байтов, а не символов. Отсюда две половины стандартной библиотеки: байтовая и та, что с приставкой mb_.
Строка хранит байты. Пока текст в ASCII, байт и символ совпадают, и разницы не видно. Как только появляется кириллица или эмодзи, strlen() начинает считать не то, а substr() — резать символ пополам. Для текста берут функции mb_*, которые знают про кодировку.
PHP
$s = 'Привет';
strlen($s); // 12 — байтовmb_strlen($s); // 6 — символовsubstr($s, 0, 3); // "Пр" с половиной буквы: битый байтmb_substr($s, 0, 3); // 'При'strtoupper($s); // 'Привет' — не сработало: функция знает только латиницуmb_strtoupper($s); // 'ПРИВЕТ'// а ещё есть символы из нескольких кодовых точекmb_strlen('👨👩👦'); // 5, а глазами — один
С PHP 7.3 закрывающая метка может стоять с отступом, и этот отступ срезается у всех строк. Поэтому heredoc внутри метода больше не обязан прижиматься к левому краю файла.
строка — это байты
по байтам и по символам
Самое нужное из нескольких сотен строковых функций. Приставка mb_ означает «понимает кодировку»: для пользовательского текста берите её, для протоколов и двоичных данных — байтовую версию.
if (!mb_check_encoding($raw, 'UTF-8')) {
$raw = mb_convert_encoding($raw, 'UTF-8', 'Windows-1251');
}
опции: второй аргумент — разделитель
$re = '/' . preg_quote($needle, '/') . '/iu';
опции: отрицательные индексы работают
$s = 'abc';
$s[0]; // 'a'$s[-1]; // 'c'
пример
str_contains()8.0
Есть ли подстрока. До 8.0 писали strpos($h, $n) !== false и регулярно забывали про !== false.
опции: регистрозависима
if (str_contains($email, '@')) { }
Ничего не нашлось. Попробуйте другое слово.
Сборка текста: sprintf вместо склейки
PHP
// склейка прячет структуру и ломается на переводе$msg = 'Пользователь ' . $name . ' купил ' . $n . ' шт. на ' . number_format($sum, 2) . ' ₽';
// шаблон видно целиком, порядок аргументов можно поменять под язык$msg = sprintf(
'Пользователь %s купил %d шт. на %s ₽',
$name,
$n,
number_format($sum, 2, ',', ' '),
);
Экранирование по месту вывода, а не по месту ввода
templates/comment.phpPHP
<!-- в HTML --><p><?=htmlspecialchars($comment) ?></p><!-- в атрибуте — то же самое, с 8.1 одинарные кавычки экранируются по умолчанию -->
<a title="<?=htmlspecialchars($title) ?>">…</a><!-- в JavaScript — не htmlspecialchars, а JSON --><script>const user = <?=json_encode($user, JSON_THROW_ON_ERROR | JSON_HEX_TAG) ?>;</script>
граблиЭкранирование «на входе» портит данные
Если htmlspecialchars() применяют к тексту перед сохранением в базу, то в базе лежит уже не текст пользователя, а HTML. Такое поле нельзя отдать в JSON, в письмо или в PDF без обратного преобразования — и рано или поздно кто-нибудь экранирует его второй раз.
Безопасное усечение длинного текста
PHP
functionexcerpt(string$text, int$limit = 140): string
{
$text = trim(preg_replace('/\s+/u', ' ', strip_tags($text)));
if (mb_strlen($text) <= $limit) {
return$text;
}
// режем по символам, а не по байтам, и не посреди слова$cut = mb_substr($text, 0, $limit);
$space = mb_strrpos($cut, ' ');
returnrtrim($space === false ? $cut : mb_substr($cut, 0, $space), " ,.;:") . '…';
}
советМодификатор u в регулярных выражениях
Без него preg_* работает с байтами: . съест один байт кириллической буквы, а \w не будет считать её буквой. Для текста флаг u обязателен — и он же требует, чтобы строка была корректным UTF-8.
array
Массивы
Один тип на все случаи: список, словарь, очередь и набор — это всё упорядоченная хэш-таблица с ключами int и string.
Массив PHP — упорядоченная хэш-таблица. Порядок вставки сохраняется всегда, ключом может быть только int или string, всё остальное приводится: 1.7 станет 1, true — 1, null — пустой строкой, а строка "7" — числом 7.
Массив присваивается по значению, но копия делается лениво: пока никто не пишет, обе переменные смотрят в одни данные. Объект внутри массива при этом не копируется — копируется ссылка на него.
PHP
$a = [1, 2, 3];
$b = $a; // копии ещё нет$b[] = 4; // вот теперь копия: $a остался [1, 2, 3]$objects = [newArrayObject([1])];
$copy = $objects;
$copy[0][] = 2; // тот же объект: изменились оба массива// ссылка в foreach переживает цикл — классические грабли$rows = [1, 2, 3];
foreach ($rowsas &$row) { $row *= 2; }
unset($row); // без этого следующий foreach испортит последний элемент
граблиЗабытый unset($row) после foreach по ссылке
После цикла $row остаётся ссылкой на последний элемент. Следующий foreach ($rows as $row) будет на каждом шаге писать в него — и массив закончится дублем предпоследнего значения. Либо unset() сразу после цикла, либо не брать по ссылке вовсе: array_map() или новый массив.
Разбор массива на переменные
PHP
[$first, $second] = ['раз', 'два'];
['id' => $id, 'name' => $name] = $row; // по ключам
[, $second] = $pair; // пропустить элемент
[[$a, $b], [$c, $d]] = $matrix; // вложенноforeach ($rowsas ['id' => $id, 'sum' => $sum]) {
// разбор прямо в заголовке цикла
}
// распаковка в другой массив; строковые ключи — с 8.1$defaults = ['limit' => 10, 'offset' => 0];
$query = [...$defaults, 'limit' => 50];
Функции сгруппированы по задаче: сначала обход и преобразование, потом поиск, потом сортировка и структурные операции.
// было: O(n) поиск внутри O(n) циклаforeach ($ordersas$order) {
foreach ($usersas$user) {
if ($user->id === $order->userId) { /* ... */ }
}
}
// стало: один проход на индекс, дальше обращение по ключу$byId = array_column($users, null, 'id');
foreach ($ordersas$order) {
$user = $byId[$order->userId] ?? null;
}
Массив как набор
PHP
// набор из ключей: проверка принадлежности — по хэшу, а не перебором$allowed = array_flip(['create', 'update', 'delete']);
if (isset($allowed[$action])) { /* ... */ }
// пересечение по ключам — способ оставить только разрешённые поля$clean = array_intersect_key($input, array_flip(['title', 'body']));
советСписок или словарь — решайте заранее
array_filter(), array_diff() и array_unique() сохраняют ключи, а json_encode() превращает массив с дырами в объект. Если результат должен уехать в JSON как массив — закрывайте цепочку array_values(), иначе фронтенд однажды получит {"1": …}.
Когда массив уже не нужен
PHP
// весь файл в памяти$lines = file('big.csv');
$rows = array_map('str_getcsv', $lines);
// по строке за раз: память не растёт с размером файлаfunctionrows(string$path): Generator
{
$fh = fopen($path, 'r');
try {
while (($row = fgetcsv($fh)) !== false) {
yield$row;
}
} finally {
fclose($fh);
}
}
вниманиеФункция получает копию, а объект — нет
Передача массива в функцию не копирует данные, пока функция их не меняет. Но если внутри что-то присваивается, копия создаётся целиком — на миллионе строк это заметно. Либо &$array, либо генератор, либо объект.
function
Функции и аргументы
Объявление, типы, значения по умолчанию и четыре способа передать аргумент — включая именованные, которые убирают из вызова ряды из true, null и false.
Объявление функции — это имя, список параметров и, по желанию, объявленные типы. Всё, что не передано и не имеет значения по умолчанию, — ошибка вызова: с PHP 8 ArgumentCountError, а не предупреждение.
PHP
declare(strict_types=1);
functionpaginate(
array$rows, // обязательныйint$page = 1, // со значением по умолчаниюint$perPage = 20,
?string$sort = null, // ? обязателен: неявный nullable устарел в 8.4
): array {
$offset = ($page - 1) * $perPage;
returnarray_slice($rows, $offset, $perPage);
}
paginate($rows); // 2-й и далее — по умолчаниюpaginate($rows, perPage: 50); // именованный: пропустили $pagepaginate($rows, sort: 'id', page: 2); // порядок именованных не важен
Четыре способа передать аргументы
позиционно
по порядку; после именованного аргумента позиционных быть не может
именованно — с 8.0
perPage: 50; имя параметра становится частью публичного контракта
списком — `...$args`
в объявлении собирает остаток, в вызове раскладывает массив
по ссылке — `&$x`
функция меняет переменную вызывающего; в новом коде почти не нужен
граблиИмя параметра теперь тоже контракт
Как только кто-то вызвал вашу функцию с именованным аргументом, переименование параметра — ломающее изменение. В библиотеках это отдельная причина не переименовывать параметры в минорных версиях.
Возврат
PHP
classRepository
{
publicfunctionfind(int$id): ?User { } // объект или nullpublicfunctiontotal(): int { } // ровно intpublicfunctionsave(): void { } // ничего: return; можно, а return null; — нетpublicfunctionfail(string$m): never { } // не вернётся: бросит или завершитpublicfunctioncopy(): static { } // объект того класса, у которого вызвали
}
// несколько значений — массивом или объектом; список разбирается на месте
[$ok, $error] = validate($input);
// что делают эти true и null — по вызову не понять$pdf = render($html, true, null, false, 'A4');
// то же самое, но читается без документации$pdf = render($html, landscape: true, format: 'A4');
Когда параметров стало слишком много
src/Report/Params.phpPHP
// шесть параметров, половина необязательных — признак того, что это объектfinalclassReportParams
{
publicfunction__construct(
publicreadonlyDateTimeImmutable$from,
publicreadonlyDateTimeImmutable$to,
publicreadonlystring$currency = 'RUB',
publicreadonlybool$withTax = true,
) {}
}
functionreport(ReportParams$params): Report { /* ... */ }
report(newReportParams(from: $from, to: $to, withTax: false));
советРанний выход вместо лестницы условий
Проверки в начале функции с немедленным return или throw держат основной путь на нулевом уровне вложенности. Сравните: три вложенных if против трёх охранных условий — вторые читаются сверху вниз и не заканчиваются частоколом закрывающих скобок.
PHP
functionpublish(Post$post, User$user): void
{
if (!$user->can('publish')) {
thrownewAccessDenied();
}
if ($post->isPublished()) {
return; // уже сделано — не ошибка
}
$post->publish(); // основной путь: без вложенности
}
Closure
Замыкания
Функция как значение: объект класса Closure, который можно передать, сохранить и привязать к другому объекту.
Замыкание — это объект класса Closure. Оно помнит область видимости, в которой создано: обычная форма берёт переменные списком use, стрелочная — сама, по значению, и только те, что реально упомянуты в теле.
PHP
$rate = 0.2;
// обычное: что захватить — пишем явно$withVat = function (int$sum) use ($rate): int {
return (int) round($sum * (1 + $rate));
};
// стрелочное: захватывает само, по значению, тело — одно выражение$withVat = fn(int$sum): int => (int) round($sum * (1 + $rate));
// по ссылке: изменения видны снаружи$total = 0;
$add = function (int$n) use (&$total): void { $total += $n; };
$add(5); // $total === 5
Замыкание и $this
PHP
classCart
{
privatearray$items = [];
publicfunctiontotals(): array
{
// замыкание внутри метода видит $this автоматическиreturnarray_map(fn(Item$i) => $i->price * $i->qty, $this->items);
}
}
// static-замыкание отказывается от $this: его нельзя привязать к объекту$pure = staticfn(int$a, int$b): int => $a + $b;
граблиСтрелочная функция захватывает значение, а не переменную
$fn = fn() => $i; внутри цикла запомнит текущее значение $i — и это обычно то, что нужно. Но если переменная меняется позже, замыкание об этом не узнает: для связи по ссылке нужна обычная форма с use (&$i).
11 шт.
опции: use (&$x) — по ссылке
$log = function (string$line) use ($file): void {
file_put_contents($file, $line, FILE_APPEND);
};
опции: вложенные fn тоже видят внешние переменные
usort($rows, fn($a, $b) => $a->at <=> $b->at);
опции: привязать к объекту нельзя
$compare = staticfn(int$a, int$b): int => $a <=> $b;
serialize() на объекте, у которого в свойстве лежит Closure, бросит исключение. Это всплывает там, где объекты кладут в кэш или в очередь: вместо замыкания в таких местах держат имя класса-обработчика или __invoke-объект.
Приватное — наружу, когда очень надо
PHP
// в тестах иногда нужно заглянуть внутрь объекта без geттера$read = function (string$prop) { return$this->$prop; };
$value = Closure::bind($read, $object, $object::class)('secret');
Generator
Генераторы
Функция, которая отдаёт значения по одному и останавливается между ними. Миллион строк проходит через память по одной, а не целиком.
Одно yield в теле превращает функцию в генератор: при вызове тело не выполняется, а возвращается объект Generator. Тело двигается только тогда, когда у генератора просят следующее значение, и замирает ровно на yield — со всеми локальными переменными.
Генератор не ускоряет обработку — он снимает потолок по памяти.
PHP
functionrows(string$path): Generator
{
$fh = fopen($path, 'r');
try {
while (($row = fgetcsv($fh)) !== false) {
yield$row; // здесь функция замирает до следующего шага
}
} finally {
fclose($fh); // finally отработает и при выходе из foreach через break
}
}
foreach (rows('orders.csv') as$i => $row) {
// $i — ключ: по умолчанию счётчик с нуля
}
Ключи, возврат и двусторонняя связь
PHP
functionpairs(): Generator
{
yield'id' => 1; // свой ключ$answer = yield'q' => 2; // yield как выражение: получает то, что передали в send()return'итог'; // доступно после обхода
}
$gen = pairs();
$gen->current(); // 2 — нет, сначала первое значение$gen->send('ответ'); // продолжает с места yield, возвращает следующее значение$gen->getReturn(); // 'итог' — только когда генератор дошёл до конца
граблиГенератор нельзя перемотать и пройти дважды
foreach по одному генератору второй раз бросит Exception: Cannot rewind a generator that was already run. Если результат нужен дважды — либо вызывайте функцию-генератор заново, либо соберите её в массив через iterator_to_array(), приняв расход памяти сознательно.
Вложенный генератор отдаёт свои ключи — и они спокойно совпадут с ключами внешнего. В foreach это незаметно, а вот iterator_to_array($gen) молча потеряет элементы с одинаковыми ключами: из четырёх значений останется два. Лечится вторым аргументом false.
советВозвращайте iterable, а принимайте тоже iterable
Функция, объявленная с iterable, одинаково принимает массив и генератор — и вызывающий код решает, что ему важнее: простота или память. Это позволяет перевести обработку на генераторы позже, не переписывая сигнатуры.
Fiber
Файберы
Кусок кода, который можно остановить на любой глубине вызовов и продолжить позже. Фундамент асинхронных библиотек, а не инструмент прикладного кода.
Файбер — это отдельный стек вызовов, которым управляют руками. Запустили, внутри дошли до Fiber::suspend() — выполнение вернулось туда, откуда запускали, вместе с переданным значением. Потом resume() — и файбер продолжил с той же строки.
Отличие от генератора одно, но решающее: генератор умеет останавливаться только в своём теле, а файбер — на любой глубине вложенных вызовов. Поэтому на файберах строят асинхронные библиотеки: код внутри выглядит обычным, синхронным, а ждёт он неблокирующе.
В один момент времени работает ровно один файбер: PHP по-прежнему однопоточен. Файберы переключаются только там, где кто-то явно вызвал suspend(), — это кооперативная многозадачность, а не потоки и не процессы.
граблиFiber::suspend() вне файбера — ошибка
Вызов снаружи бросает FiberError: Cannot suspend outside of a fiber. Поэтому библиотеки вроде Amp и ReactPHP прячут файберы внутри своего планировщика: прикладной код зовёт их await, а не Fiber::suspend() напрямую.
9 шт.
опции: создание ничего не выполняет
$fiber = newFiber(function (int$id) {
// тело выполнится только после start()
});
if ($fiber->isTerminated()) {
$result = $fiber->getReturn();
}
опции: взаимоисключающие
foreach ($fibersas$f) {
if ($f->isSuspended()) {
$f->resume();
}
}
опции: статический метод
if (Fiber::getCurrent() === null) {
thrownewLogicException('только внутри файбера');
}
опции: наследник Error
try {
Fiber::suspend();
} catch (FiberError$e) {
// Cannot suspend outside of a fiber
}
пример
new Fiber(callable $callback)8.1
Создаёт файбер, но не запускает его. В колбэк уйдут аргументы start().
опции: создание ничего не выполняет
$fiber = newFiber(function (int$id) {
// тело выполнится только после start()
});
Ничего не нашлось. Попробуйте другое слово.
Планировщик в двадцать строк
src/Async/scheduler.phpPHP
/** Крутит файберы по очереди, пока все не закончатся. */functionrun(callable ...$tasks): array
{
$fibers = array_map(staticfn(callable$t) => newFiber($t), $tasks);
$results = [];
foreach ($fibersas$i => $fiber) {
$fiber->start();
}
while ($fibers !== []) {
foreach ($fibersas$i => $fiber) {
if ($fiber->isTerminated()) {
$results[$i] = $fiber->getReturn();
unset($fibers[$i]);
continue;
}
$fiber->resume(); // здесь настоящая библиотека ждала бы готовности сокета
}
}
ksort($results);
return$results;
}
run(
function () { Fiber::suspend(); return'первая'; },
function () { Fiber::suspend(); return'вторая'; },
);
советВ прикладном коде файберы почти не пишут руками
Их место — внутри Amp, ReactPHP, Swoole и очередей задач. Оттуда они выглядят как обычные вызовы: $response = $client->request($url) не блокирует процесс, потому что под капотом стоит Fiber::suspend(). Знать про файберы полезно, чтобы понимать, почему такой код вообще работает.
вниманиеОбщее состояние остаётся общим
Файберы делят память процесса: статические свойства, контейнер, открытые соединения. Переключение происходит только на suspend(), так что гонок в привычном смысле нет, но состояние, оставленное посреди операции, увидят все остальные файберы.
class
Классы и объекты
Объявление, конструктор в одну строку, наследование и позднее статическое связывание — всё, из чего собирают объекты в PHP 8.
Класс описывает свойства и методы, объект — их экземпляр. С PHP 8.0 конструктор умеет объявлять свойства прямо в параметрах: одна строка вместо трёх — объявления, параметра и присваивания.
src/Order.phpPHP
declare(strict_types=1);
finalclassOrder// final: наследовать нельзя
{
publicconststringSTATUS_NEW = 'new'; // типизированная константа — с 8.3privatearray$items = [];
publicfunction__construct(
publicreadonlyint$id, // продвижение: свойство + параметр сразуprivateClock$clock = newSystemClock(), // new в умолчании — с 8.1
) {}
publicstaticfunctiondraft(int$id): self// именованный конструктор
{
returnnewself($id);
}
publicfunctionadd(Item$item): static// static: наследник вернёт себя
{
$this->items[] = $item;
return$this;
}
}
$order = Order::draft(7)->add($item);
$order::class; // 'Order' — с 8.0 работает и на объекте$orderinstanceofOrder;
Наследование и позднее связывание
PHP
abstractclassModel
{
publicstaticfunctioncreate(): static// static — «класс, у которого вызвали»
{
returnnewstatic(); // позднее статическое связывание
}
publicstaticfunctiontable(): string
{
returnstatic::TABLE; // возьмёт константу наследника
}
abstractpublicfunctionrules(): array; // наследник обязан реализовать
}
finalclassUserextendsModel
{
publicconstTABLE = 'users';
publicfunctionrules(): array
{
return [];
}
}
User::create(); // User, а не Model — из-за static вместо self
граблиself и static — не синонимы
self — это класс, в котором написана строка, static — класс, у которого вызвали метод. Фабрика с new self() в базовом классе всегда вернёт базовый класс, сколько бы наследников ни было. Это ломается тихо: тесты на базовом классе проходят.
Объект без имени
PHP
$logger = newclassimplementsLoggerInterface {
publicfunctionlog(string$line): void
{
error_log($line);
}
};
// анонимный класс с аргументами конструктора и родителем$stub = newclass($clock) extendsBaseRepo {
publicfunctionfind(int$id): ?User { returnnull; }
};
finalclassMoney
{
privatefunction__construct(
publicreadonlyint$amount, // в копейках: float для денег не годитсяpublicreadonlystring$currency,
) {}
publicstaticfunctionfromRubles(float$rub): self
{
returnnewself((int) round($rub * 100), 'RUB');
}
publicstaticfunctionzero(string$currency = 'RUB'): self
{
returnnewself(0, $currency);
}
}
Money::fromRubles(199.90);
советЗакрытый конструктор + статические фабрики
Приватный __construct() заставляет создавать объект через методы с именами: Money::fromRubles(), Money::zero(). Каждый путь создания виден в списке методов, а не прячется в наборе необязательных аргументов.
вниманиеНаследование — не способ переиспользовать код
Если у наследника «почти то же самое, только другой способ сохранения», это композиция: передайте объект, который умеет сохранять. Наследование оправдано там, где наследник действительно является родителем и его можно подставить в любой код, ожидающий базовый тип.
readonly · hooks
Свойства
Типизированные, неизменяемые, с раздельной видимостью на чтение и запись и с перехватом чтения и записи — свойство в PHP 8.4 умеет почти всё, что раньше делали геттеры.
У свойства есть тип, видимость и — с 8.4 — хуки: код, который выполняется при чтении и записи. Снаружи это по-прежнему обычное свойство: $user->email = $v вместо $user->setEmail($v), но с проверкой внутри.
src/User.phpPHP
declare(strict_types=1);
classUser
{
// вычисляемое свойство: хранилища нет, есть только чтениеpublicstring$fullName {
get => trim($this->first . ' ' . $this->last);
}
// обычное свойство с проверкой на записиpublicstring$email {
set (string$value) {
$value = mb_strtolower(trim($value));
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
thrownewInvalidArgumentException('не похоже на адрес почты');
}
$this->email = $value; // запись в само свойство, рекурсии нет
}
}
// читают все, пишет только класс — с 8.4publicprivate(set) int$version = 1;
publicfunction__construct(
publicstring$first,
publicstring$last,
string$email,
) {
$this->email = $email; // сеттер отработает и здесь
}
}
$u = newUser('Иван', 'Петров', ' IVAN@Example.COM ');
$u->fullName; // 'Иван Петров'$u->email; // 'ivan@example.com'$u->version = 5; // Error: Cannot modify private(set) property User::$version
Три состояния типизированного свойства
не инициализировано
тип есть, значения нет; чтение — Error: must not be accessed before initialization
со значением
обычная работа; тип проверяется при каждой записи
readonly — после записи
первая запись из класса разрешена, вторая — Error, даже из самого класса
граблиreadonly — не «константа объекта»
Ограничение только на переприсваивание свойства. Если в readonly-свойстве лежит массив, его нельзя изменить целиком, но объект внутри свойства менять можно сколько угодно: $this->logger->push(...) работает. Неизменяемость вглубь язык не обеспечивает.
Свойство, которое уже используют как $obj->title, можно превратить в вычисляемое или добавить к нему проверку, не меняя ни одной строки в вызывающем коде. До 8.4 ради такой возможности заранее прятали всё за геттеры — теперь это не нужно.
граблиХук set пишет в само свойство, а не в другое
Внутри set присваивание $this->email = $value не вызывает хук заново — оно пишет в хранилище свойства. А вот обращение к другому свойству с хуком сработает как обычно, со всеми его проверками.
Неизменяемость без километра конструктора
PHP
finalreadonlyclassFilter
{
publicfunction__construct(
public ?string$query = null,
public ?int$categoryId = null,
publicint$page = 1,
) {}
// до 8.5: перечисляем все поляpublicfunctionwithPage(int$page): self
{
returnnewself($this->query, $this->categoryId, $page);
}
}
interface
Интерфейсы
Договор без реализации: что объект умеет, не говоря ни слова о том, как. Половина возможностей языка — от foreach до json_encode — держится на встроенных интерфейсах.
Интерфейс перечисляет публичные методы, константы и — с 8.4 — свойства, которые обязан иметь класс. Класс реализует сколько угодно интерфейсов, и каждый из них становится его типом: по интерфейсу пишут объявления параметров, а значит, подменить реализацию можно не трогая вызывающий код.
src/Clock.phpPHP
declare(strict_types=1);
interfaceClock
{
publicfunctionnow(): DateTimeImmutable;
}
finalclassSystemClockimplementsClock
{
publicfunctionnow(): DateTimeImmutable
{
returnnewDateTimeImmutable();
}
}
finalclassFrozenClockimplementsClock
{
publicfunction__construct(privatereadonlyDateTimeImmutable$at) {}
publicfunctionnow(): DateTimeImmutable
{
return$this->at;
}
}
// код зависит от интерфейса — и поэтому тестируется без ожидания реального времениfinalclassSubscription
{
publicfunction__construct(privatereadonlyClock$clock) {}
publicfunctionisExpired(DateTimeImmutable$until): bool
{
return$this->clock->now() > $until;
}
}
Сужать можно, расширять нельзя
PHP
interfaceRepository
{
publicfunctionfind(int$id): ?Model;
}
finalclassUserRepositoryimplementsRepository
{
// возврат сузили: ?User вместо ?Model — так можно (ковариантность)publicfunctionfind(int$id): ?User
{
returnnull;
}
}
// а вот потребовать от аргумента больше, чем интерфейс, нельзя:// параметр можно только расширить (контравариантность)
советИнтерфейс описывает роль, а не класс
Хороший интерфейс называется по тому, что объект умеет: Clock, Mailer, PasswordHasher. Интерфейс UserServiceInterface с двадцатью методами, у которого одна реализация, — это не абстракция, а второй файл с тем же содержимым.
Встроенные интерфейсы, на которые опирается сам язык: реализуете — и объект начинает работать в foreach, count(), json_encode() и квадратных скобках.
Для $container['db'] ни IDE, ни анализатор не знают тип результата — там mixed. Поэтому современные контейнеры и конфиги дают типизированные методы (getString(), get(Db::class)), а скобки оставляют для совместимости.
trait
Трейты
Кусок класса, который можно вставить в несколько классов. Не наследование и не интерфейс: код копируется в класс на этапе компиляции.
Трейт вкладывается в класс так, будто его методы и свойства написаны прямо там. Отсюда все свойства трейтов: они видят $this, участвуют в разрешении конфликтов и не создают отдельного типа — по трейту нельзя объявить параметр.
PHP
traitTimestamps
{
public ?DateTimeImmutable$createdAt = null;
publicconststringFORMAT = 'Y-m-d H:i'; // константы в трейтах — с 8.2abstractpublicfunctionclock(): Clock; // требование к классу-хозяинуpublicfunctiontouch(): void
{
$this->createdAt ??= $this->clock()->now();
}
}
finalclassPost
{
useTimestamps;
publicfunction__construct(privatereadonlyClock$clock) {}
publicfunctionclock(): Clock
{
return$this->clock;
}
}
Post::FORMAT; // константа пришла из трейта$postinstanceofTimestamps; // ошибка: трейт — не тип
Конфликты имён
PHP
traitJson { publicfunctionrender(): string { return'{}'; } }
traitXml { publicfunctionrender(): string { return'<x/>'; } }
finalclassReport
{
useJson, Xml {
Json::render insteadofXml; // чей метод победитXml::render as renderXml; // второй доступен под другим именем
renderXml asprotected; // заодно можно сменить видимость
}
}
граблиСтатическое свойство трейта — своё у каждого класса
Трейт со static $count даст отдельный счётчик каждому использующему классу: это копия, а не общее хранилище. Ровно та же история со статическими переменными внутри методов трейта — общего состояния между классами не будет.
traitNeedsDeps
{
// вместо конструктора — требование к классу-хозяинуabstractprotectedfunctiondeps(): Deps;
}
пример
use в классе
Вставляет трейт. Методы трейта проигрывают методам самого класса и выигрывают у унаследованных от родителя.
опции: несколько трейтов через запятую
finalclassUser
{
useTimestamps, SoftDelete;
}
Ничего не нашлось. Попробуйте другое слово.
Хороший трейт — мелкий и без состояния
PHP
// трейт на одну механику: сравнение value-объектовtraitComparesByValue
{
publicfunctionequals(self$other): bool
{
return$this == $other; // == сравнит класс и свойства
}
}
// трейт на «умеет превращаться в массив»traitArrayableFromPublic
{
publicfunctiontoArray(): array
{
returnget_object_vars($this);
}
}
вниманиеТрейт с десятью методами и своим состоянием — это класс
Если трейт завёл свойства, конструирует объекты и обращается к базе, из него получится обычная зависимость: объект, который передают в конструктор. Такую зависимость видно в сигнатуре, её можно подменить в тесте — с трейтом не выйдет ни того, ни другого.
советТрейт не заменяет интерфейс
По трейту нельзя объявить тип, поэтому пара «интерфейс + трейт с реализацией по умолчанию» — обычная связка: интерфейс объявляет контракт и годится в объявлениях типов, трейт избавляет реализации от копипасты.
enum
Перечисления
Тип с заранее известным набором значений. Заменяет строковые константы там, где «статус» может быть только одним из четырёх.
Перечисление объявляет все свои значения сразу, и других быть не может. Каждый вариант — объект-одиночка, поэтому сравнивать их можно через ===, а объявление типа Status $status гарантирует, что внутрь не попадёт опечатка.
src/Order/Status.phpPHP
declare(strict_types=1);
enumStatus: string// привязанное: у каждого варианта есть скалярное значение
{
caseNew = 'new';
casePaid = 'paid';
caseShipped = 'shipped';
caseCancelled = 'cancelled';
publicfunctionlabel(): string// у перечислений бывают методы
{
returnmatch ($this) {
self::New => 'новый',
self::Paid => 'оплачен',
self::Shipped => 'отправлен',
self::Cancelled => 'отменён',
};
}
publicfunctionisFinal(): bool
{
returnin_array($this, [self::Shipped, self::Cancelled], true);
}
publicstaticfunctiondefault(): self
{
returnself::New;
}
}
Status::Paid->value; // 'paid' — то, что уходит в базуStatus::Paid->name; // 'Paid' — имя вариантаStatus::from('paid'); // Status::Paid, иначе ValueErrorStatus::tryFrom('nope'); // null вместо исключенияStatus::cases(); // все варианты списком
Два вида перечислений
чистое — `enum Status`
варианты без значений; хранить в базе напрямую нельзя, зато ничего лишнего
привязанное — `enum Status: string`
у каждого варианта есть value типа int или string: его и пишут в базу и JSON
`->name`
имя варианта, есть у обоих видов; менять его — ломающее изменение, как и имя класса
`->value`
только у привязанного; это и есть контракт с внешним миром
граблиВ перечислении не бывает состояния
Свойства объявлять нельзя: варианты — одиночки, и любое «состояние» стало бы глобальным. Если варианту нужны данные, их возвращает метод через match ($this) или они лежат в константе-таблице.
11 шт.
опции: реализует UnitEnum
enumDirection
{
caseAsc;
caseDesc;
}
опции: реализует BackedEnum
enumLevel: int
{
caseDebug = 100;
caseError = 400;
}
Если ветки покрывают все варианты, default не нужен — и это плюс: добавив новый вариант, вы получите UnhandledMatchError в первом же тесте, а не тихо уедете в ветку по умолчанию. С default новая карта состояний молча сломается.
вниманиеЗначение привязанного перечисления — часть контракта
value уезжает в базу, в JSON и в чужие системы. Менять его нельзя так же, как нельзя менять название колонки: понадобится миграция данных. Имя варианта (name) — контракт для кода.
__get() · __call()
Магические методы
Методы с двумя подчёркиваниями, которые PHP вызывает сам: при обращении к несуществующему свойству, при печати объекта, при клонировании и сериализации.
Магический метод — это запасной путь. PHP зовёт его, только когда обычный не сработал: __get() — если свойства нет или оно недоступно, __call() — если метода нет. Отсюда две особенности: магия не мешает обычным свойствам и методам, и она невидима для IDE, анализаторов и автодополнения.
PHP
finalclassBag
{
privatearray$data = [];
publicfunction__get(string$name): mixed
{
return$this->data[$name] ?? null;
}
publicfunction__set(string$name, mixed$value): void
{
$this->data[$name] = $value;
}
publicfunction__isset(string$name): bool
{
returnisset($this->data[$name]); // без него isset($bag->x) всегда false
}
publicfunction__unset(string$name): void
{
unset($this->data[$name]);
}
}
$bag = newBag();
$bag->title = 'Привет'; // __setisset($bag->title); // __isset — иначе false, даже когда значение есть$bag->title; // __get
грабли__get() без __isset() ломает isset() и ??
isset($obj->prop) и $obj->prop ?? 'default' не зовут __get() — они зовут __isset(). Класс, где объявлен только __get(), будет уверенно отвечать, что свойства нет, и ?? всегда подставит значение по умолчанию.
Цена магии
IDE и анализаторы
не знают, какие свойства есть; спасает только @property в PHPDoc
скорость
вызов метода вместо чтения поля; на горячем пути это заметно
ошибки
опечатка в имени не ошибка, а новое «свойство» со значением null
Вычисляемое свойство, проверка при записи, «только чтение снаружи» — всё это теперь делают хуки и асимметричная видимость. В отличие от магии они объявлены явно, типизированы и видны анализатору. Магию оставляют там, где набор свойств действительно неизвестен заранее: в обёртках над данными и в прокси.
вниманиеМагия и производительность
__get() — это полноценный вызов метода вместо чтения поля, а __call() ещё и собирает массив аргументов. На единичных обращениях разницы нет, но в цикле на сто тысяч итераций она становится главным пунктом профиля.
DateTimeImmutable
Дата и время
Один класс на всё: разбор, форматирование, арифметика и часовые пояса. Берите неизменяемую версию — изменяемая портит объекты, которые вы кому-то передали.
В PHP два почти одинаковых класса. DateTime меняет сам себя: $d->modify('+1 day') сдвигает объект, который вы, возможно, уже куда-то передали. DateTimeImmutable возвращает новый объект и ничего не портит — в новом коде используют только его.
PHP
$at = newDateTimeImmutable('2024-03-31 12:00', newDateTimeZone('Europe/Moscow'));
$at->format(DATE_ATOM); // '2024-03-31T12:00:00+03:00'$at->setTimezone(newDateTimeZone('UTC')); // тот же момент в другом поясе: 09:00$at->getTimestamp(); // секунды с 1970 года$tomorrow = $at->modify('+1 day'); // новый объект$at->format('d.m'); // 31.03 — исходный не изменился$start = newDateTimeImmutable('2024-01-01');
$end = newDateTimeImmutable('2024-03-01');
$diff = $start->diff($end); // DateInterval$diff->days; // 60 — всего дней$diff->m; // 2 — «месяцев и дней», а не месяцев всего$start < $end; // объекты дат сравниваются напрямую
Разбор строки
PHP
newDateTimeImmutable('2024-03-31'); // ISO — понимается всегдаnewDateTimeImmutable('now');
newDateTimeImmutable('first day of next month');
newDateTimeImmutable('@1700000000'); // из метки времени, пояс UTCDateTimeImmutable::createFromFormat('d.m.Y', '31.03.2024'); // свой форматDateTimeImmutable::createFromFormat('!d.m.Y', '31.03.2024'); // ! обнуляет времяDateTimeImmutable::createFromTimestamp(1700000000.5); // с 8.4, дробные секунды// разбор может не удаться — проверять обязательно$d = DateTimeImmutable::createFromFormat('!d.m.Y', $input);
if ($d === false) {
thrownewInvalidArgumentException('ожидался формат дд.мм.гггг');
}
граблиcreateFromFormat без «!» подставляет текущее время
Разбирая '31.03.2024' по формату 'd.m.Y', PHP возьмёт сегодняшние часы, минуты и секунды. Две такие даты никогда не окажутся равны, а сравнение «начало дня» будет работать через раз. Восклицательный знак в начале формата обнуляет всё, что в нём не указано.
грабли«+1 month» от 31 января — это 2 марта
PHP прибавляет месяц к номеру месяца, а потом нормализует несуществующую дату: 31 февраля превращается во 2 марта. Если нужен «последний день следующего месяца», так и пишут: modify('last day of next month').
16 шт.
опции: без пояса — date.timezone
newDateTimeImmutable('first day of next month midnight');
Разбирает строку в человеческом формате: ISO, now, +3 days, next monday, first day of next month.
опции: без пояса — date.timezone
newDateTimeImmutable('first day of next month midnight');
Ничего не нашлось. Попробуйте другое слово.
Часы как зависимость
src/Clock.phpPHP
interfaceClock// так же выглядит PSR-20
{
publicfunctionnow(): DateTimeImmutable;
}
finalclassSystemClockimplementsClock
{
publicfunction__construct(privatereadonlyDateTimeZone$tz = newDateTimeZone('UTC')) {}
publicfunctionnow(): DateTimeImmutable
{
returnnewDateTimeImmutable('now', $this->tz);
}
}
finalclassFrozenClockimplementsClock
{
publicfunction__construct(privateDateTimeImmutable$at) {}
publicfunctionnow(): DateTimeImmutable
{
return$this->at;
}
}
// в тесте «через 30 дней подписка истекла» больше не нужно ждать 30 дней
советХранить в UTC, показывать в поясе пользователя
В базе — момент времени в UTC, в интерфейсе — setTimezone() на пояс пользователя. Это избавляет от целого класса ошибок: переход на летнее время, переезд сервера, пользователи из разных стран и «час, который случился дважды».
Диапазон дат без дыр
PHP
$from = newDateTimeImmutable('2024-01-01');
$to = newDateTimeImmutable('2024-01-08');
$byDay = [];
foreach (newDatePeriod($from, newDateInterval('P1D'), $to) as$day) {
$byDay[$day->format('Y-m-d')] = 0; // заготовка с нулями
}
foreach ($rowsFromDbas$row) {
$byDay[$row['day']] = (int) $row['count']; // дни без данных останутся нулями
}
вниманиеСравнивать даты строками можно только в ISO
'2024-03-31' < '2024-04-01' работает, потому что порядок символов совпадает с порядком дат. Стоит формату стать d.m.Y — и сравнение начинает врать. Сравнивайте объекты: у DateTimeImmutable операторы <, >, == делают ровно то, что нужно.
json_encode()
JSON и сериализация
Две функции с десятком флагов и одна привычка: всегда включать исключения, иначе ошибка разбора выглядит как «пришёл null».
json_encode() превращает значение в строку, json_decode() — обратно. Без флага JSON_THROW_ON_ERROR обе при ошибке возвращают false или null и оставляют код ошибки в глобальном состоянии — это главный источник молчаливых потерь данных.
PHP
$json = json_encode($data, JSON_THROW_ON_ERROR
| JSON_UNESCAPED_UNICODE// «привет», а не при...
| JSON_UNESCAPED_SLASHES// http://a/b вместо http:\/\/a\/b
| JSON_PRETTY_PRINT); // с отступами: для логов и файлов$data = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR);
// без ассоциативного режима объекты становятся stdClass$obj = json_decode('{"a":1}'); // $obj->a$arr = json_decode('{"a":1}', true); // $arr['a']json_validate($json); // с 8.3: проверить, не разбирая
Что уедет в JSON от объекта
PHP
finalclassOrderimplementsJsonSerializable
{
publicfunction__construct(
publicreadonlyint$id,
privatereadonlystring$secret,
publicreadonlyDateTimeImmutable$at,
) {}
publicfunctionjsonSerialize(): array
{
return [
'id' => $this->id,
'at' => $this->at->format(DATE_ATOM), // иначе выедет структура DateTime
];
}
}
json_encode(newOrder(1, 'токен', newDateTimeImmutable()));
// {"id":1,"at":"2024-03-31T12:00:00+03:00"} — приватное поле не попало
граблиПустой массив в JSON — это [], а не {}
PHP не различает список и словарь, а JSON различает. Массив [] закодируется как [], массив с дырами в ключах — как объект. Если API обещает объект, приводите явно: (object) $map или JSON_FORCE_OBJECT; если массив — закрывайте цепочку array_values().
советФлаги задаются один раз, а не в каждом вызове
Набор флагов — это решение уровня проекта: кодировка, экранирование, точность чисел. Когда он повторяется в тридцати местах, рано или поздно где-то забудут JSON_THROW_ON_ERROR — и получат null вместо данных без единого сообщения.
вниманиеunserialize() на данных пользователя — дыра
Разбор сериализованной строки создаёт объекты и вызывает их магические методы — этого достаточно для запуска чужого кода в вашем процессе. Для данных извне только JSON. Если формат PHP всё же нужен, обязателен второй аргумент ['allowed_classes' => false].
preg_match()
Регулярные выражения
Движок PCRE, десять функций и один модификатор, без которого кириллица ломается. Плюс понимание, где регулярное выражение — не тот инструмент.
Шаблон — это строка, внутри которой сам шаблон обёрнут разделителями, а после закрывающего разделителя идут модификаторы. Для текста на кириллице модификатор u обязателен: без него движок работает с байтами, и . съедает половину буквы.
PHP
// / шаблон / модификаторы$re = '/^(?<year>\d{4})-(\d{2})$/u';
if (preg_match($re, '2024-03', $m)) {
$m[0]; // '2024-03' — всё совпадение$m[1]; // '2024' — первая группа$m['year']; // '2024' — она же по имени$m[2]; // '03'
}
preg_match_all('/\d+/u', 'a1 b22 c333', $all); // $all[0] === ['1', '22', '333']preg_replace('/\s+/u', ' ', $text); // схлопнуть пробелыpreg_split('/[\s,]+/u', $line); // разбить по пробелам и запятымpreg_quote($userInput, '/'); // экранировать пользовательский ввод
Модификаторы, которые действительно нужны
u
UTF-8: шаблон и строка — символы, а не байты. Для текста обязателен
i
без учёта регистра; с u работает и для кириллицы
m
^ и $ совпадают с началом и концом каждой строки, а не всего текста
s
. начинает совпадать и с переводом строки
x
пробелы и переводы строк в шаблоне игнорируются — можно писать с отступами и комментариями
граблиБез модификатора u кириллица — это байты
preg_match('/^.{3}$/', 'абв') не совпадёт: в строке шесть байтов. А \w без u не считает кириллицу буквой. Добавили u — и строка обязана быть корректным UTF-8, иначе функция вернёт false, а preg_last_error_msg() скажет почему.
Шаблоны вида (\w+\s?)+$ на длинной строке без совпадения заставляют движок перебирать экспоненциальное число вариантов: запрос висит, процессор занят. Лечится конкретикой вместо вложенных квантификаторов и ограничением длины входа до проверки шаблоном.
советПроверять HTML регулярным выражением не нужно
Для HTML есть DOMDocument и Dom\HTMLDocument (8.4), для CSV — fgetcsv(), для почты и URL — filter_var(), для JSON — json_decode(). Регулярное выражение хорошо там, где формат простой и строгий: номер телефона, код купона, строка лога.
fopen() · php://
Файлы и потоки
Файл, сетевое соединение, память и стандартный ввод — для PHP это один и тот же поток, который открывают одной функцией.
Всё, что читается и пишется, в PHP — поток. Путь при открытии может быть обычным файлом, адресом https://, псевдофайлом php://memory или чем-то, что зарегистрировала библиотека. Поэтому функции работы с файлами одинаково читают и локальный CSV, и тело HTTP-запроса.
PHP
// целиком — когда файл заведомо небольшой$text = file_get_contents($path);
file_put_contents($path, $text, LOCK_EX);
file_put_contents($log, $line . PHP_EOL, FILE_APPEND | LOCK_EX);
// по частям — когда размер неизвестен$fh = fopen($path, 'rb');
try {
while (($line = fgets($fh)) !== false) {
// ...
}
} finally {
fclose($fh);
}
// псевдопотоки$body = file_get_contents('php://input'); // сырое тело запроса$buffer = fopen('php://temp', 'r+'); // память, с переездом на диск после 2 МБ$stdout = fopen('php://stdout', 'w');
Режимы открытия
'r' / 'r+'
чтение / чтение и запись; файл должен существовать, указатель в начале
'w' / 'w+'
запись; файл создаётся или обрезается до нуля
'a' / 'a+'
дозапись в конец; существующее содержимое остаётся
'x' / 'x+'
создать и писать; если файл уже есть — ошибка. Так делают файлы-замки
'c' / 'c+'
открыть или создать без обрезания; подходит для flock() перед записью
'b'
двоичный режим: добавляйте всегда, иначе Windows подменяет переводы строк
граблиРежим 'w' стирает файл в момент открытия
Не при записи, а именно при fopen(). Если дальше код упадёт с исключением, на диске останется пустой файл вместо прежних данных. Для безопасной перезаписи пишут во временный файл и затем переименовывают.
functionwriteAtomic(string$path, string$data): void
{
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
$fh = fopen($tmp, 'wb');
if ($fh === false) {
thrownewRuntimeException("не открылся временный файл: $tmp");
}
try {
if (fwrite($fh, $data) === false) {
thrownewRuntimeException("не записалось: $tmp");
}
fsync($fh); // данные действительно на диске
} finally {
fclose($fh);
}
if (!rename($tmp, $path)) { // переименование в пределах ФС атомарноunlink($tmp);
thrownewRuntimeException("не переименовался: $tmp");
}
}
советЗагруженные файлы проверяют по содержимому
$_FILES['f']['type'] приходит от браузера и подделывается тривиально, расширение — тем более. Реальный тип даёт finfo, а сам файл берут через move_uploaded_file() — она проверяет, что файл действительно загружен, а не подсунут путём.
Набор готовых классов на случаи, где массив уже мешает: очередь, стек, куча, множество объектов и обход дерева каталогов.
Массив PHP умеет почти всё — и поэтому не говорит о намерении. SplQueue в коде заявляет «это очередь», SplObjectStorage — «множество объектов», WeakMap — «данные, которые исчезнут вместе с объектом». Плюс часть структур экономит память там, где массив её не жалеет.
PHP
// множество объектов: ключ — сам объект, а не строка$seen = newSplObjectStorage();
$seen->attach($user);
$seen->contains($user); // true$seen[$user] = ['at' => time()]; // к объекту можно привязать данныеcount($seen);
// слабая карта: запись исчезает, когда объект больше никому не нужен$cache = newWeakMap();
$cache[$entity] = $heavyComputation; // утечки памяти не будет// очередь и стек с понятным названием$queue = newSplQueue();
$queue->enqueue($job);
$job = $queue->dequeue();
// массив фиксированной длины: меньше памяти на больших объёмах$points = newSplFixedArray(1_000_000);
советWeakMap — правильный кэш «по объекту»
Обычный массив с ключом spl_object_id($obj) держит запись вечно, даже когда объект давно не нужен, — и в долгоживущем процессе это утечка. WeakMap (с 8.0) удаляет запись автоматически, как только исчезла последняя ссылка на ключ.
Каждый элемент SplDoublyLinkedList — объект со своими накладными расходами, и на небольших объёмах обычный массив и быстрее, и экономнее. Берите SPL ради смысла («это очередь», «это множество объектов») или ради конкретной возможности, которой у массива нет.
#[Attribute]
Атрибуты и рефлексия
Метаданные, которые пишутся в коде и читаются программой. То, ради чего раньше разбирали PHPDoc-комментарии регулярными выражениями.
Атрибут — это объявление, которое компилятор сохраняет рядом с элементом кода, но сам не выполняет. Прочитать его можно только рефлексией, и только там он превращается в объект своего класса.
src/Routing/Route.phpPHP
declare(strict_types=1);
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]finalclassRoute
{
publicfunction__construct(
publicreadonlystring$path,
publicreadonlystring$method = 'GET',
) {}
}
finalclassOrderController
{
#[Route('/orders', method: 'GET')]#[Route('/orders/list')]// повторяемый — можно несколькоpublicfunctionindex(): Response { /* ... */ }
}
// читаем: без этого шага атрибут не делает ничего$rc = newReflectionClass(OrderController::class);
foreach ($rc->getMethods() as$method) {
foreach ($method->getAttributes(Route::class) as$attr) {
$route = $attr->newInstance(); // вот здесь создаётся объект Route$router->add($route->method, $route->path, [$rc->getName(), $method->getName()]);
}
}
советАтрибут сам по себе ничего не делает
Ни валидация, ни маршрут, ни кэш не появятся оттого, что вы написали #[Route]. Работает код, который читает атрибуты рефлексией. Именно поэтому фреймворки кэшируют результат чтения: рефлексия на каждом запросе — заметная трата.
Куда можно ставить
TARGET_CLASS
классы, интерфейсы, трейты, перечисления
TARGET_METHOD / TARGET_FUNCTION
методы и функции
TARGET_PROPERTY / TARGET_PARAMETER
свойства и параметры — на них держится валидация и внедрение зависимостей
TARGET_CLASS_CONSTANT
константы класса; с 8.5 — и обычные константы
TARGET_ALL | IS_REPEATABLE
везде; и разрешить несколько одинаковых на одном элементе
Сначала атрибуты, встроенные в сам PHP, затем рефлексия — то, чем их читают.
Разбор атрибутов у сотни классов на каждом запросе съедает больше, чем сама работа. Правило простое: рефлексия выполняется один раз, результат складывается в массив или файл (var_export() + require), а в рантайме читается уже он.
советАтрибуты не заменяют конфигурацию
Маршруты и правила валидации рядом с кодом читаются лучше. А вот настройки окружения, подключения и списки сервисов в атрибуты не помещаются: их меняют без правки кода. Обычно проект использует и то, и другое, и это нормально.
composer.json
Composer
Менеджер зависимостей, который заодно раздаёт автозагрузку. Два файла: чего мы хотим и что в итоге установилось.
composer.json описывает пожелания: какие пакеты нужны и в каких пределах версий. composer.lock фиксирует результат: конкретные версии с контрольными суммами. На сервере выполняется composer install, который ставит ровно то, что в lock-файле, и поэтому у всех одинаковый набор.
от 3.2 до 4.0 — разрешает минорные и патчи. Обычный выбор для библиотек
~3.2
от 3.2 до 3.3 — только патчи. Строже, чем ^
~3.2.1
от 3.2.1 до 3.3 — тоже только патчи, но с точкой отсчёта
3.*
любая версия ветки 3
>=3.2 <4.0
явный диапазон; пишут, когда нужны нестандартные границы
dev-main
ветка вместо версии — только для своих пакетов и временных правок
граблиcomposer.lock нужно коммитить
Без него composer install на сервере и в CI поставит другие версии, чем у вас на машине: диапазон ^3.0 со временем начинает означать другое. Правило: composer.lock в репозитории, vendor/ — нет.
Команды, которые нужны каждый день, и поля файла, которые действительно настраивают.
composer check # то же самое запускает и CI, и разработчикcomposer install --no-dev--optimize-autoloader# сборка для прода
советОбновляйтесь малыми шагами
composer update всего сразу превращает обновление в отдельный проект с непонятным сроком. Раз в пару недель composer outdated --direct, затем обновление одного-двух пакетов с прогоном тестов — и версия зависимостей никогда не становится проблемой.
вниманиеПакет — это чужой код в вашем процессе
Он выполняется с теми же правами, читает ту же память и попадает в ваш деплой. Перед установкой стоит посмотреть на число поддерживающих, дату последнего релиза и объём: зависимость ради одной функции на двадцать строк почти никогда не окупается.
psr/log · psr/http
PSR — общие интерфейсы
Договорённости PHP-FIG: как оформлять код и какими интерфейсами обмениваться, чтобы библиотеки разных авторов подходили друг к другу.
PSR — это рекомендации группы PHP-FIG. Часть из них про оформление кода, часть — готовые пакеты с интерфейсами: psr/log, psr/container, psr/http-message. Второе важнее первого: библиотека, которая принимает LoggerInterface, работает с любым логгером, а не только с тем, о котором знал автор.
PHP
usePsr\Log\LoggerInterface;
usePsr\Log\NullLogger;
finalclassPaymentService
{
publicfunction__construct(
privatereadonlyLoggerInterface$log = newNullLogger(),
) {}
publicfunctionpay(Order$order): void
{
$this->log->info('оплата начата', ['order' => $order->id]);
}
}
// в проекте сюда попадёт Monolog, в тесте — свой заглушечный логгер:// классу всё равно, он знает только интерфейс
советИнтерфейс из пакета — это не зависимость от фреймворка
psr/log — три файла без единой строки реализации. Пакет, который принимает LoggerInterface, не тянет за собой Monolog и вообще ничего не навязывает. Именно поэтому такие интерфейсы стали общим языком экосистемы.
Принятые стандарты, которые встречаются в реальных проектах. Номера идут по времени принятия, поэтому соседние номера редко связаны по смыслу.
Базовые правила и подробный стиль оформления: отступы, переносы, порядок модификаторов. Проверяются и исправляются автоматически.
опции: php-cs-fixer, PHP_CodeSniffer
vendor/bin/php-cs-fixer fix --rules=@PSR12
Ничего не нашлось. Попробуйте другое слово.
Зависимость от интерфейса, а не от библиотеки
PHP
// было: класс намертво связан с конкретным логгером и клиентомfinalclassNotifier
{
publicfunctionsend(string$to): void
{
(newMonolog\Logger('app'))->info('шлём ' . $to);
(newGuzzleHttp\Client())->post('https://api.example.com/send');
}
}
// стало: подменяется в тесте, не тянет лишнего в composer.jsonfinalclassNotifier
{
publicfunction__construct(
privatereadonlyClientInterface$http, // PSR-18privatereadonlyRequestFactoryInterface$requests, // PSR-17privatereadonlyLoggerInterface$log = newNullLogger(), // PSR-3
) {}
}
вниманиеPSR — не всегда лучший выбор
PSR-7 с его неизменяемыми объектами многословен, PSR-6 сложнее, чем нужно половине проектов. Смысл стандартов — в совместимости: они нужны там, где код должен подойти чужому приложению. Во внутреннем сервисе свой маленький интерфейс часто честнее.
phpstan · phpunit
Инструменты
Пять инструментов, которые ловят ошибки до продакшена: статический анализ, тесты, единый стиль, автоматический рефакторинг и отладчик.
PHP не компилируется заранее, поэтому «синтаксически верно» не значит «работает». Разницу закрывают инструменты: статический анализатор читает код и находит то, что упадёт при выполнении, тесты проверяют поведение, форматтер убирает споры о стиле из обсуждений.
только синтаксис; бесплатно и мгновенно — годится как самый первый шаг в CI
PHPStan / Psalm
типы, недостижимый код, обращения к несуществующим методам — без запуска
PHPUnit
поведение: что код действительно делает с этими данными
php-cs-fixer
оформление; спорить о скобках больше не нужно
Rector
массовые правки: миграция на новую версию PHP, замена устаревшего
Xdebug
пошаговая отладка и покрытие тестами; на проде — выключен
советСтатический анализ начинают не с максимума
Поставьте уровень, на котором ошибок немного, зафиксируйте текущие проблемы в baseline (phpstan analyse --generate-baseline) и поднимайте планку по одному уровню. Так анализатор начинает приносить пользу с первого дня, а не «когда-нибудь потом».
Если проверки запускаются одной командой (composer check), никто не узнаёт о нарушении стиля из красной сборки через десять минут. Полезно и в git-хуке перед коммитом — но только быстрые проверки, иначе его отключат.
вниманиеИнструменты не заменяют ревью
Анализатор не скажет, что метод называется неверно, абстракция протекает, а вместо трёх классов хватило бы функции. Он снимает механическую часть — и именно поэтому на ревью остаётся время для содержательной.
PHP_VERSION
Версии 8.0–8.5
Что появилось в каждом выпуске восьмёрки, что из старого перестало работать и как переезжать, не устраивая проект на полгода.
Выпуски PHP выходят раз в год, в конце ноября. Каждая версия получает два года активной поддержки и ещё два — только исправления безопасности. Отсюда практическое правило: отставание больше двух версий означает, что дыры в вашем PHP уже никто не чинит.
что даёт версия
граблиУстаревшее — это не «сломается завтра»
E_DEPRECATED ничего не ломает сегодня, но обещает ошибку в следующей крупной версии. Проект, где в логе тысячи deprecated-сообщений, обновляется рывком и с болью; проект, где их ноль, переезжает за вечер.
Все заметные возможности восьмёрки в одном списке — с версией, начиная с которой их можно писать. Фильтр понимает и номер версии: наберите «8.3», чтобы увидеть только её.
Поднимите "php": "^8.4" в composer.json и выполните composer update — часть пакетов обновится сама.
Прогоните тесты на новой версии с error_reporting(E_ALL) и включённым логированием: устаревшее проявится сразу.
Соберите deprecated-сообщения из логов — это и есть список работ.
Пропустите Rector с набором для целевой версии (withPhpSets) и просмотрите изменения глазами.
Поднимите уровень PHPStan: на новой версии он видит больше.
Выкатывайте на часть трафика, если есть такая возможность: часть несовместимостей вылезает только на реальных данных.
Проверка версии в коде
PHP
// сравнение строк версий работает, но PHP_VERSION_ID быстрее и надёжнееif (PHP_VERSION_ID < 80300) {
fwrite(STDERR, "нужен PHP 8.3 или новее, а тут " . PHP_VERSION . "\n");
exit(1);
}
// возможность лучше проверять по её наличию, а не по номеру версииif (function_exists('json_validate')) {
$ok = json_validate($raw);
} else {
json_decode($raw);
$ok = json_last_error() === JSON_ERROR_NONE;
}
советМинимальную версию задаёт composer.json, а не сервер
Строка "php": "^8.3" в require не даст установить проект туда, где стоит 8.1, и заставит Composer подбирать совместимые версии пакетов. А config.platform фиксирует версию, под которую собираются зависимости, даже если локально у вас новее.
вниманиеНовый синтаксис нельзя закрыть полифилом
Функцию можно дописать самому, а |>, хуки свойств и readonly — нет: файл с ними не разберётся на старой версии вовсе, даже если эта ветка кода никогда не выполняется. Поэтому в библиотеках, поддерживающих несколько версий, новый синтаксис появляется только со сменой мажорной версии пакета.