SQLSTATE[HY000] [2002] Connection Refused in Laravel and Docker

SQLSTATE[HY000] [2002] Connection Refused in Laravel and Docker

You ran a migration or loaded your Laravel app, and got this back.

SQLSTATE[HY000] [2002] Connection refused

This error means something different from a login failure. It means PHP could not even reach the database server at all. No username or password was checked yet, since the connection never got that far. I want to walk through the two most common causes, since they are different problems with different fixes, and a lot of guides mix them together.

Cause 1: DB_HOST points to the wrong place

If you run Laravel and MySQL as separate Docker containers, your .env file probably has this line.

DB_HOST=127.0.0.1

Inside a container, 127.0.0.1 means the container itself, not your host machine and not your database container. So your app looks for MySQL on its own loopback address, finds nothing there, and fails immediately. The fix is to point DB_HOST at the actual service name from your docker-compose file instead.

DB_HOST=mysql

Docker Compose gives every service a hostname matching its service name, and containers on the same network can reach each other by that name. If your database service in docker-compose.yml is called mysql, or db, or database, that exact word is what belongs in DB_HOST, not an IP address.

Cause 2: the database container is not actually ready yet

This is the cause I think trips people up more, since it looks random. Your DB_HOST is correct, your credentials are correct, and it still fails, but only sometimes. Usually right after you run docker compose up from a cold start.

Docker's own documentation is direct about why. As stated in Docker's Compose file reference, "Compose does not wait until a container is 'ready'... only until it's running." A MySQL container can report as started well before MySQL itself has finished initializing and is ready to accept connections. If your app tries to connect during that gap, you get connection refused, even though the container is technically up.

A plain depends_on entry does not fix this, and I think this is the part most people get wrong first. It only waits for the container to start, not for the database inside it to actually be ready.

# This only waits for the container to start, not for MySQL to be ready
depends_on:
  - mysql

The real fix: a healthcheck plus a healthy condition

We covered how to set up Docker healthchecks properly in our guide on Docker healthchecks and restart policies. The same tool solves this problem. Add a healthcheck to your MySQL service that actually tests readiness, then tell your app service to wait for that specific health state before starting.

services:
  app:
    build: .
    depends_on:
      mysql:
        condition: service_healthy

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: laravel
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 10s

Notice the change from a plain list under depends_on to the long form with a condition. That difference is the entire fix. With this in place, your app container will not even start until MySQL passes its own healthcheck, so the race condition disappears.

The catch: this changes how long a fresh startup takes

Once you add this, a cold docker compose up will take a little longer before your app container starts, since it is now actually waiting on MySQL instead of racing it. I think this tradeoff is worth it every time. A few extra seconds of startup time is a much better outcome than a flaky migration that sometimes fails for no obvious reason.

If you are still on an older Docker Compose version that does not support the long-form condition syntax, a temporary workaround is a small wait loop in your app's entrypoint script that checks the database port before continuing. I would treat this as a stopgap, though, and upgrade Compose when you can, since the healthcheck approach is more reliable and easier to read.

FAQ

How do I know which cause I actually have?
Check your DB_HOST value first, since that is the simpler fix. If DB_HOST already matches your service name and the error still happens, especially only on a fresh start, you are dealing with the readiness race condition instead.

Does this same fix apply outside of Laravel?
Yes. This is a Docker Compose behavior, not a Laravel-specific one. Any application connecting to a database in a separate container can hit the same race condition, and the same healthcheck fix applies.

Why does it sometimes work and sometimes fail on the same setup?
Startup timing is not perfectly consistent. Some runs, MySQL happens to finish initializing before your app tries to connect. Other runs, it does not. That inconsistency is exactly the signal that you are dealing with a race condition, not a configuration typo.

Is this different from a "too many connections" or access denied error?
Yes, and I would not confuse them. Connection refused means Laravel could not reach the database at all. Access denied and too many connections both mean Laravel did reach the database, and then hit a different problem once it got there, like the one we covered in our guide on the MySQL too many connections error.

Should I add a healthcheck to Laravel's own container too?
It is a good idea generally, especially if other services depend on your app being ready, but it does not affect this specific error. This error is about your app waiting on the database, not the other way around.

Bottom line

Connection refused almost always means one of two things. Either DB_HOST points somewhere your app cannot actually reach, or your app started before the database was truly ready. The first is a quick config fix. The second needs a real healthcheck, not just a plain depends_on line, since Docker only guarantees a container has started, not that the service inside it is ready.

Sources: Docker Compose file reference, official documentation; Laravel official documentation, Database. Verified against official documentation on August 29, 2026.

Comments 0

Be the first to comment.

Leave a comment