majorwiki/05-troubleshooting/php-84-vendor-implicit-nullable-patch.md
MajorLinux 724ae2a5e3 Add troubleshooting article: PHP 8.4 implicit-nullable vendor patch
Generalizes the Castopod/UuidModel incident from 2026-05-10. PHP 8.4
deprecated implicit-nullable parameters (`function f(int $x = null)`).
Old vendor libraries spam E_DEPRECATED warnings; CodeIgniter wraps each
in a 23-frame stack trace; per-minute spark cron amplifies into 53-80
MB/day log bleed and 22% sustained CPU floor on small VPS boxes.

Documents the four-line sed fix AND the substring-match gotcha that
extended the fix from 30 seconds to 30 minutes — bare `int \$limit = null`
patterns substring-match `?int \$limit = null` elsewhere in the file
and produce illegal `??type` syntax. Covers anchored sed patterns,
reference-parameter handling (&\$db), the lint-after-every-edit rule,
and a bonus section on hunting stray developer debug prints
(`log_message('critical', 'ITS HEEEEEEEEEEEERE')`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:52:25 -04:00

9.8 KiB
Raw Blame History

title domain category tags status created updated
Patching PHP 8.4 Implicit-Nullable Deprecations in Vendor Packages troubleshooting troubleshooting
php
php-8.4
codeigniter
castopod
composer
vendor
deprecation
troubleshooting
published 2026-05-10 2026-05-10

Patching PHP 8.4 Implicit-Nullable Deprecations in Vendor Packages

TL;DR — PHP 8.4 deprecated implicit-nullable parameters (function f(int $x = null) without ?int). Old vendor packages that haven't been updated will spam E_DEPRECATED warnings on every load. CodeIgniter (and similar frameworks) wrap each warning in a 23-frame stack trace, which on a per-minute cron multiplies into hundreds of MB/day of logs and a noticeable CPU floor on small VPS boxes. The fix is a four-line sed patch — but be very careful: a naive sed pattern can substring-match an already-nullable parameter and produce illegal ??type syntax.


Symptom

You're running a CodeIgniter 4 app (Castopod, BookStack, etc.) on PHP 8.4 with an older vendored library that hasn't been updated to declare nullable types properly. The combination produces:

  • Sustained CPU floor on a 1 vCPU box (typically 1525% baseline) when the framework's spark/cron scheduler runs every 60 seconds
  • Massive daily log volume in writable/logs/log-YYYY-MM-DD.log — 50100 MB per day is common
  • Each WARNING line is followed by a 23-frame stack trace through Composer's autoloader, the framework's autoloader, and the application's command/model entry point
  • The actual scheduler task may report Failed: even though it logs no obvious error — the deprecation is fatal in some PHP/CodeIgniter combinations

The deprecation warnings look like:

WARNING - 2026-05-10 16:33:01 --> [DEPRECATED]
Vendor\Package\SomeModel::doFindAll(): Implicitly marking parameter $limit as nullable is deprecated,
the explicit nullable type must be used instead in
VENDORPATH/vendor/package/src/SomeModel.php on line 287.

Why this matters more than a typical deprecation

Three multipliers turn "minor PHP deprecation" into "the box is on fire":

  1. Per-minute cronphp spark tasks:run runs every 60 seconds. Each run loads the framework, hits the deprecation, dumps a stack trace.
  2. CodeIgniter's error handler is verbose — it catches E_DEPRECATED and writes a full backtrace to disk. There's no debug-vs-production split here.
  3. Small VPS boxes have a thin idle margin — on a 1 vCPU droplet, sustained 22% from PHP startup overhead + log writes is enough to trip a default >85% / 5min DigitalOcean alert during traffic spikes.

Diagnostic chain

1. Confirm the symptom is deprecation cascade, not autoload failure

The stack trace makes this look like an autoload error — it isn't. Check the WARNING line itself:

  • [DEPRECATED] ... Implicitly marking parameter ... as nullable → vendor library + PHP 8.4 mismatch (this article applies)
  • Class 'X' not found → actual autoload problem (different fix)

2. Identify the PHP version

php -v

If it's 8.4+, implicit-nullable is now E_DEPRECATED. (PHP 8.4.0 was released 2024-11-21; many distros bumped during 202526.)

3. List the offending lines

The log itself names them. Grep for the unique vendor-path pattern:

grep 'DEPRECATED' /var/www/<app>/writable/logs/log-$(date +%Y-%m-%d).log \
  | awk -F'on line ' '{print $2}' | sort -u

You'll typically see three to six line numbers in one file — each parameter that needs ? prefixing.

4. Inspect each line before patching

F=/var/www/<app>/vendor/<package>/src/<File>.php
sed -n '287p;520p' "$F"   # Show only the lines named by the warnings

Look for already-prefixed parameters in the same function or nearby — if ?type $foo = null already exists in the file, your sed pattern must not match it.

The fix — anchored sed

Step 1: Backup.

F=/var/www/<app>/vendor/<package>/src/<File>.php
sudo cp -p "$F" "$F.bak.$(date +%Y%m%d-%H%M%S)"

Step 2: Apply patches with anchors. Don't use bare patterns like int \$limit = null — they'll substring-match against ?int \$limit = null (an already-nullable parameter elsewhere in the file) and produce ??int $limit = null, which PHP rejects as a ParseError: unexpected token "??".

Anchor on the function signature:

sudo sed -i \
  -e 's|^\(\s*protected function doFindAll(\)int \$limit = null|\1?int $limit = null|' \
  -e 's|^\(\s*protected function doUpdateBatch(\)array \$set = null, string \$index = null|\1?array $set = null, ?string $index = null|' \
  "$F"

For constructors with reference operators (&$db), include the & in the anchor:

sudo sed -i 's|ConnectionInterface &\$db = null|?ConnectionInterface \&$db = null|' "$F"

Step 3: Lint immediately.

sudo php -l "$F"
# Must print: No syntax errors detected in <path>

If lint fails, restore from the backup and try a tighter anchor — don't chain another sed onto a broken file.

Step 4: Verify the runtime.

sudo -u www-data php /var/www/<app>/spark tasks:run | grep -E '(Executed|Failed)'

The previously-Failing task should now show Executed.

Step 5: Confirm the log bleed stops. Wait 60s, then:

LOG=/var/www/<app>/writable/logs/log-$(date +%Y-%m-%d).log
SINCE=$(date -d '60 seconds ago' '+%H:%M:%S')
awk -v t="$SINCE" '/DEPRECATED/ && $4>=t' "$LOG" | wc -l
# Expect: 0

The substring-match gotcha (the one that bit me)

This is the failure mode that turns a 30-second fix into a 30-minute incident:

# DANGEROUS
sed -i 's|int \$limit = null|?int $limit = null|' "$F"

That pattern matches both:

  • protected function doFindAll(int $limit = null, …) — the line you want to fix
  • protected function doInsertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100) — somewhere else in the file, where there's an int $limit = null substring inside an already-nullable signature you don't want to touch

