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>
9.8 KiB
| title | domain | category | tags | status | created | updated | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Patching PHP 8.4 Implicit-Nullable Deprecations in Vendor Packages | troubleshooting | 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 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
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 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.
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 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