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>
This commit is contained in:
Marcus Summers 2026-05-10 12:52:25 -04:00
parent 631d7e8bc5
commit 724ae2a5e3
3 changed files with 229 additions and 2 deletions

View file

@ -0,0 +1,225 @@
---
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

View file

@ -98,6 +98,7 @@ updated: 2026-05-10T00:10
* [Pi-hole AI Blocklist Blocks Claude Desktop (ERR_CONNECTION_REFUSED)](05-troubleshooting/networking/pihole-blocks-claude-desktop.md) * [Pi-hole AI Blocklist Blocks Claude Desktop (ERR_CONNECTION_REFUSED)](05-troubleshooting/networking/pihole-blocks-claude-desktop.md)
* [Claude Desktop MCP Server Started via wsl.exe Sees Empty Environment (WSLENV)](05-troubleshooting/wsl-env-claude-desktop-mcp.md) * [Claude Desktop MCP Server Started via wsl.exe Sees Empty Environment (WSLENV)](05-troubleshooting/wsl-env-claude-desktop-mcp.md)
* [Claude Desktop MCP Mass-Disconnect After Blocking SSH Reboot](05-troubleshooting/claude-desktop-mcp-mass-disconnect-blocking-reboot.md) * [Claude Desktop MCP Mass-Disconnect After Blocking SSH Reboot](05-troubleshooting/claude-desktop-mcp-mass-disconnect-blocking-reboot.md)
* [Patching PHP 8.4 Implicit-Nullable Deprecations in Vendor Packages](05-troubleshooting/php-84-vendor-implicit-nullable-patch.md)
* [Ollama Drops Off Tailscale When Mac Sleeps](05-troubleshooting/ollama-macos-sleep-tailscale-disconnect.md) * [Ollama Drops Off Tailscale When Mac Sleeps](05-troubleshooting/ollama-macos-sleep-tailscale-disconnect.md)
* [Ollama: `ollama run` with Piped Stdin Bypasses Chat Template + SYSTEM Prompt](05-troubleshooting/ollama-chat-template-pipe-stdin-bypass.md) * [Ollama: `ollama run` with Piped Stdin Bypasses Chat Template + SYSTEM Prompt](05-troubleshooting/ollama-chat-template-pipe-stdin-bypass.md)
* [rsync over Tailscale: Hung in TCP Teardown After Transfer Completes](05-troubleshooting/networking/rsync-tailscale-teardown-stall.md) * [rsync over Tailscale: Hung in TCP Teardown After Transfer Completes](05-troubleshooting/networking/rsync-tailscale-teardown-stall.md)

View file

@ -7,7 +7,7 @@ updated: 2026-05-10T01:30
> A growing reference of Linux, self-hosting, open source, streaming, and troubleshooting guides. Written by MajorLinux. Used by MajorTwin. > A growing reference of Linux, self-hosting, open source, streaming, and troubleshooting guides. Written by MajorLinux. Used by MajorTwin.
> >
> **Last updated:** 2026-05-10 > **Last updated:** 2026-05-10
> **Article count:** 110 > **Article count:** 111
## Domains ## Domains
@ -17,7 +17,7 @@ updated: 2026-05-10T01:30
| 🏠 Self-Hosting & Homelab | `02-selfhosting/` | 39 | | 🏠 Self-Hosting & Homelab | `02-selfhosting/` | 39 |
| 🔓 Open Source Tools | `03-opensource/` | 10 | | 🔓 Open Source Tools | `03-opensource/` | 10 |
| 🎙️ Streaming & Podcasting | `04-streaming/` | 2 | | 🎙️ Streaming & Podcasting | `04-streaming/` | 2 |
| 🔧 General Troubleshooting | `05-troubleshooting/` | 47 | | 🔧 General Troubleshooting | `05-troubleshooting/` | 48 |
--- ---
@ -217,6 +217,7 @@ updated: 2026-05-10T01:30
| Date | Article | Domain | | Date | Article | Domain |
|---|---|---| |---|---|---|
| 2026-05-10 | [Patching PHP 8.4 Implicit-Nullable Deprecations in Vendor Packages](05-troubleshooting/php-84-vendor-implicit-nullable-patch.md) — generalized from a Castopod/UuidModel incident; covers the substring-match gotcha that turns a 30-second fix into a 30-minute one | Troubleshooting |
| 2026-05-10 | [Logwatch Fleet Setup — Surviving Package Upgrades](02-selfhosting/monitoring/logwatch-fleet-setup.md) — added Fedora CA bundle missing diagnosis, journald-vs-mail.log methodology note, and bounce-source-must-be-real-mailbox section | Self-Hosting | | 2026-05-10 | [Logwatch Fleet Setup — Surviving Package Upgrades](02-selfhosting/monitoring/logwatch-fleet-setup.md) — added Fedora CA bundle missing diagnosis, journald-vs-mail.log methodology note, and bounce-source-must-be-real-mailbox section | Self-Hosting |
| 2026-05-10 | [ClamAV Fleet Deployment with Ansible](02-selfhosting/security/clamav-fleet-deployment.md) — added DigitalOcean monitoring caveat for 1vCPU droplets (with follow-up note: per-droplet relaxed alert can still trip; accept-the-page decision) | Self-Hosting | | 2026-05-10 | [ClamAV Fleet Deployment with Ansible](02-selfhosting/security/clamav-fleet-deployment.md) — added DigitalOcean monitoring caveat for 1vCPU droplets (with follow-up note: per-droplet relaxed alert can still trip; accept-the-page decision) | Self-Hosting |
| 2026-05-10 | [Claude Desktop MCP Mass-Disconnect After Blocking SSH Reboot](05-troubleshooting/claude-desktop-mcp-mass-disconnect-blocking-reboot.md) | Troubleshooting | | 2026-05-10 | [Claude Desktop MCP Mass-Disconnect After Blocking SSH Reboot](05-troubleshooting/claude-desktop-mcp-mass-disconnect-blocking-reboot.md) | Troubleshooting |