After sed, the second line becomes ??array $set = null (or similar) — illegal in PHP. The first time the autoloader tries to load the file, you get:

ParseError: syntax error, unexpected token "??", expecting variable
at vendor/.../src/<File>.php:426

Recovery is restore-from-backup, then re-apply with anchored patterns. Always lint before reload, before flush, before next anything.

Are reference parameters tricky? Yes.

&$db (pass-by-reference) needs the ampersand preserved when adding the ? prefix:

Before After
ConnectionInterface &$db = null ?ConnectionInterface &$db = null
array &$rows ?array &$rows

In sed, escape the ampersand in the replacement (\&) because unescaped & in the replacement means "the matched text." Easy way to test the right escaping: run sed with --debug or do a dry-run with -n and p.

Bonus: hunt for stray debug prints while you're in there

When you're already grepping the application source for one issue, scan for sloppy log_message('critical', ...) calls left in by upstream developers. Real-world finds include:

  • log_message('critical', 'ITS HEEEEEEEEEEEERE'); — left in Castopod's modules/Fediverse/Filters/FediverseFilter.php line 62, firing on every fediverse request, contributing 195 CRITICAL entries to one day's log
  • log_message('critical', 'TODO');
  • log_message('critical', 'wtf');
grep -rE "log_message\(['\"]critical['\"]" /var/www/<app>/modules/ /var/www/<app>/app/ \
  | grep -v -E 'TODO|FIXME' \
  | head -10

These are usually safe to remove (or downgrade to debug level) — they don't represent real failure conditions, just developer artifacts.

Why not just upgrade the vendor package?

composer update <package> is the proper fix. But:

  • Many PHP applications (Castopod especially) ship pre-built vendor/ and don't expect composer to be installed at runtime
  • A major version bump (v1.x → v2.x) implies API changes that the application may not handle
  • composer update may pull in cascading dependency updates you don't want

Hot-patching is the right answer when:

  • The application doesn't ship with composer.json referencing the package directly
  • The fix is purely syntactic (parameter type declarations)
  • A future application release will likely include the upgraded vendor anyway

Just document the patch and add a follow-up task to re-apply (or skip) after the next application upgrade. Without that note, the next time the box is rebuilt or upgraded, you'll spend another evening chasing the same stack trace.

Specific examples observed in the MajorsHouse fleet

Castopod 1.20+ on PHP 8.4

vendor/michalsn/codeigniter4-uuid/src/UuidModel.php v1.3.1 — four nullable-prefix corrections needed:

Line Original Patched
54 __construct(ConnectionInterface &$db = null, ValidationInterface $validation = null) __construct(?ConnectionInterface &$db = null, ?ValidationInterface $validation = null)
287 doFindAll(int $limit = null, int $offset = 0) doFindAll(?int $limit = null, int $offset = 0)
520 doUpdateBatch(array $set = null, string $index = null, …) doUpdateBatch(?array $set = null, ?string $index = null, …)

Line 426 (doInsertBatch(?array $set = null, ?bool $escape = null, …)) was already correct — the substring-match gotcha above was triggered by it.

Upstream michalsn/codeigniter4-uuid v2.0.0 (released 2024) declares all parameters with explicit ?type syntax and has no deprecation warnings. Castopod hadn't upgraded the dependency as of Castopod 1.20.

See also