- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You bump the Docker base image to php:8.5-fpm, run the test suite, and the log fills up. Most of it is Deprecated: noise you can defer. Some of it is not noise. A handful of PHP 8.5 changes shift behavior silently or turn into hard errors, and those are the ones that reach production before anyone reads the migration guide.
PHP 8.5 shipped in November 2025. It is a smaller release than 8.4, but it does something 8.4 mostly didn't: it starts tightening the screws ahead of PHP 9.0. Everything deprecated in 8.5 still runs. It runs while shouting. The point of reading this now is to know which shouts are warnings you triage later and which are behavior changes you fix before the deploy.
The upgrade math for 8.5
A deprecation in 8.5 is a promise about 9.0. The behavior still works, you get a Deprecated notice, and the code keeps running. If your logging swallows E_DEPRECATED, you'll never see it until 9.0 removes the feature and your app fatals.
So the triage is two-bucket. Bucket one: deprecations. Loud, harmless today, fatal later. Fix on your own schedule. Bucket two: behavior shifts and removed features. These change output or throw right now, on 8.5, with no grace period. That's the bucket that decides whether the deploy is safe.
Start by making the notices visible instead of hiding them:
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
Run your full suite once with that, capture the deprecation log, and sort it. The rest of this post is what shows up in that log, in the order I'd act on it.
Behavior shifts that change output silently
These are bucket two. No deprecation notice you can ignore. The code does something different than it did on 8.4.
Casting non-representable floats to int now warns. If a float or float-like string can't be represented as an int, the cast still happens but emits a warning. NAN cast to any type warns too. Money code that does (int) $someFloat on an overflowing value is the usual suspect.
$big = 1.0e20;
$n = (int) $big; // Warning: implicit conversion,
// value not representable
Destructuring a non-array now warns. Pulling a value out of a string or scalar with list() or [] used to fail quietly. Now it tells you.
[$a] = "string"; // Warning: cannot destructure
// a non-array value
Loose comparison of uncomparable objects is consistent now. Enums, CurlHandle, and other internal objects compared loosely to booleans now follow (bool) $object semantics in both directions. Older code that relied on $enum == $otherEnum returning false by accident can flip. If you compare enums, use === and stop guessing.
PDO FETCH_CLASS constructor args follow CUFA semantics. Constructor arguments passed with PDO::FETCH_CLASS now behave like call_user_func_array(): string keys act as named arguments, and the old automatic by-value-to-by-ref wrapping is gone. If you hydrate entities through FETCH_CLASS with a positional args array that happened to have string keys, check it. Calling setFetchMode() mid-fetch now throws Error outright.
printf treats a missing precision as 0. The printf family previously reset a missing precision incorrectly. If you format with a trailing . and no digits, the output width can change. Snapshot tests on generated strings catch this; nothing else will.
Deprecations you'll actually hit
Bucket one. These fire a notice on 8.5 and become fatal in 9.0. Worth clearing while the change is a one-liner.
The backtick operator is deprecated. The shell-exec shorthand goes away. Replace it with the explicit call.
$out = `ls -la`; // deprecated in 8.5
$out = shell_exec('ls -la'); // do this
Non-canonical cast names are deprecated. (integer), (boolean), (double), and (binary) are on notice. Use the short forms.
$n = (integer) $value; // deprecated
$n = (int) $value; // canonical
__sleep() and __wakeup() are soft-deprecated. Move serialization to __serialize() and __unserialize(), which return and accept an array and give you real control over the payload.
public function __serialize(): array
{
return ['id' => $this->id->toString()];
}
public function __unserialize(array $data): void
{
$this->id = OrderId::fromString($data['id']);
}
Incrementing a non-numeric string is deprecated. The Perl-style string increment ($s = 'a'; $s++;) now wants the explicit function.
$code = 'az';
$code = str_increment($code); // 'ba', explicit
null as an array offset is deprecated. Writing $arr[null] = 1 used to silently key on the empty string. Use "" if that's what you meant.
Reflection::setAccessible() is now a no-op and deprecated. It has done nothing since 8.1 opened up reflection by default. Delete the calls.
Extension and runtime changes that break deploys
This is where an upgrade dies in CI or, worse, at boot. The platform under your code breaks, not the code itself.
Opcache is always built in now. The --enable-opcache and --disable-opcache configure flags are gone, and loading it explicitly with zend_extension=opcache.so in your INI emits a warning. The opcache.enable and opcache.enable_cli directives still work. If your Dockerfile or Ansible role hard-codes that zend_extension line, strip it.
disable_classes INI was removed entirely. If your hardening config used it, the setting no longer does anything, and any tooling that parses it needs updating.
MySQLi got stricter. Calling the constructor on an already-constructed mysqli object now throws Error. The mysqli_execute() alias is deprecated in favor of mysqli_stmt_execute().
Resource-freeing functions are deprecated across extensions. curl_close(), imagedestroy() (GD), and xml_parser_free() no longer do anything useful — those types became objects releases ago and free themselves. The MHASH_* constants are deprecated too. These are safe to delete.
ArrayObject and ArrayIterator stop accepting enums, and Intl now needs at least ICU 57.1. If you run an older distro's ICU, that's a base-image bump, not a code change.
A pre-upgrade checklist
Run this in order on a branch before you touch production config.
- Set
error_reporting(E_ALL)and log deprecations. Run the full suite. Save the log. - Grep the codebase for the mechanical ones: backticks,
(integer)/(boolean)/(double)casts,curl_close,imagedestroy,xml_parser_free,setAccessible(. - Search for
__sleep/__wakeupand migrate serialization. - Audit every
PDO::FETCH_CLASShydration and any loose==on enums or internal objects. - Diff your Dockerfile and INI: drop the opcache
zend_extensionline and anydisable_classessetting. - Check ICU version in the target image (
php -i | grep ICU). - Bump PHPStan or Psalm to a version that knows 8.5 and run it. Static analysis catches the removed-function calls before runtime does.
Let Rector do the mechanical part
Most of bucket one is find-and-replace with a parser that understands PHP. That's exactly what Rector automates. Point it at the 8.5 set:
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([__DIR__ . '/src'])
->withPhpSets(php85: true);
The 8.5 set rewrites the cast-name deprecations, the backtick operator, and the other mechanical shifts into their canonical forms. Run it with --dry-run first, read the diff, then apply. It will not decide your serialization strategy or fix a wrong enum comparison — those need a human — but it clears the boilerplate deprecations so your log narrows to the handful that need judgment.
The order that works: Rector for the mechanical rewrites, static analysis to surface the removed-function calls, then your test suite with deprecations logged to catch the behavior shifts. Three passes, each cheaper than the production incident that finds these for you.
If this was useful
The changes that hurt in an upgrade are almost always the ones that leaked into your domain code: a (integer) cast in an entity, a curl_close() buried in a repository, a serialization hook on a value object. Keep those platform concerns at the edge (adapters, infrastructure) and your domain barely notices which PHP version it runs on. That boundary is the whole subject of Decoupled PHP: how to build applications that outlive the framework, and the runtime version underneath it too.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)