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>
225 lines
9.8 KiB
Markdown
225 lines
9.8 KiB
Markdown
---
|
||
title: Patching PHP 8.4 Implicit-Nullable Deprecations in Vendor Packages
|
||
domain: troubleshooting
|
||
category: troubleshooting
|
||
tags:
|
||
- php
|
||
- php-8.4
|
||
- codeigniter
|
||
- castopod
|
||
- composer
|
||
- vendor
|
||
- deprecation
|
||
- troubleshooting
|
||
status: published
|
||
created: 2026-05-10
|
||
updated: 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 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":
|
||
|
||
1. **Per-minute cron** — `php 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
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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.**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
sudo sed -i 's|ConnectionInterface &\$db = null|?ConnectionInterface \&$db = null|' "$F"
|
||
```
|
||
|
||
**Step 3: Lint immediately.**
|
||
|
||
```bash
|
||
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.**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
# 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');`
|
||
|
||
```bash
|
||
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
|
||
|
||
- [Castopod Posts Don't Appear on Mastodon — Diagnosing the Federation Path](security/castopod-broadcast-not-on-mastodon.md) — tttpod-specific diagnostic
|
||
- [PHP RFC: Deprecate implicitly nullable parameter types](https://wiki.php.net/rfc/deprecate-implicitly-nullable-types) — the canonical PHP 8.4 reference
|