PHP 8.6: что нового — частичное применение, clamp() и Duration

PHP 8.6: что нового — частичное применение, clamp() и Duration
содержание

Введение

После 8.5 в PHP выходит очередная минорная версия — 8.6, и эта статья продолжает обзор PHP 8.5. Финальный релиз (GA) назначен на 19 ноября 2026 года. На момент написания доступен RC2 от 24 сентября, а по графику впереди ещё три кандидата: RC3 (8 октября), RC4 (22 октября) и RC5 (5 ноября).

Новых возможностей больше не добавят: RFC нужно было влить до 11 августа, а 22 сентября наступил hard feature freeze. Поэтому уже сейчас можно разобрать версию целиком — новые возможности с примерами, изменения поведения, которые затронут код без единой правки, устаревания и то, что в 8.6 так и не попало. Примеры проверены на php:8.6-rc-cli (8.6.0RC2).

Частичное применение функций

Самое заметное нововведение — partial function application, PFA (частичное применение функций). Функцию вызывают с частью аргументов, а на месте недостающих оставляют плейсхолдер:

  • ? — один аргумент, который подставят позже;
  • ... — все оставшиеся аргументы разом.

На выходе получается Closure, которому остаётся передать только пропущенное. Знакомый по PHP 8.1 синтаксис first-class callable strlen(...) теперь выглядит как частный случай PFA.

$titles = ['hello world', 'hello php'];

// до 8.6: fn(string $s) => str_replace('hello', 'hi', $s)
$result = array_map(str_replace('hello', 'hi', ?), $titles);
// ['hi world', 'hi php']

PFA легко спутать со стрелочной функцией, но разница между ними существенная. Во-первых, заранее переданные аргументы PFA вычисляет один раз — когда создаётся замыкание, а стрелочная функция пересчитывает их на каждом вызове. Во-вторых, у замыкания остаются имена и типы параметров исходной функции: это видно через Reflection и позволяет вызывать его с именованными аргументами.

function discount(int $price, int $percent): int
{
    return $price - intdiv($price * $percent, 100);
}

$pfa = discount(?, currentPercent());               // currentPercent() выполнится один раз
$arrow = fn(int $price) => discount($price, currentPercent()); // на каждом вызове

Если функция вызывается в цикле, а аргумент дорогой (запрос к БД, чтение конфига), это разница между одним вычислением и тысячей. Обратная сторона та же: если значение должно быть свежим на каждом вызове, нужна стрелочная функция.

С необязательными параметрами действует простое правило: там, где стоит ?, параметр становится обязательным, даже если у него было значение по умолчанию. Именованный плейсхолдер вроде separator: ? ведёт себя так же. Сохранить необязательность позволяет только ....

function makeSlug(string $title, string $separator = '-', int $maxLength = 60): string
{
    return substr(strtolower(str_replace(' ', $separator, $title)), 0, $maxLength);
}

$slug = makeSlug(?, ?);
$slug('New Order');        // ArgumentCountError: ожидается 2 аргумента
$slug('New Order', '_');   // 'new_order'

$slug = makeSlug(?, ...);
$slug('Big Sale');         // 'big-sale', $separator по-прежнему необязателен

PFA как колбэк в коллекциях Laravel

Здесь PFA помогает обойти давнюю ловушку first-class callable. Метод map() у коллекций Laravel передаёт в колбэк два аргумента — значение и ключ. Функция, переданная как round(...), принимает оба, и ключ молча оказывается во втором параметре. Замыкание PFA без ... лишние аргументы просто отбрасывает:

$prices = collect([1.2345, 2.3456, 3.4567]);

$prices->map(round(...))->all();  // [1.0, 2.3, 3.46] — ключ стал $precision
$prices->map(round(?, 2))->all(); // [1.23, 2.35, 3.46] — ключ отброшен

Ошибка коварна тем, что исключения нет: на первом элементе ключ равен 0, и результат выглядит правдоподобно. Та же история с intval(...) (ключ превращается в основание системы счисления), trim(...) (ключ становится списком удаляемых символов) и любыми функциями с необязательными параметрами. Для колбэков надёжнее ?, чем ....

Связка с оператором |>

Полностью PFA раскрывается вместе с оператором |> из PHP 8.5. Раньше функцию с несколькими аргументами в пайпе приходилось оборачивать в стрелочную функцию, да ещё и брать её в скобки. В 8.6 хватает плейсхолдера:

$slug = '  New Order  '
    |> trim(...)
    |> strtolower(...)
    |> str_replace(' ', '-', ?);   // 'new-order'

