Новое в журнале. Маленькие pull request: почему их быстрее ревьюят и как резать задачу
PHP и бэкенд

Ловушки миграции со старого PHP: от 7.2 до безопасного перехода на 8.x

Миграция с PHP 7.2 на современные версии ломается не на крупных изменениях синтаксиса, а на мелочах: count() на не-countable, удалённых функциях, тихо изменившемся поведении each() и сравнений.

13 января 202610 мин чтенияПрактикам
Ловушки миграции со старого PHP: от 7.2 до безопасного перехода на 8.x
4 релизамежду 7.2 и 8.4
1 инструментdeprecation-сканер перед апгрейдом

Проекты, которые пережили PHP 7.2 и до сих пор не обновились до актуальной версии, рано или поздно сталкиваются с принудительной миграцией: хостинг прекращает поддержку старого runtime, или зависимость требует PHP 8.x. Переход кажется формальностью, но именно мелкие изменения поведения, не крупные, обрушивают продакшен в первый же день.

Сложность миграции редко пропорциональна размеру изменений в официальном списке breaking changes. Один абзац changelog про изменённое правило сравнения строки и числа может означать несколько дней поиска мест, где старое поведение использовалось намеренно, а не случайно, и где новое поведение тихо меняет результат проверки без единой ошибки в логе.

Прежде чем переходить к конкретным ловушкам, полезно выделить саму идею: большинство проблем при обновлении PHP возникают не из-за намеренного использования устаревших функций, а из-за кода, который случайно оказался в серой зоне между «технически работает» и «написано правильно». Новая версия языка просто перестаёт закрывать глаза на эту серую зону.

Count() и потеря молчаливой терпимости

В PHP 7.2 функция count() на значении, которое не является массивом или объектом, реализующим Countable, выдавала предупреждение и возвращала единицу или ноль. Начиная с PHP 8.0 это уже TypeError. Код вида count($maybeArray), где переменная иногда оказывается null из-за неудачного запроса к базе, в старых версиях просто предупреждал в лог, а на 8.x останавливает выполнение запроса целиком.

// Было безопасно 'по случайности' на 7.2, упадёт на 8.x
function itemsCount($items): int
{
    return count($items);
}

// Правильная защита независимо от версии
function itemsCount(?array $items): int
{
    return count($items ?? []);
}
Главная мысльСамые дорогие баги при миграции PHP не в новом синтаксисе, а в старом коде, который раньше работал по случайности, а не по контракту.

Удалённые функции each() и create_function()

Поиск вызовов удалённых функций редко решается одним простым grep по кодовой базе: та же each() могла попасть в проект не напрямую, а через стороннюю зависимость, которую давно не обновляли. Перед обновлением версии PHP стоит отдельно прогнать composer-зависимости через их собственные changelog или хотя бы проверить минимально поддерживаемую версию PHP в их composer.json.

Функция each(), устаревшая ещё в 7.2, была окончательно удалена в PHP 8.0. Код, написанный в стиле ручного обхода массива через while (list($key, $value) = each($array)), встречается в легаси чаще, чем ожидается, особенно во внутренних библиотеках без активной поддержки. Замена простая: foreach справляется с той же задачей без побочных эффектов на внутренний указатель массива.

// Удалено начиная с PHP 8.0
while (list($key, $value) = each($array)) {
    echo "{$key}: {$value}\n";
}

// Современная замена
foreach ($array as $key => $value) {
    echo "{$key}: {$value}\n";
}

Изменения в сравнении строк и чисел

PHP 8.0 изменил правило сравнения строки с числом: если строка не является числовой, число приводится к строке для сравнения, а не наоборот. Это закрывает классическую уязвимость вида 0 == 'abc', которая раньше возвращала true, а на новых версиях возвращает false. Код, который опирался на старое поведение для «мягкой» проверки входных данных, начинает вести себя иначе без единой ошибки в логе, просто с другим результатом.

СравнениеPHP 7.2 и нижеPHP 8.0 и выше
0 == 'abc'truefalse
'1' == '01'truetrue (не изменилось)
'10' == '1e1'truetrue (не изменилось)
Вы в начале разбораДальше: разбор компромиссов, чек-лист и ответы на частые вопросы.

Объектный тип и слишком широкие параметры

Введённый в 7.2 тип параметра и возврата object разрешает передать экземпляр любого класса. Это удобно для действительно универсальных операций, например кеширования произвольных объектов, но в бизнес-логике превращается в источник путаницы: функция, принимающая object, ничего не говорит о том, какой именно объект ожидается, и ошибка обнаруживается только при обращении к несуществующему свойству во время выполнения, а не на этапе проверки типов.

