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 spamE_DEPRECATEDwarnings 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-linesedpatch — but be very careful: a naive sed pattern can substring-match an already-nullable parameter and produce illegal??typesyntax.
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 15–25% baseline) when the framework's spark/cron scheduler runs every 60 seconds
- Massive daily log volume in
writable/logs/log-YYYY-MM-DD.log— 50–100 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":
- Per-minute cron —
php spark tasks:runruns every 60 seconds. Each run loads the framework, hits the deprecation, dumps a stack trace. - CodeIgniter's error handler is verbose — it catches
E_DEPRECATEDand writes a full backtrace to disk. There's no debug-vs-production split here. - 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% / 5minDigitalOcean 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¶
If it's 8.4+, implicit-nullable is now E_DEPRECATED. (PHP 8.4.0 was released 2024-11-21; many distros bumped during 2025–26.)
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.
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:
Step 3: Lint immediately.
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.
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:
That pattern matches both:
protected function doFindAll(int $limit = null, …)— the line you want to fixprotected function doInsertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100)— somewhere else in the file, where there's anint $limit = nullsubstring 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'smodules/Fediverse/Filters/FediverseFilter.phpline 62, firing on every fediverse request, contributing 195 CRITICAL entries to one day's loglog_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 updatemay pull in cascading dependency updates you don't want
Hot-patching is the right answer when:
- The application doesn't ship with
composer.jsonreferencing 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¶
- Castopod Posts Don't Appear on Mastodon — Diagnosing the Federation Path — tttpod-specific diagnostic
- PHP RFC: Deprecate implicitly nullable parameter types — the canonical PHP 8.4 reference