Накладных расходов при этом почти нет: если в вызове ровно один ?, оптимизатор OPcache (при включённом OPcache) заменяет такой шаг пайпа прямым вызовом функции, без создания замыкания. Кроме того, PFA разрешено в константных выражениях — это продолжение того, что в PHP 8.5 появилось для замыканий и first-class callables в константных выражениях. Например, значением свойства по умолчанию может быть private Closure $toInt = array_map(intval(?), ?);.

Функция clamp()

Задача «зажать значение в диапазон» встречается постоянно, и теперь для неё есть встроенная функция:

clamp(mixed $value, mixed $min, mixed $max): mixed

Значение внутри диапазона возвращается без изменений, вышедшее за него заменяется ближайшей границей. Сравнение подчиняется обычным правилам PHP, так что кроме чисел подходят строки и любые другие сравнимые значения. При $min > $max или границе, равной NAN, функция бросает ValueError.

$percent = clamp($request->integer('discount'), 0, 100);  // скидка от 0 до 100 %
$page = clamp($request->integer('page'), 1, $lastPage);    // номер страницы не выходит за пагинацию
$perPage = clamp($request->integer('per_page', 15), 10, 100);

clamp('m', 'a', 'f');  // 'f' — строки сравниваются лексикографически
clamp(5, 10, 1);       // ValueError: $min больше $max

Привычная запись max($min, min($max, $value)) работает, но читается с усилием, и min с max в ней легко поменять местами — такая ошибка незаметна до первого граничного значения. У clamp() порядок аргументов говорит сам за себя.

Одна тонкость: типы функция не приводит. Если значение вышло за диапазон, вернётся граница — вместе со своим типом. В итоге clamp(1.5, 0, 1) даёт int(1), а clamp('150', 0, 100) — int(100), тогда как строка '50' внутри диапазона останется строкой. Когда результат записывается в типизированное свойство или сериализуется в JSON, границы лучше передавать того же типа, что и само значение.

Класс Time\Duration

Расширение date получило класс Time\Duration — длительность с наносекундной точностью, объявленную как final readonly, то есть неизменяемую. Конструктор закрыт, объекты создаются фабричными методами: fromSeconds() (наносекунды можно передать вторым аргументом), fromMilliseconds(), fromMicroseconds(), fromNanoseconds(), fromMinutes(), fromHours() и fromIso8601DurationString().

use Time\Duration;

$timeout = Duration::fromSeconds(30);
$sla = Duration::fromIso8601DurationString('PT1H30M');  // полтора часа
$spent = Duration::fromMilliseconds(1500);

$total = $sla->add(Duration::fromMinutes(15))->multiplyBy(2);  // возвращается новый объект
$half = $sla->divideBy(2);                                      // 45 минут

var_dump($sla > Duration::fromHours(1));   // true — операторы сравнения работают
var_dump(Duration::compare($timeout, $spent));  // 1

$diff = $spent->sub($timeout);  // длительность со знаком «минус»
$diff->seconds;       // 28 — без знака
$diff->nanoseconds;   // 500000000
$diff->negative;      // true
$diff->absolute();    // те же 28,5 секунды, но положительные

Помимо арифметики доступны negate() и absolute(). Состояние объекта описывают три свойства — $seconds, $nanoseconds и $negative; знак вынесен в отдельный флаг, поэтому $seconds никогда не бывает отрицательным. Ошибка разбора строки приводит к Time\TimeException, деление на ноль — к DivisionByZeroError.

Путать Duration с DateInterval не стоит: это разные сущности. DateInterval — календарный интервал вроде «1 месяц», чья реальная длина зависит от того, от какой даты его отсчитывать; к тому же он изменяем и не умеет складываться и делиться. Duration — фиксированное количество времени. Отсюда и ограничение fromIso8601DurationString(): принимается только временная часть (PT…), а на P1D или P1M метод ответит TimeException. Форматирования в классе пока нет, так что вывод в человекочитаемом виде придётся реализовать самому.

Readonly-свойства со значениями по умолчанию

До 8.6 у readonly-свойства не могло быть значения по умолчанию, и даже неизменную по смыслу константу приходилось присваивать в конструкторе. Теперь это разрешено. Больше всего выигрывают классы, реализующие свойства интерфейса из PHP 8.4:

interface HasCurrency
{
    public string $currency { get; }
}

final class Price implements HasCurrency
{
    public readonly string $currency = 'RUB';

    public function __construct(public readonly int $amount) {}
}

echo new Price(1500)->currency;  // RUB

Здесь легко ошибиться. Свойство со значением по умолчанию считается уже инициализированным, поэтому записать в него что-то другое не получится нигде, в том числе в конструкторе, — PHP выбросит Error: Cannot modify readonly property. Если значение по умолчанию нужно иногда переопределять при создании объекта, подходит прежний вариант: параметр конструктора (constructor property promotion) со значением по умолчанию.