При миграции стоит искать параметры типа object и mixed, оставшиеся из эпохи «лишь бы прошло без ошибки», и сужать их до конкретных классов или интерфейсов, пока код всё равно трогают.

Похожая осторожность нужна и с устаревшими директивами конфигурации php.ini: часть из них, например связанные со старым обработчиком ошибок или магическими кавычками, была удалена задолго до 8.x, но иногда до сих пор встречается в закомментированных конфигах или в скриптах развёртывания, скопированных с очень старого сервера без проверки актуальности.

Параметр типа string по умолчанию и неявные приведения

Передача null в параметр с нестрогим скалярным типом, например string $value, молча работала в PHP 7.x и ранних версиях 8.x с предупреждением об устаревании, а начиная с PHP 8.1 требует явного объявления ?string, если null действительно допустим. Код, который раньше полагался на неявное приведение null к пустой строке, нужно просматривать отдельно: часть случаев это баг, который маскировался приведением типа, а часть, действительно допустимый null, который нужно объявить честно.

Эта группа изменений особенно болезненна для кода, который вызывает функции встроенной библиотеки PHP с потенциально null-значением из базы данных: раньше интерпретатор тихо приводил null к ожидаемому скалярному типу, а начиная с восьмой версии такие вызовы при включённом strict_types завершаются ошибкой, которую проще найти заранее через статический анализатор, чем через падение в продакшене.

Порядок безопасной миграции

Миграция сразу на последнюю версию PHP с 7.2 рискованна из-за накопленного числа изменений поведения. Надёжнее двигаться по промежуточным версиям, на каждом шаге включая логирование deprecation-предупреждений в продакшене, а не только в разработке, потому что именно нетипичные входные данные из реального трафика вскрывают большинство проблем.

  1. Поднять локальное окружение на целевой версии PHP и прогнать полный набор тестов, если он есть
  2. Включить вывод deprecation-предупреждений в лог на стейджинге под реальной копией трафика
  3. Пройтись статическим анализатором, например PHPStan, по кодовой базе на новом уровне строгости
  4. Отдельно проверить места с count(), each(), object-типами и сравнением строк с числами
  5. Выкатывать миграцию отдельным релизом без смешивания с новыми фичами, чтобы откат был простым

Большая часть боли при переходе со старых версий PHP не в новом синтаксисе, который легко освоить за один вечер, а в коде, который раньше работал благодаря терпимым правилам языка, а не благодаря собственной корректности. Чем явнее типы и чем меньше кода опирается на неявные приведения, тем спокойнее проходит следующая миграция.

После завершения миграции полезно оставить в проекте список изменений поведения, которые реально проявились на этом переходе, с краткой пометкой, где и почему. При следующем крупном обновлении PHP, а они будут происходить регулярно, эта заметка сэкономит команде время, которое иначе снова ушло бы на самостоятельный поиск одних и тех же граблей.

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

Если такой зависимости найти замену не удалось, разумный промежуточный шаг: изолировать её за собственным тонким интерфейсом внутри проекта, чтобы при следующей попытке обновления менять реализацию за этим интерфейсом, а не переписывать все места вызова библиотеки по всему приложению одновременно. Этот же интерфейс заодно упрощает написание тестов, которые раньше зависели от конкретной сторонней реализации напрямую, и позволяет подменить саму зависимость без правок в остальной кодовой базе проекта.

Частые вопросы

Можно ли мигрировать сразу с PHP 7.2 на 8.4, пропуская промежуточные версии?

Технически можно, но рискованно: накопленных изменений поведения становится больше, и сложнее понять, какое именно изменение сломало конкретное место. Надёжнее проверять код на промежуточных версиях.

Что чаще всего ломается при обновлении PHP на легаси-проектах?

Не крупный синтаксис, а мелкое поведение: count() на значениях, которые не являются массивом, удалённая функция each(), изменённые правила сравнения строки и числа.

Как заранее найти проблемные места перед обновлением версии?

Включить логирование deprecation-предупреждений на стейджинге с реальной копией трафика и прогнать статический анализатор на уровне строгости, близком к целевой версии PHP.

Код, который не стыдно передать

Начните с принципов, а дальше двигайтесь по рубрикам. Каждый разбор заканчивается чек-листом.

Случайный разбор