PHP 8.2: что нового — readonly-классы, DNF-типы и расширение Random

PHP 8.2: что нового — readonly-классы, DNF-типы и расширение Random
содержание

Введение

PHP 8.2 вышел 8 декабря 2022 года. Из крупного в нём появились readonly-классы, DNF-типы, отдельные типы null, false и true, расширение Random и константы в трейтах. Но при обновлении с 8.1 больше хлопот доставляет другое: версия пометила устаревшими динамические свойства и интерполяцию ${}, и лог быстро заполняется предупреждениями.

Для проектов, которые до сих пор работают на 8.2, важнее всего сроки. Активная поддержка версии закончилась 31 декабря 2024 года, а исправления безопасности выходят до 31 декабря 2026 года. Дальше разобраны нововведения, изменения, которые ломают код при переходе с 8.1, и выбор целевой версии PHP с учётом требований Laravel 12 и 13.

Readonly-классы

В PHP 8.1 readonly ставился только на отдельное свойство. В DTO (объекте передачи данных), где неизменяемы все поля, модификатор приходилось писать в каждой строке конструктора.

PHP < 8.2

enum OrderStatus: string
{
    case New = 'new';
    case Paid = 'paid';
}

final class OrderData
{
    public function __construct(
        public readonly int $id,
        public readonly OrderStatus $status,
        public readonly float $total,
    ) {}
}

PHP 8.2

final readonly class OrderData
{
    public function __construct(
        public int $id,
        public OrderStatus $status,
        public float $total,
    ) {}
}

$order = new OrderData(1, OrderStatus::New, 1500.0);
$order->total = 0; // Error: Cannot modify readonly property OrderData::$total

В 8.2 достаточно одного readonly перед class — он распространяется на все свойства. Взамен класс получает несколько ограничений:

  • у каждого свойства должен быть тип — public $amount; вызовет ошибку компиляции;
  • статические свойства запрещены;
  • динамические свойства создать нельзя, а атрибут #[\AllowDynamicProperties] к такому классу не применяется;
  • readonly-класс может наследовать только readonly-класс, и его наследник тоже обязан быть readonly.
readonly class Price
{
    public function __construct(public int $amount) {}
}

// class DiscountPrice extends Price {} // Fatal error: Non-readonly class DiscountPrice cannot extend readonly class Price

$price = new Price(100);
$price->currency = 'RUB';            // Error: Cannot create dynamic property Price::$currency

DNF-типы

В PHP 8.1 уже были объединения типов (A|B) и пересечения (A&B), но в одной декларации их нельзя было смешивать. Параметр «объект с двумя интерфейсами или null» приходилось проверять вручную.

DNF (Disjunctive Normal Form, дизъюнктивная нормальная форма) снимает это ограничение: пересечения можно перечислять через |. Правило одно — каждое пересечение внутри объединения заключается в скобки.

PHP < 8.2

final class OrderItemsPrinter
{
    public function print(mixed $items): void
    {
        // тип приходится проверять вручную
        if ($items !== null && !($items instanceof Countable && $items instanceof Traversable)) {
            throw new InvalidArgumentException('Ожидается коллекция позиций или null');
        }

        echo $items === null ? 0 : count($items), PHP_EOL;
    }
}

PHP 8.2

final class OrderItemsPrinter
{
    // объект, который одновременно Countable и Traversable, либо null
    public function print((Countable&Traversable)|null $items): void
    {
        echo $items === null ? 0 : count($items), PHP_EOL;
    }
}

(new OrderItemsPrinter())->print(new ArrayIterator(['iPhone', 'Чехол'])); // 2
(new OrderItemsPrinter())->print(null);                                   // 0
(new OrderItemsPrinter())->print([1, 2]);                                 // TypeError

Скобки обязательны: если написать Countable&Traversable|null, файл не пройдёт разбор.

null, false и true как самостоятельные типы

Раньше null и false можно было указать лишь внутри объединения (?array, string|false), а типа true не было совсем. В 8.2 все три пишутся как самостоятельные типы. Пригождается это в двух случаях: когда реализация интерфейса или наследник сужает тип возврата и когда функция всегда возвращает одно и то же значение.

PHP < 8.2

