Web Development Bug Report

PHP 8.1+ log flood: Passing null to parameter of type string is deprecated

After moving a PHP 7.4 app to PHP 8.3, our logs grew to 9 GB a day of null-to-string deprecation notices and filled a disk. We found the real call sites, fixed them by intent instead of blind casts, and blocked new ones in CI.

Daniel Tran Daniel Tran @fun_dev · December 17, 2024 · 8 min read · 8 Views
Banner: PHP log file overflowing with null-to-string deprecation warnings

Five days after we moved our booking platform from PHP 7.4 to PHP 8.3, the on-call engineer was paged at 03:10 for a full root volume on one of the web nodes. The application was still serving traffic, but log shipping had stalled and /var/log held 31 GB, almost all of it PHP-FPM output. The lines all looked like variations of this:

[16-Dec-2024 02:58:41 UTC] PHP Deprecated:  strlen(): Passing null to parameter #1 ($string) of type string is deprecated in /var/www/app/src/Search/QueryParser.php on line 88
[16-Dec-2024 02:58:41 UTC] PHP Deprecated:  htmlspecialchars(): Passing null to parameter #1 ($string) of type string is deprecated in /var/www/app/templates/partials/guest.php on line 14
[16-Dec-2024 02:58:41 UTC] PHP Deprecated:  str_replace(): Passing null to parameter #3 ($subject) of type array|string is deprecated in /var/www/app/src/Util/Slug.php on line 22

Each node wrote roughly 9 GB of these per day. Our staging environment had display_errors off and error_reporting set to exclude deprecations, which is why the upgrade had looked clean there.

Reproducing it

Any request that touched a nullable value reproduced it. The simplest was a search with no query string:

curl -s -o /dev/null "https://staging.example.com/search"
tail -n 1 /var/log/php8.3-fpm/error.log

$_GET['q'] ?? null produced null, which went straight into strlen() and trim().

What we ruled out

  • A broken dependency. We first assumed a vendor package was not PHP 8 ready. Grouping by file showed 97% of the lines came from our own src/ and templates/ directories.
  • A real functional bug. strlen(null) still returns 0 and htmlspecialchars(null) still returns "" in PHP 8.x. The deprecation is a warning about future behaviour, not a change in today's result, so no page rendered differently.
  • Just raising the log level. Hiding E_DEPRECATED would have stopped the disk filling, but the RFC that introduced this deprecation states that these calls become a TypeError in the next major version. Hiding them would have turned a log problem into a future outage.

Root cause

PHP 8.1 deprecated passing null to non-nullable scalar parameters of internal functions. In PHP 7.x, internal functions silently coerced null to "", 0 or false. Our code relied on that everywhere: nullable database columns rendered in templates, optional query parameters, and helper functions that returned null for "not found". The behaviour still worked, but every call now emitted an E_DEPRECATED notice, and our production error_reporting was E_ALL, with log_errors = On and no rate limiting.

What is happening under the hood

User-defined functions have always been strict about null: if you declare function f(string $s) and pass null, PHP throws a TypeError even in coercive typing mode. Internal functions, written in C, historically accepted null for scalar parameters and coerced it. PHP 8.1 aligned the two behaviours in steps. For now, internal functions keep coercing but emit a deprecation; the plan is to throw TypeError like user functions do.

Parameters that are explicitly nullable in the signature (for example the $length parameter of substr()) are unaffected. The message tells you exactly which parameter is involved, its position, name and declared type, which is what makes the fix mechanical once you know where to look.

Environment

Component Version / value
OS Debian 12 (bookworm)
PHP 8.3.14 with PHP-FPM, upgraded from 7.4.33
Framework Slim 4.14 with plain PHP templates
Logging FPM catch_workers_output = yes, error_log to file
error_reporting (prod) E_ALL
error_reporting (staging) E_ALL & ~E_DEPRECATED & ~E_STRICT

Minimal reproduction

<?php
// null_to_internal.php
$query = $_GET['q'] ?? null;      // null when the parameter is missing
if (strlen($query) > 2) {          // Deprecated in 8.1+, TypeError planned for 9.0
    echo 'searching';
}

function shout(string $s): string { return strtoupper($s); }
echo shout(null);                  // TypeError already, in every PHP 8 version
php -d error_reporting=E_ALL null_to_internal.php

Who is affected

Any codebase upgraded from PHP 7.x or 8.0 to 8.1, 8.2, 8.3 or 8.4 that passes nullable values to string functions (strlen, trim, str_replace, htmlspecialchars, explode, strpos), numeric functions or date functions. Typical sources are optional request input, nullable database columns, and functions that return null on failure such as preg_replace() on a regex error.

The fix

