--- 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//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//vendor//src/.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//vendor//src/.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 ``` 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//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//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/.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//modules/ /var/www//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 ` 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