interface ProductRepository
{
    public function isArchived(): bool;
    public function findDeleted(): ?array;
}

// function isArchived(): false {} // Fatal error: False can not be used as a standalone type

PHP 8.2

final class InMemoryProductRepository implements ProductRepository
{
    // сужение типа возврата в реализации — допустимо
    public function isArchived(): false
    {
        return false;
    }

    public function findDeleted(): null
    {
        return null;
    }
}

function warmUpCache(): true
{
    // ...
    return true;
}

Объединить их в false|true нельзя — для этого есть bool.

Расширение Random

У rand(), mt_rand() и shuffle() один общий генератор Mersenne Twister на весь процесс. Если какая-нибудь библиотека вызовет mt_srand(), «случайные» значения во всём остальном коде станут предсказуемыми, и найти причину будет непросто.

Расширение random решает это объектным API. Состояние генератора хранится в объекте-движке (engine), а готовые операции — число из диапазона, байты, перемешивание — предоставляет класс Random\Randomizer.

use Random\Engine\Mt19937;
use Random\Randomizer;

// по умолчанию используется криптостойкий движок Random\Engine\Secure
$randomizer = new Randomizer();

$pinCode = $randomizer->getInt(1000, 9999);                  // код подтверждения
$promoCode = strtoupper(bin2hex($randomizer->getBytes(4))); // например, 9F04C1A2

// с зерном последовательность повторяется — удобно в тестах и сидерах
$seeded = new Randomizer(new Mt19937(42));
$winners = $seeded->pickArrayKeys(['anna' => 1, 'boris' => 2, 'vera' => 3], 2);
$queue = $seeded->shuffleArray([101, 102, 103, 104]); // id заказов

Кроме Secure (CSPRNG, криптостойкий генератор) доступны Mt19937, PcgOneseq128XslRr64 и Xoshiro256StarStar. Для токенов, паролей и кодов подтверждения подходит только Secure, остальные нужны там, где важна повторяемость. Рандомизаторы с одинаковым движком и зерном дают одну и ту же последовательность, так что упавший тест с тестовыми данными легко воспроизвести. У двух последних движков есть ещё jump(): он переносит состояние далеко вперёд, и из одного зерна получается несколько независимых потоков — например, по одному на Fiber или воркер.

Константы в трейтах

В 8.1 константа в трейте вызывала ошибку компиляции, поэтому общие для трейта значения выносили в интерфейс или прописывали прямо в методах.

PHP < 8.2

trait HasOrderLimits
{
    public const MAX_ITEMS = 50; // Fatal error: Traits cannot have constants
}

PHP 8.2

trait HasOrderLimits
{
    public const MAX_ITEMS = 50;

    public function canAddItem(int $count): bool
    {
        return $count < self::MAX_ITEMS;
    }
}

final class Cart
{
    use HasOrderLimits;
}

echo Cart::MAX_ITEMS;              // 50
echo HasOrderLimits::MAX_ITEMS;    // Error: Cannot access trait constant HasOrderLimits::MAX_ITEMS directly

Константа принадлежит классу, в который подключён трейт. Через имя самого трейта к ней не обратиться — только через класс или через self:: в методах.

Устаревшие динамические свойства

PHP создаёт свойство на лету, если присвоить объекту поле, которого нет в объявлении класса. Чаще всего за этим стоит опечатка или забытое поле, поэтому с 8.2 такое присваивание выдаёт Deprecated.

class Customer
{
    public string $name = '';
}

$customer = new Customer();
$customer->phone = '+7 900 000-00-00';
// PHP 8.1: работает молча
// PHP 8.2: Deprecated: Creation of dynamic property Customer::$phone is deprecated

$row = new stdClass();
$row->phone = '+7 900 000-00-00'; // stdClass — без предупреждений

На stdClass и классы с магическим методом __set() предупреждение не распространяется. В остальных случаях есть три выхода. Лучший — объявить свойство. Для старого кода, который пока нельзя переделать, подойдёт атрибут #[\AllowDynamicProperties] (его наследуют и дочерние классы). А если нужно прикрепить данные к объекту чужого класса, их можно хранить в WeakMap.