Enum SortDirection и Laravel

В глобальном пространстве имён появился enum SortDirection с двумя кейсами — Ascending и Descending. Скалярных значений у него нет (unbacked enum): его задача — стать общим типом для направления сортировки вместо строк 'asc'/'desc' и параметров bool $descending. Функции ядра вроде sort() enum пока не принимают, это оставлено на будущее.

А вот Laravel его уже понимает. Начиная с 13.8.0 orderBy() и reorder() принимают SortDirection наравне со строками 'asc'/'desc', а orderedChunkById() и orderedLazyById() — вместо булева флага $descending. Методы orderByDesc(), latest() и oldest() направление не принимают, но внутри тоже перешли на enum. На PHP ниже 8.6 enum берётся из пакета symfony/polyfill-php86, который фреймворк подтягивает как зависимость.

use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\View\View;

final class OrderController
{
    public function index(Request $request): View
    {
        $direction = $request->boolean('oldest_first')
            ? SortDirection::Ascending
            : SortDirection::Descending;

        $orders = Order::query()
            ->where('status', 'paid')
            ->orderBy('created_at', $direction)  // раньше: 'asc' / 'desc'
            ->paginate(20);

        return view('orders.index', compact('orders'));
    }
}

Выигрыш — в проверке типов. Опечатка 'dsc' в строке всплывёт только при выполнении запроса, а несуществующий SortDirection::Dsc подсветят IDE и статический анализатор ещё до запуска.

Мелкие, но полезные изменения

  • Атрибут #[\Override] можно ставить на константы классов и кейсы enum. Если у родителя или интерфейса такой константы нет, код не скомпилируется.
  • В enum разрешён метод __debugInfo() — он управляет тем, что покажет var_dump().
  • Объект, сохранённый в константе, теперь изменяется напрямую: CONFIG->env = 'dev'.
  • Новая INI-настройка error_include_args дописывает аргументы функции в сообщения об ошибках. По умолчанию она выключена, а параметры с #[\SensitiveParameter] в вывод не попадают.
  • json_last_error_msg() и текст JsonException показывают, где именно ошибка: Syntax error near location 1:10.
  • В Reflection добавлены ReflectionProperty::isReadable()/isWritable() и ReflectionParameter::getDocComment() — докблоки параметров больше не теряются.
  • Производительность: вызов array_map() с first-class callable или PFA-колбэком компилируется в обычный foreach, промежуточное замыкание не создаётся. Статические замыкания, не захватывающие переменных, теперь кэшируются — явный static fn начинает окупаться.
  • Прочее одной строкой: grapheme_strrev(), mysqli_quote_string(), модификаторы порядка байтов </> в pack()/unpack(), Poll API (Io\Poll\*), обработка ошибок потоков (StreamException, stream_last_errors()), билдеры Uri\Rfc3986\UriBuilder и Uri\WhatWg\UrlBuilder для URI-расширения из 8.5.

Что изменится без правок кода

Где Было Стало в 8.6
session.use_strict_mode 0 1 — неизвестные ID сессий отклоняются
session.cookie_httponly 0 1 — cookie недоступна из JavaScript
session.cookie_samesite не задано Lax
trim(), ltrim(), rtrim() \f не удалялся form feed \f удаляется по умолчанию
array_filter() с неверным $mode молча фильтровал по значению ValueError
preg_grep() при ошибке PCRE частичный массив false, как у других preg_*

К этому стоит добавить редкий случай: foreach по ссылке после уплотнения массива может пройти по элементам, которые раньше пропускал.

Laravel-приложений новые значения session.* практически не касаются. Фреймворк не вызывает session_start(): данные сессии хранит Illuminate\Session\Store, а cookie выставляет middleware StartSession, опираясь на config/session.php (http_only, same_site). Изменения почувствует только код на нативных сессиях — старые скрипты рядом с приложением или сторонние библиотеки.

Устаревания

Устаревшие конструкции продолжают работать, но выдают E_DEPRECATED. Главное из списка:

Что устарело Чем заменить
return в блоке finally вернуть значение после try/finally
возврат значения из __construct()/__destruct() убрать return $value
is_double(), is_long(), is_integer() is_float(), is_int()
doubleval() floatval()
strcoll(), флаг SORT_LOCALE_STRING Collator::compare(), Collator::sort()/asort()
metaphone() библиотека фонетического сравнения
spl_object_hash() spl_object_id()
ArrayIterator::getFlags(), setFlags(), asort(), ksort(), uasort(), uksort(), natsort(), natcasesort(), serialize(), unserialize() сортировать массив до создания итератора или использовать ArrayObject, у которого методы остаются; для сериализации — функции serialize()/unserialize()
SplFileObject::fgetcsv(), fputcsv(), setCsvControl(), getCsvControl() функции fgetcsv()/fputcsv() с дескриптором из fopen() и явным аргументом $escape
объект в http_build_query() http_build_query(get_object_vars($obj))
третий аргумент define() убрать аргумент
mbregex (mb_ereg*()), Oniguruma больше не поддерживается preg_*() с модификатором u

