A timeout is the least helpful error your app can produce, because the place it surfaces is almost never the place it's caused. Nginx returns the 504, but Nginx didn't do anything wrong — it just got tired of waiting for PHP, which got tired of waiting for a query, which got tired of waiting for a lock. Debugging a timeout is really an exercise in walking that chain backwards to the one layer that actually stalled, and the trick is knowing how each layer signals that it was the one that gave up first.
The five timeout layers
A Laravel HTTP request touches multiple layers. Each has its own timeout:
| Layer | Default | Config |
|---|---|---|
| Client (browser, curl) | Varies (~30s) | Client-specific |
| Load balancer (ALB, CF) | 60s AWS ALB default | idle_timeout |
| Nginx upstream | 60s | fastcgi_read_timeout |
| PHP-FPM request | 0 (unlimited) | request_terminate_timeout |
| PHP script | 30s | max_execution_time |
| Database statement | Unlimited (dangerous) | statement_timeout (Postgres) |
Diagnose the layer that failed
Nginx access log
# Enable timing fields in log_format
log_format upstream_timing '$remote_addr $request $status '
'request=$request_time upstream=$upstream_response_time';
# Look for 504s with timing
grep ' 504 ' /var/log/nginx/access.log | tail
If upstream_response_time is ~equal to fastcgi_read_timeout, Nginx gave up. If upstream_response_time is -, PHP-FPM didn't respond at all (crashed, full worker pool, or no workers running).
PHP-FPM slowlog
/etc/php/8.2/fpm/pool.d/www.conf
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 10s
request_terminate_timeout = 60s
PHP-FPM dumps the stack trace of any request taking longer than request_slowlog_timeout. Priceless for catching where the hang actually happens — often inside a database driver or file operation.
Database-level timeouts
config/database.php (PostgreSQL)
'pgsql' => [
'driver' => 'pgsql',
// ...
'options' => [
PDO::ATTR_TIMEOUT => 5,
],
// Per-session statement timeout
'statement_timeout' => '15s',
],
Without statement_timeout, a query can hold a PHP-FPM worker indefinitely. Set a value lower than your request timeout so DB hangs fail recoverably instead of taking out the worker.
Common 504 causes
- Synchronous external HTTP call with no timeout. Default Guzzle = unlimited. Set
Http::timeout(5)on every external call. - Slow DB query on unindexed column.
EXPLAIN ANALYZEreveals full-table scans. - Exhausted PHP-FPM worker pool.
pm.max_childrentoo low, legitimate requests queue, queue exceeds timeout. - Lock contention on a hot row.
SELECT ... FOR UPDATEblocking other requests. Visible in Postgres'spg_locks. - Blocking cron job on the same server. A scheduled task hogging CPU or disk starves web workers.
Don't just raise every timeout
The instinct to bump fastcgi_read_timeout to 300s papers over the real issue. A request taking 300s shouldn't run in a request at all — queue it. Use a synchronous request for the queue dispatch and return a 202 with a polling URL or a webhook subscription.
THE EASY WAY
NightOwl surfaces slow and timed-out requests with trace drilldown
Every request is recorded with duration, status, and full per-span breakdown. Filter by 504 or slow duration to find timeouts; click a request to see the dominant span — usually the DB query or external HTTP call that hung.
composer require nightowl/agent
php artisan nightowl:installFrom $5/month flat. Data in your PostgreSQL.