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

225 lines
9.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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 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 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 202526.)
### 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