Из более редкого: функция с именем readonly, namespace в роли имени константы класса, is_a()/is_subclass_of() со строкой при $allow_string = false, ReflectionMethod::invoke() с объектом для статического метода, spl_classes(), mysqli_get_charset()/mysqli_stmt_init(), объекты в array_walk().

Собственные обработчики сессий в Laravel

Если класс реализует SessionHandlerInterface, но не содержит методов create_sid() и validateId() (и не реализует SessionIdInterface и SessionUpdateTimestampHandlerInterface), PHP теперь сообщает об этом уже при объявлении класса: в PHP 9.0 оба метода станут обязательными. Встроенные обработчики Laravel получили их в 13.31.0 и 13.32.0 (PR #61469 и #61517). Собственные — например, для хранения сессий в отдельной базе — придётся дополнить вручную:

use Illuminate\Support\Str;

final class TenantSessionHandler implements SessionHandlerInterface
{
    // open(), close(), read(), write(), destroy(), gc() — без изменений

    public function create_sid(): string
    {
        return Str::random(40);  // как в Illuminate\Session\Store
    }

    public function validateId(string $id): bool
    {
        $data = $this->read($id);

        return is_string($data) && $data !== '';  // read() может вернуть false
    }
}

Откладывать это не стоит. UPGRADING относит изменение к устареваниям, но в 8.6.0RC2 уведомление приходит с уровнем E_WARNING, а не E_DEPRECATED. Laravel превращает предупреждения в ErrorException, так что без этих методов запрос упадёт при первой же загрузке класса обработчика, а не оставит тихую запись в логе deprecations. До финального релиза уровень могут понизить, но рассчитывать на это не стоит: методы всё равно понадобятся для PHP 9.0.

А вот list() остаётся в языке: RFC о его устаревании не прошёл — 23 голоса «за» и 23 «против» при необходимых двух третях.

Что не вошло в PHP 8.6

В ранних обзорах встречаются возможности, которых в 8.6 не будет:

RFC Статус
Pipe Assignment Operator (|>=) отклонён
Bound-Erased Generic Types (дженерики) отклонён
array_only_keys()/array_except_keys() отклонён
array_path_get() отклонён
магический метод __exists() отклонён
Time\Instant и Time\Clock на обсуждении, не успели
флаг PREG_THROW_ON_ERROR на обсуждении, не успели
автозагрузка функций на обсуждении, не успели
устаревание «нечётких» приведений ((int) "123abc") на обсуждении, не успели
асинхронность в ядре на обсуждении, не успели

RFC, которые не успели довести до голосования, могут попасть в следующую версию. Time\Instant и Time\Clock стали бы естественным дополнением к Duration, но пока это только предложения.

Как попробовать и что проверить перед обновлением

Удобнее всего запускать релиз-кандидат в Docker — локальная установка PHP при этом не затрагивается:

docker run --rm -i php:8.6-rc-cli php -v
docker run --rm -v "$PWD":/app -w /app php:8.6-rc-cli php -l app/Models/Order.php

# ищем устаревшие функции в своём коде и зависимостях
grep -rnE "spl_object_hash|is_double|is_long|is_integer|doubleval|strcoll|SORT_LOCALE_STRING" app/ vendor/
grep -rln "implements.*SessionHandlerInterface" app/ vendor/

План проверки:

  1. Прогнать тесты на RC с error_reporting=E_ALL — так видны все E_DEPRECATED.
  2. Поискать устаревшие функции в app/ и vendor/; особенно часто в пакетах встречается spl_object_hash().
  3. Проверить собственные обработчики сессий и код, работающий с нативными сессиями.
  4. Обновить Laravel до 13.32 или новее — в этих версиях есть поддержка SortDirection и недостающие методы встроенных обработчиков сессий.

Итог

  • PHP 8.6 выходит 19 ноября 2026 года, набор возможностей окончательный.
  • Можно использовать сразу: clamp() вместо max(min()), плейсхолдеры ? в array_map() и пайпах, SortDirection в orderBy() на Laravel 13.8+, Time\Duration для таймаутов и SLA.
  • Проверить до обновления: устаревшие функции в коде и vendor/, собственные обработчики сессий, return внутри finally.
  • Дженериков, |>= и Time\Clock в 8.6 не будет, а list() устаревшим не объявлен.

Похожие