class Customer
{
    public string $name = '';
    public ?string $phone = null; // свойство объявлено
}

#[\AllowDynamicProperties]
class LegacyCustomer
{
    public string $name = '';
}

$legacy = new LegacyCustomer();
$legacy->phone = '+7 900 000-00-00'; // без предупреждения

Предупреждение — только первый шаг: согласно RFC, в PHP 9.0 такая запись будет выбрасывать Error. Когда выйдет PHP 9, пока неизвестно. Найти проблемные места проще всего по логу deprecation-предупреждений на тестах или стейджинге, а недостающие свойства массово объявляет Rector.

Атрибут #[\SensitiveParameter]

В трассировку стека попадают аргументы вызовов, а вместе с ней — в логи и системы мониторинга. Параметр с атрибутом #[\SensitiveParameter] в трассировке показывается как объект SensitiveParameterValue, а не как значение.

function login(string $email, #[\SensitiveParameter] string $password): void
{
    throw new RuntimeException('Сервис авторизации недоступен');
}

try {
    login('admin@example.com', 'secret-123');
} catch (RuntimeException $e) {
    echo $e->getTraceAsString();
    // #0 ...: login('admin@example.c...', Object(SensitiveParameterValue))
}

Новые функции и методы

  • mysqli_execute_query() и mysqli::execute_query() готовят запрос, привязывают параметры и выполняют его за один вызов.
  • memory_reset_peak_usage() обнуляет пиковое потребление памяти — пригодится для замеров в долгоживущих воркерах.
  • ini_parse_quantity() превращает значения вида 128M, полученные из ini_get(), в число байт.
  • curl_upkeep() поддерживает открытое соединение cURL и проверяет его.
  • openssl_cipher_key_length() сообщает длину ключа для заданного шифра.
  • sodium_crypto_stream_xchacha20_xor_ic() и libxml_get_external_entity_loader().
  • ReflectionFunction::isAnonymous() и ReflectionMethod::hasPrototype() в Reflection.
  • ZipArchive::getStreamIndex(), ZipArchive::getStreamName() и ZipArchive::clearError().
  • Атрибуты #[\AllowDynamicProperties] и #[\SensitiveParameter].

Устаревания и несовместимые изменения

Устаревший синтаксис пока работает и лишь пишет Deprecated в лог, так что обновление он не блокирует. Исправить его всё равно стоит заранее: в PHP 9 эти конструкции планируют убрать.

$name = 'Анна';

echo "Здравствуйте, ${name}";  // Deprecated: Using ${var} in strings is deprecated
echo "Здравствуйте, {$name}";  // рекомендуемая запись

$latin1 = utf8_decode('Café');                                // Deprecated
$latin1 = mb_convert_encoding('Café', 'ISO-8859-1', 'UTF-8'); // замена

echo strtolower('ЗАКАЗ');     // 'ЗАКАЗ' — кириллица не меняется
echo mb_strtolower('ЗАКАЗ');  // 'заказ'

Устарело:

  • интерполяция "${var}" и "${expr}" — удаление запланировано в PHP 9; замена — "$var", "{$var}" и "{${expr}}";
  • utf8_encode() и utf8_decode() — замена mb_convert_encoding();
  • callable, которые понимает call_user_func(), но не синтаксис $callable(): "self::method", "parent::method", "static::method", ["self", "method"], ["parent", "method"], ["static", "method"], ["Foo", "Bar::method"], [new Foo, "Bar::method"]. Обычные "Order::method" и [Order::class, "method"] не затронуты;
  • внутренний метод SplFileInfo::_bad_state_ex();
  • кодировки QPrint, Base64, Uuencode и HTML-ENTITIES в функциях mbstring.
class OrderExporter
{
    public function exportAll(array $orders): array
    {
        $old = array_map('self::formatRow', $orders);     // Deprecated: Use of "self" in callables is deprecated
        $new = array_map(self::formatRow(...), $orders);  // работает, PHP 8.1+

        return $new;
    }

    private static function formatRow(array $order): string
    {
        return $order['id'] . ';' . $order['total'];
    }
}

Опаснее изменения поведения — они не выдают предупреждений, код просто начинает работать иначе:

  • strtolower(), strtoupper() и родственные функции (stripos(), ucfirst(), str_ireplace() и др.) перестали учитывать локаль и меняют регистр только у ASCII-символов. Если код рассчитывал на setlocale() с однобайтовой кодировкой вроде CP1251, теперь нужны mb_*-функции;
  • str_split('') возвращает пустой массив, а не [''];
  • ksort() и krsort() с SORT_REGULAR сравнивают числовые строки по правилам PHP 8;
  • DateTime::createFromImmutable() и DateTimeImmutable::createFromMutable() получили предварительный (tentative) тип возврата static;
  • SplFileObject::hasChildren() и SplFileObject::getChildren() получили предварительные типы возврата false и null; ряд методов SplFileObject (fgetc(), ftell(), fflush() и др.) теперь строго проверяет сигнатуру;
  • расширения ODBC и PDO_ODBC экранируют имя пользователя и пароль в строке подключения.

Сроки поддержки: что делать проектам на PHP 8.2

С 2024 года у каждой версии PHP одинаковый цикл: два года активной поддержки (исправления ошибок и безопасности), затем два года только исправлений безопасности; сроки заканчиваются 31 декабря. У Laravel для каждого мажорного релиза своя минимальная версия PHP, поэтому обновлять язык и фреймворк приходится согласованно.

Версия Релиз Исправления ошибок до Исправления безопасности до Версии PHP
PHP 8.2 08.12.2022 31.12.2024 31.12.2026 —
PHP 8.3 23.11.2023 31.12.2025 31.12.2027 —
PHP 8.4 21.11.2024 31.12.2026 31.12.2028 —
PHP 8.5 20.11.2025 31.12.2027 31.12.2029 —
Laravel 12 24.02.2025 13.08.2026 24.02.2027 8.2–8.5
Laravel 13 17.03.2026 III квартал 2027 17.03.2028 8.3–8.5

Выход PHP 8.6 запланирован на 19 ноября 2026 года; сроки его поддержки появятся на php.net после релиза. Что войдёт в версию — в обзоре PHP 8.6.

Выводы из таблицы:

  • с 1 января 2027 года найденные в PHP 8.2 уязвимости закрывать не будут, поэтому переезд нужно закончить до 31.12.2026;
  • Laravel 12 уже получает только исправления безопасности (до 24.02.2027), а Laravel 13 на PHP 8.2 не встанет — ему нужен 8.3 или новее. Значит, на связке «PHP 8.2 + Laravel 12» сначала обновляется PHP и лишь потом фреймворк;
  • PHP 8.3 — самый короткий шаг, но активная поддержка у него уже закончилась, а у PHP 8.4 закончится 31.12.2026. Для новых установок и больших переездов разумнее сразу выбрать PHP 8.5.

Прежде чем трогать сервер, полезно выяснить, какие зависимости не пускают на новую версию, и проверить код в контейнере с нужным PHP:

# какие пакеты из composer.lock не совместимы с PHP 8.5
composer why-not php 8.5

# синтаксическая проверка кода на PHP 8.5
docker run --rm -v "$PWD":/app -w /app php:8.5-cli \
  sh -c "find app config routes -name '*.php' -print0 | xargs -0 -n1 php -l"

Затем тесты прогоняются на новой версии с включёнными E_DEPRECATED — так видно и ошибки, и то, что сломается в PHP 9. После этого в composer.json обновляются require.php и config.platform.php. Свежие версии PHP удобно запускать в контейнере — например, на FrankenPHP.

Что появилось после 8.2

Итог

  • Нововведения PHP 8.2: readonly-классы, DNF-типы, типы null/false/true, расширение Random, константы в трейтах и #[\SensitiveParameter].
  • Большинство предупреждений после перехода с 8.1 дают динамические свойства, интерполяция ${}, utf8_encode()/utf8_decode() и callable вида "self::method"; без предупреждений меняется поведение strtolower() и родственных функций.
  • Исправления безопасности для 8.2 выходят до 31.12.2026. Обновляться лучше сразу на PHP 8.5, минимально допустимый шаг — 8.3.
  • Laravel 13 на PHP 8.2 не устанавливается, поэтому порядок такой: сначала PHP, затем фреймворк.

Похожие