Ali Saleem London, UK

← Blog

Why Laravel's defer() Breaks on Forge Servers

Calling Laravel's defer() helper on a Forge-provisioned server can produce a 502 Bad Gateway, an empty Laravel log, and an nginx error that gives little away:

recv() failed (104: Connection reset by peer) while reading response header from upstream

The cause is a function name collision with the Swoole extension rather than anything in the application code.

What's happening

Forge-provisioned servers ship with the Swoole PHP extension installed. Swoole registers a global defer() function when the extension loads, before the application's autoloader runs.

Laravel's helper is guarded:

if (! function_exists('defer')) {
    function defer(...) { /* ... */ }
}

By the time that guard is evaluated, function_exists('defer') already returns true. Laravel's version is skipped, and every call to defer() in the application resolves to Swoole's function.

Swoole's defer() requires a coroutine context. Under PHP-FPM there isn't one, so the request ends in:

PHP Fatal error: Uncaught Swoole\Error: API must be called in the coroutine

Why the error is hidden

Three things combine to keep the real error out of sight:

The result is a crash with no error message in any of the usual places.

Confirming the collision

Check the FPM SAPI directly:

php-fpm8.5 -i | grep -i 'swoole\|use_shortname'

If Swoole is present and swoole.use_shortname is on (the default), the collision is there.

If the underlying fatal error still isn't visible, point PHP at a real log file first:

; /etc/php/8.5/pool.d/www.conf
catch_workers_output = yes
decorate_workers_output = no

Worker stderr then lands in /var/log/php8.5-fpm.log, which already has sensible permissions and rotation.

The fix

Laravel's global defer() is a thin wrapper; the real implementation lives at Illuminate\Support\defer(). Import it explicitly:

use function Illuminate\Support\defer;

Add that to the use block of any file that calls defer(). Nothing else changes.

This is the fix to reach for first. It is explicit, unaffected by extension load order, and lives in the codebase, so it survives server rebuilds and config changes.

Optional: disable Swoole's short names

If nothing on the server uses Swoole's runtime, which is usually the case when serving through PHP-FPM, the collision can be removed at the source:

# /etc/php/8.5/mods-available/swoole.ini
swoole.use_shortname = Off

Then restart FPM:

sudo systemctl restart php8.5-fpm

This also releases go() and co(), Swoole's other short-name exports. If the extension isn't used at all, disabling it entirely on the FPM SAPI is another option.

Server-level config is easier to lose than code: a rebuilt server or a fresh provision will reset it. Do the use function import regardless, and treat the config change as a secondary measure rather than the fix.

The general point

Any PHP extension can define global functions, and a function_exists() guard means the extension wins. Laravel's namespaced helpers exist for this situation. When a global helper behaves oddly, importing the namespaced version is a quick way to rule out a collision:

use function Illuminate\Support\defer;
use function Illuminate\Support\enum_value;
use function Illuminate\Support\literal;

And when a fatal error leaves no trace, check where PHP is actually writing before assuming it isn't happening.