Our MySQL server was perfectly healthy, and the site was still down for 8 minutes.

No deploy, no traffic spike, no slow query. The app server just could not reach the database for a while, and Laravel waited. And waited. Turns out a Laravel MySQL connection sets no connect timeout of its own, and when the connect fails, Laravel tries again, three more times.

What Happened

The project I work on runs on Hetzner. The app box talks to MySQL over Hetzner's private network. One afternoon that internal network failed in one datacenter. MySQL was fine. The app box was fine. They just could not see each other.

The Laravel log filled with this:

SQLSTATE[HY000] [2002] Connection timed out 

An error in the log is normal during a network problem. But the whole site stopped answering, including routes that never touch the database.

The box has one PHP-FPM pool with 5 workers, and two Laravel apps share it: the storefront API and the API our ERP uses for its data exchange. Every request that needed the DB took a worker and hung on the connect. Within a minute all 5 workers were stuck, and nothing else could get in. A short network blip turned into a full outage.

nginx logs from that day (the app box serves both APIs, the storefront sits in front of it):

Where Errors during the outage Before
App box 260 x 5xx 0
Storefront 7 x 504, 43 x 499 (the customer gave up) 0

Why One Connect Attempt Takes 30 Seconds

Look at the mysql connection options in config/database.php (ours, from an older skeleton; Laravel 13's has the same shape):

'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], 

No PDO::ATTR_TIMEOUT. The Laravel 13 skeleton doesn't have one either.

So who decides how long a connect waits? Not php.ini, which surprised me. pdo_mysql has its own default: when you pass any options array to PDO and leave out ATTR_TIMEOUT, it waits 30 seconds. Laravel always passes options (Connector has a default set: error mode, case, and so on), so in a Laravel app it's always 30.

I checked it with PDO against an unroutable IP, with default_socket_timeout set to 3 to prove it plays no part:

$start = microtime(true); try { new PDO('mysql:host=10.255.255.1;dbname=x', 'x', 'x', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, ]); } catch (PDOException $e) { echo round(microtime(true) - $start, 2) . 's ' . $e->getMessage(); } 
$ php -d default_socket_timeout=3 probe.php 29.84s SQLSTATE[HY000] [2002] Connection timed out 

Heads up: drop the options array and the same connect fails after 3 seconds, because a bare new PDO() with no options does follow default_socket_timeout. That's why a quick test in plain PHP can show you a different number than your app sees.

30 seconds per request is already bad. Spoiler: Laravel waits four times.

Laravel Tries Four Times

Connection timed out is on Laravel's list of "lost connection" errors in LostConnectionDetector:

'SQLSTATE[HY000] [2002] Connection timed out', 

For an error on that list, Laravel retries in two places.

The first is the connector. If creating the PDO fails with a lost-connection error, Connector::createConnection() tries once more, right away.

The second is the query. Laravel connects lazily, so the connect happens inside the first query. When that query fails with a lost-connection error, Illuminate\Database\Connection resets the connection and runs the query again (Laravel 12 shown; 13 adds a guard for queries fired during the reconnect itself, the main query still runs again):

protected function tryAgainIfCausedByLostConnection(QueryException $e, $query, $bindings, Closure $callback) { if ($this->causedByLostConnection($e->getPrevious())) { $this->reconnect(); return $this->runQueryCallback($query, $bindings, $callback); } throw $e; } 

Running the query again means connecting again, through the same connector, with its own retry. So 2 attempts x 2 = 4 connect attempts for one failing query.

One failing query makes four connect attempts: two in the connector, then a reconnect and two more

I measured it on Laravel 12.43 and on a fresh 13.40, with a connection pointing at an unroutable host. Same numbers on both:

config(['database.connections.probe' => [ 'driver' => 'mysql', 'host' => '10.255.255.1', 'database' => 'x', 'username' => 'x', 'password' => 'x', 'options' => [PDO::ATTR_TIMEOUT => 2], ]]); $start = microtime(true); try { DB::connection('probe')->select('select 1'); } catch (Throwable $e) { echo round(microtime(true) - $start, 2) . 's'; } 
PDO::ATTR_TIMEOUT One failing select 1
1 4.01 s
2 8.01 s
not set 4 x 30 s, about 2 minutes

Our team measured the last row locally before the fix: about 30 seconds per attempt. With nginx's default 60-second fastcgi_read_timeout, nginx gives up halfway, but the FPM worker behind it keeps waiting.

What about transactions? Inside an open transaction, the query-level retry is skipped. But beginTransaction() has its own reconnect on a lost connection, so DB::transaction() still ended up at about 4x (3.8 s with a 1-second timeout).

The Fix

One option, on every MySQL connection the app uses:

'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), // Seconds per connect attempt; Laravel retries, so a failing query waits ~4x this. PDO::ATTR_TIMEOUT => (int) env('DB_CONNECT_TIMEOUT', 2), ]) : [], 

Plus DB_CONNECT_TIMEOUT=2 in .env.example, with the same note that it's per attempt.

Four details:

  • Don't use 0 to mean "no limit". In our config and in the Laravel 13 skeleton, array_filter() drops it, and the connection falls back to the 30-second default. The 12.x skeleton filters only null, so there a 0 reaches PDO, which then falls back to default_socket_timeout (60 seconds by default). Not "no limit" either way.
  • It's a connect timeout, not a query timeout. On staging select sleep(5) still passed with the timeout at 2.
  • Why 2? Over a private network a healthy connect takes well under a millisecond. Pick a value a few times your worst healthy connect: with TLS or a managed database that is further away, you may need more.
  • Both apps on the shared pool got the same change. Fixing only one would leave the other free to fill the pool.

To prove it, I ran the probe above against a copy of the real mysql config with the host swapped. Locally: 8.0 s. On staging: 8.0 s, after config:cache plus an FPM reload (opcache there does not revalidate files).

What It Does Not Fix

Let me be honest: this does not keep the site up when the database is unreachable. Requests that need the DB still fail. What changes is how they fail: an error after 8 seconds instead of a worker stuck for 2 minutes. The workers stay free, so routes served from cache and the health check can still answer.

Why not the usual suspects?

  • max_execution_time would not have helped. On Linux it does not count time spent in stream operations or system calls, and a hung connect is exactly that.
  • FPM's request_terminate_timeout kills a stuck worker, but every new request still walks into the same 30-second connect.
  • A bigger pool only delays the outage. Two minutes of waiting per request fills 50 workers too, just later. Raising pm.max_children is a separate decision.
  • I kept Laravel's retry. On a real one-second blip, the retry is what saves the request, and with a short timeout it costs seconds, not minutes.
  • A circuit breaker or a proxy like ProxySQL can fail even faster. You still want the timeout under them.

This article is about MySQL. Redis and outgoing HTTP calls need their own timeouts too, and I haven't tested how PostgreSQL behaves here.

TL;DR

  • A Laravel MySQL connection without PDO::ATTR_TIMEOUT waits pdo_mysql's 30 seconds per connect attempt, not your php.ini value.
  • A timed-out connect is a "lost connection", so Laravel makes 4 attempts per query: about 2 minutes.
  • During a network blip that holds every FPM worker, so routes that don't need the database go down too.
  • Set PDO::ATTR_TIMEOUT, and remember the real wait is 4x the value.

💡 Try it on your app: point a copy of your mysql connection at 10.255.255.1 and time a select 1.

Author's Note

Thanks for sticking around!
Find me on dev.to, linkedin, or you can check out my work on github.

Laravel, after the happy path.