First we stopped the bleeding: we added a log rotation policy capped by size and temporarily routed deprecations to a separate sampled channel instead of the main error log. Then we found the real call sites by collapsing 14 million lines into unique locations:

grep -h 'Passing null to parameter' /var/log/php8.3-fpm/error.log* \
  | sed -E 's/^\[[^]]+\] //' \
  | awk -F' in ' '{print $NF}' \
  | sort | uniq -c | sort -rn | head -n 40
4108823 /var/www/app/src/Search/QueryParser.php on line 88
2911402 /var/www/app/templates/partials/guest.php on line 14
1730355 /var/www/app/src/Util/Slug.php on line 22
...

Only 63 unique locations produced all 14 million lines, and the top ten produced 91% of them. We fixed each one according to what null meant there, not with a reflexive cast:

 // Optional input: null means "no query", which is a real state.
-$query = $_GET['q'] ?? null;
-if (strlen($query) > 2) {
+$query = trim((string) ($_GET['q'] ?? ''));
+if ($query !== '' && strlen($query) > 2) {
 // Nullable column: render nothing for a missing middle name.
-<?= htmlspecialchars($guest['middle_name']) ?>
+<?= htmlspecialchars($guest['middle_name'] ?? '') ?>
 // Helper that should never receive null: make the contract explicit.
-function slugify($title)
+function slugify(string $title): string
 {
     return strtolower(trim(str_replace(' ', '-', $title)));
 }

The last one is the most valuable kind of change. Adding the string type moved the failure to the caller, where we found a controller passing $event->subtitle that was null for 40% of events. That was a real bug: those events were getting an empty slug segment.

Alternatives and trade-offs

  1. Rector's NullToStrictStringFuncCallArgRector. It is part of the php81 set and rewrites calls such as strlen($x) to strlen((string) $x) when it cannot prove $x is a string. It is fast and safe in the sense that behaviour does not change, but it also hides places where null indicates a bug, like the slug case above. We ran it only on the templates/ directory, where empty-string-for-null is almost always the intent.

    // rector.php
    use Rector\Config\RectorConfig;
    use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector;
    
    return RectorConfig::configure()
        ->withPaths([__DIR__ . '/templates'])
        ->withRules([NullToStrictStringFuncCallArgRector::class]);
    
  2. Suppressing E_DEPRECATED in production. Reasonable as a short-term disk-saving measure, provided deprecations are still collected somewhere, such as CI or a sampled log. Do not make it permanent.

  3. A global error handler that converts deprecations to exceptions. Great in tests, too aggressive in production on day one.

How to test the fix

In the test suite we turned deprecations into failures. With PHPUnit 10 or 11 this is a configuration switch:

<!-- phpunit.xml (excerpt) -->
<phpunit failOnDeprecation="true" displayDetailsOnTestsThatTriggerDeprecations="true">
  <php>
    <ini name="error_reporting" value="-1"/>
  </php>
</phpunit>
vendor/bin/phpunit --testsuite feature

We also wrote a smoke test that hits the 30 busiest routes with and without their optional parameters and asserts the error log did not grow.

Confirming in production

After two deploys over three days, deprecation lines per node dropped from about 9 GB per day to under 2 MB, almost entirely from a single vendor package that we reported upstream. The log volume alert we added at 1 GB per day per node has not fired since. Disk usage on /var stayed flat at 18%.

Regression guard

  • PHPStan at level 8 reports nullable values passed to non-nullable parameters (output below). We added it to CI with a baseline for the remaining legacy code, so new violations fail the build while old ones are burned down.
  • failOnDeprecation in PHPUnit keeps new deprecations out of tested code paths.
  • Staging now runs with exactly the same error_reporting as production.
vendor/bin/phpstan analyse --level=8 src templates
 ------ -------------------------------------------------------------------------------
  Line   src/Search/QueryParser.php
 ------ -------------------------------------------------------------------------------
  88     Parameter #1 $string of function strlen expects string, string|null given.
 ------ -------------------------------------------------------------------------------

Security implications

The deprecations themselves are harmless, but the incident had two security angles. A full disk can stop audit logging and session writes, which is a denial-of-service risk; size-based rotation and a volume alert now cover that. Second, the htmlspecialchars() call sites are output escaping. When fixing them we checked that every one still escapes, because a careless "fix" that drops the call to avoid the warning would open an XSS hole.

Prevention checklist

  • Run staging with the same error_reporting and logging settings as production.
  • Aggregate deprecations by file and line before fixing; the long tail is usually short.
  • Decide what null means at each call site: missing input, empty value or bug.
  • Add scalar type declarations to your own functions so null fails loudly at the caller.
  • Keep PHPStan level 8 and PHPUnit failOnDeprecation in CI before the next major PHP version.

References

Add a label

#php #php-fpm #rector #phpstan #deprecations
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.