Clearing PHP 8.4 Deprecations Out of a Legacy Codebase
An app that runs fine on PHP 7 fills the log with deprecations on 8.4, and two of them are behaviour changes that will eventually be fatal. Here is how we inventory and clear them.
The usual shape of this job: an application that has been running on PHP 7.4 for years gets moved to a modern box, it still works, and the error log grows by a few hundred megabytes a week. Every line says Deprecated. The temptation is to turn the log level down and move on. That is a mistake, because a handful of those deprecations are not stylistic - they are behaviour changes with a fatal ending scheduled for a later release. Everything below was run against the PHP on our own box, PHP 8.4.25 (cli), and every message quoted is the string PHP actually printed.
First, get the inventory without a staging outage
You cannot fix what you have not counted, and you should not find out the size of the job by flipping display_errors on in production. What we do instead is send diagnostics to a dedicated file, leave display off, and drive the application - the test suite if there is one, otherwise a sweep of the top fifty URLs from the access log with curl.
php -d error_reporting=E_ALL \
-d log_errors=1 \
-d display_errors=0 \
-d error_log=/root/myapp/storage/deprecations.log \
vendor/bin/phpunit
# or, for an app with no tests, a URL sweep against a local copy:
awk -F'"' '{print $2}' /var/log/nginx/access.log \
| awk '{print $2}' | sort | uniq -c | sort -rn | head -50 \
| awk '{print $2}' \
| while read -r path; do curl -s -o /dev/null "http://127.0.0.1:8080$path"; done
Then size it. A raw count tells you whether this is an afternoon or a fortnight:
grep -c 'Deprecated' deprecations.log
The important step is the next one. Do not group by file - group by message. Two thousand deprecations are almost never two thousand bugs; they are four or five classes of bug repeated everywhere, and you want to fix a class at a time.
grep 'PHP Deprecated' deprecations.log \
| sed -E 's/^\[[^]]*\] PHP Deprecated: //; s/ in (\/|Command line code).*$//' \
| sort | uniq -c | sort -rn
Run against our own scratch reproduction file that prints as:
3 strlen(): Passing null to parameter #1 ($string) of type string is deprecated
1 f(): Implicitly marking parameter $x as nullable is deprecated, the explicit nullable type must be used instead
1 Using ${var} in strings is deprecated, use {$var} instead
1 Creation of dynamic property Order::$total is deprecated
Four lines. That is the whole work plan, ordered by payoff. Now take them one at a time.
Passing null to a non-nullable internal parameter
This is the big one - deprecated since 8.1 and, in the legacy codebases we have touched, the majority of the log by volume. It looks like this:
php -d error_reporting=E_ALL -r 'echo strlen(null);'
PHP Deprecated: strlen(): Passing null to parameter #1 ($string) of type string is deprecated in Command line code on line 1
The same message comes out of htmlspecialchars(null) and trim(null), with the parameter name changed. The null almost never arrives as a literal. It arrives as an absent array key - $_POST['name'], a database column that is NULL, a config value nobody set. Which means you usually get two diagnostics from one line: an Undefined array key warning, then the deprecation.
The fix people reach for is strval() or a cast, which silences the message and keeps the bug. The honest fix is to decide, at the boundary, what an absent value means. Inline that is ?? ''. Repeated across a codebase it is worth a typed accessor, so the decision lives in one place:
<?php
declare(strict_types=1);
final class Input
{
public function __construct(private array $data) {}
public function str(string $key, string $default = ''): string
{
$v = $this->data[$key] ?? null;
return is_scalar($v) ? (string) $v : $default;
}
public function int(string $key, int $default = 0): int
{
$v = $this->data[$key] ?? null;
return is_numeric($v) ? (int) $v : $default;
}
}
$in = new Input(['page' => '3']);
echo strlen($in->str('missing')), "\n"; // 0
echo $in->int('page') + 1, "\n"; // 4
echo htmlspecialchars($in->str('name'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), "|\n";
That file runs clean under error_reporting=E_ALL and prints 0, 4 and a bare |. Note the ENT_SUBSTITUTE: while you are here, htmlspecialchars() on invalid UTF-8 returns an empty string unless you tell it otherwise, which is its own silent data-loss bug.
Implicitly nullable parameters
New in 8.4, and it will hit every framework-shaped codebase because the old idiom was everywhere:
<?php
class Foo {}
function f(Foo $x = null) { return $x; }
f(null);
PHP Deprecated: f(): Implicitly marking parameter $x as nullable is deprecated,
the explicit nullable type must be used instead in dep1.php on line 3
The fix is one character: function f(?Foo $x = null). It is mechanical, it is safe, and it is the one deprecation on this list you can reasonably hand to a regex-driven pass - though we still read every diff, because = null on a parameter is sometimes a signal that the function has two jobs.
Dynamic properties
Assigning to a property that was never declared has been deprecated since 8.2:
<?php
class Order { public int $id = 1; }
$o = new Order();
$o->total = 99.5;
// PHP Deprecated: Creation of dynamic property Order::$total is deprecated in dyn.php on line 4
There are exactly three responses, and they are not equivalent. Declaring the property is the fix - it is what you want in most cases, and it usually exposes that the class has three undeclared properties nobody knew about. #[\AllowDynamicProperties] is a stopgap; it silences the notice for that class and nothing else, and it is the right call on a generated or ActiveRecord-style class you are not ready to rewrite. Implementing __get/__set is a design decision, not a fix - it is correct when the class genuinely is a bag of attributes, and wrong when you reach for it to avoid typing property declarations.
<?php
declare(strict_types=1);
final class Row
{
/** @var array<string, mixed> */
private array $attributes = [];
public function __construct(array $attributes = [])
{
$this->attributes = $attributes;
}
public function __get(string $name): mixed
{
return $this->attributes[$name] ?? null;
}
public function __set(string $name, mixed $value): void
{
$this->attributes[$name] = $value;
}
public function __isset(string $name): bool
{
return isset($this->attributes[$name]);
}
public function __unset(string $name): void
{
unset($this->attributes[$name]);
}
}
If you implement __get and __set without __isset and __unset, isset($row->total) returns false for a value that is plainly there. That one costs an afternoon when it bites inside a template.
The quick mechanical ones
${var} interpolation is deprecated in 8.2 - PHP Deprecated: Using ${var} in strings is deprecated, use {$var} instead. Replace with {$var}. Careful with the regex: ${expr} and {$expr} are not the same construct for anything more complex than a bare variable name.
utf8_encode() and utf8_decode() are deprecated in 8.2, with the unusually blunt message Function utf8_encode() is deprecated since 8.2, visit the php.net documentation for various alternatives. The names were always a lie: utf8_encode() converts from ISO-8859-1 specifically, not from "whatever encoding this happens to be". Half the calls in a legacy codebase are feeding it UTF-8 already and mangling it. The replacement is explicit about both ends:
$utf8 = mb_convert_encoding($latin1, 'UTF-8', 'ISO-8859-1');
Before you swap them one for one, check what the input encoding actually is. Writing the source encoding down is the point of the change.
Curly-brace string and array offsets - $s{0} - are not deprecated, they were removed in PHP 8.0. You will not see a deprecation for these; you will see PHP Parse error: syntax error, unexpected token "{" and the file will not load at all. Replace with $s[0]. If the app boots on 8.4 you have already dealt with these.
The error-handler trap
Here is the one that turns a tidy cleanup into an outage. Legacy codebases very often install a custom set_error_handler that converts every diagnostic into an ErrorException. On PHP 7 that was mostly harmless. On 8.x it means every newly-introduced deprecation becomes a thrown exception in production. A correct handler checks the error_reporting() mask and treats diagnostics differently from errors:
<?php
function severity_name(int $severity): string
{
return match (true) {
(bool) ($severity & (E_DEPRECATED | E_USER_DEPRECATED)) => 'DEPRECATED',
(bool) ($severity & (E_NOTICE | E_USER_NOTICE)) => 'NOTICE',
(bool) ($severity & (E_WARNING | E_USER_WARNING)) => 'WARNING',
default => 'ERROR',
};
}
set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
// 1. Respect the current mask. Without this, @-suppressed calls still reach you.
if (!(error_reporting() & $severity)) {
return false;
}
// 2. Diagnostics get logged. Only real errors become exceptions.
if ($severity & (E_DEPRECATED | E_USER_DEPRECATED | E_NOTICE | E_USER_NOTICE | E_WARNING | E_USER_WARNING)) {
error_log(sprintf('[%s] %s in %s:%d', severity_name($severity), $message, $file, $line));
return true;
}
throw new ErrorException($message, 0, $severity, $file, $line);
});
$row = [];
echo strlen($row['name'] ?? ''), "\n"; // 0, silent: the real fix
echo strlen($row['name']), "\n"; // two log lines, execution continues
echo "still running\n";
Running that with error_log pointed at stdout gives exactly what you want - the diagnostics recorded, the request finished:
0
[WARNING] Undefined array key "name" in handler.php:31
[DEPRECATED] strlen(): Passing null to parameter #1 ($string) of type string is deprecated in handler.php:31
0
still running
One caveat worth knowing: declare(strict_types=1) changes this picture. Under strict types, strlen(null) is not a deprecation at all - it is an uncaught TypeError. We hit that while writing this article. Adding strict_types to a legacy file as part of a deprecation cleanup converts a log line into a fatal, so do the two things as separate commits.
Order of work
Fix the null-to-internal-function class first, because it is the largest by count and the fix is also a genuine bug fix. Then implicit nullables, which are mechanical. Then dynamic properties, which need judgement per class. Interpolation and utf8_encode last - they are small and they need a human eye on the encoding question. Re-run the inventory after each pass; the count dropping by the number you expected is the confirmation that you understood the class of bug, and a count that does not move means you fixed the symptom somewhere other than where it came from. This is the same discipline we bring to older CodeIgniter applications, where framework internals often emit the deprecations rather than your own code, and to general PHP application work where the upgrade is the prerequisite for anything else.
When not to do this
If the application is being retired inside six months, this is not the cheapest honest call. Pinning it to a PHP version its code was written for and spending the budget on the replacement is defensible, and we have recommended it. But be explicit about the clock you start: PHP 8.1 stopped receiving security fixes at the end of 2025, and an unsupported runtime is a standing risk that grows every month. If you pin, write the end date into the ticket, keep the box off the public internet where you can, and treat any slip on the retirement date as a decision to fund the upgrade after all. A failure mode we have seen more than once is a pin that was meant to last a quarter and is still in place four years later, on a version nobody will patch.
If you are staring at a deprecation log on an application that still earns its keep, we do this kind of upgrade work as a fixed scope with the inventory as the first deliverable, so you see the size of the job before committing to it. Start a project, tell us what the log says, and we will tell you what it means.
We ship and run our own products. We build yours the same way.
Start a project