Step Debugging Laravel in Docker: Xdebug That Actually Connects

Configure Xdebug 3 for Laravel in Docker: the settings that matter, VS Code path mappings, and debugging artisan commands, queue workers and Pest runs.

Steven Richardson
Steven Richardson
· 8 min read

You set a breakpoint, hit the page, and nothing happens. Then you lose an afternoon to search results that mix Xdebug 2 settings with Docker advice for a different host OS. The setup is small once it is written down correctly — here it is, including the CLI half that most guides skip.

Install Xdebug in the image#

Install the extension with PECL in your PHP image and keep the configuration in its own ini file so you can swap it per environment. Pinning the version keeps rebuilds reproducible.

FROM php:8.4-fpm-alpine

RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS linux-headers \
    && pecl install xdebug-3.5.1 \
    && docker-php-ext-enable xdebug \
    && apk del .build-deps

# Mounted, not baked, so production images can ship without it
COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/99-xdebug.ini

If you build images with multiple targets, keep Xdebug out of the production stage entirely — see optimising Laravel Docker images with multi-stage builds for the pattern.

Write the Xdebug 3 ini file#

Four settings do the work. xdebug.mode turns features on, start_with_request decides when Xdebug tries to connect, and client_host/client_port say where your IDE is listening. The log is not optional while you are getting this working.

; docker/php/xdebug.ini
xdebug.mode=develop,debug
xdebug.start_with_request=trigger
xdebug.client_host=host.docker.internal
xdebug.client_port=9003
xdebug.log=/tmp/xdebug.log
xdebug.log_level=7
xdebug.idekey=VSCODE

If you are copying config from a blog post that sets xdebug.remote_enable, xdebug.remote_autostart, xdebug.remote_host or xdebug.remote_port, it is written for Xdebug 2 and none of those settings exist any more. remote_host/remote_port became client_host/client_port, the default port moved from 9000 to 9003, and remote_connect_back became xdebug.discover_client_host. Recent 3.5.x builds will warn about unknown settings on startup, but do not rely on seeing the warning — check the names yourself.

Point the container at your IDE#

Inside the container, localhost is the container. Your IDE is on the host. On Docker Desktop (macOS and Windows) host.docker.internal resolves already; on Linux you have to add it yourself with extra_hosts.

# compose.yaml
services:
  app:
    build: .
    volumes:
      - .:/var/www/html
    extra_hosts:
      # Required on Linux; harmless on Docker Desktop
      - "host.docker.internal:host-gateway"
    environment:
      PHP_IDE_CONFIG: "serverName=laravel"

Getting this wrong is the single most common reason the connection never opens. Verify it from inside the container before touching anything else:

docker compose exec app getent hosts host.docker.internal

Configure VS Code path mappings#

Xdebug reports the paths it sees inside the container; VS Code needs to know which files on your disk those are. Map the container's application root to your workspace folder. Get this wrong and the connection works fine but every breakpoint renders as a hollow circle.

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Listen for Xdebug (Docker)",
      "type": "php",
      "request": "launch",
      "port": 9003,
      "pathMappings": {
        // container path : host path
        "/var/www/html": "${workspaceFolder}"
      },
      "log": false
    }
  ]
}

In PhpStorm the equivalent lives under Settings → PHP → Servers: add a server named to match PHP_IDE_CONFIG, tick Use path mappings, and map your project root to /var/www/html.

One mapping covers vendor code too, as long as vendor/ sits inside the mapped root. If you mount vendor separately — some setups do, for speed — add a second mapping for it, or stepping into framework code will drop you into an empty editor.

Trigger a debug session on demand#

With start_with_request=trigger, Xdebug only attempts a connection when the request carries a trigger: the XDEBUG_TRIGGER environment variable, or an XDEBUG_SESSION cookie or query parameter.

# Browser: append the parameter, or use an Xdebug helper extension
# https://your-app.test/dashboard?XDEBUG_SESSION=1

The alternative, start_with_request=yes, makes every single request try to reach your IDE. When the IDE is not listening — which is most of the time — each request pays a connection timeout first. On a page with a handful of AJAX calls that is immediately noticeable.

Debug artisan commands and queue workers#

This is the half that setup guides leave out, and it is where step debugging actually earns its keep. For CLI there is no browser and no extension — you set the trigger as an environment variable on the command itself.

# An artisan command
docker compose exec -e XDEBUG_TRIGGER=1 app php artisan report:generate

# A single queued job — --once stops the worker after one job
# so you are not re-entering the breakpoint forever
docker compose exec -e XDEBUG_TRIGGER=1 app php artisan queue:work --once

# A focused Pest run
docker compose exec -e XDEBUG_TRIGGER=1 app ./vendor/bin/pest --filter=InvoiceTest

Wrap them so nobody has to remember the variable:

// composer.json
{
  "scripts": {
    "debug:work": "XDEBUG_TRIGGER=1 php artisan queue:work --once",
    "debug:test": "XDEBUG_TRIGGER=1 ./vendor/bin/pest"
  }
}

Debugging a long-running worker is worth a note: a worker booted before you started listening will not connect. Start the listener, then start the worker. In production those workers are supervised rather than run by hand — keeping Laravel queue workers alive with Supervisor covers that side, and you should never point a supervised worker at a debugger.

Read xdebug.log when it still will not connect#

The log answers the only question that matters: did Xdebug try to connect, and was it refused? Tail it while you reload.

docker compose exec app tail -f /tmp/xdebug.log

A refused connection — wrong host, or no IDE listening:

[12] Log opened at 2026-11-15 09:14:02.118404
[12] [Step Debug] INFO: Connecting to configured address/port: host.docker.internal:9003.
[12] [Step Debug] ERR: Could not connect to debugging client. :-(

A successful one:

[14] [Step Debug] INFO: Connecting to configured address/port: host.docker.internal:9003.
[14] [Step Debug] INFO: Connected to debugging client: host.docker.internal:9003 (through xdebug.client_host/xdebug.client_port).

No log lines at all means the request never triggered Xdebug — check the trigger, and check that the ini file was actually loaded by the SAPI you are using.

Keep it off where it costs you#

xdebug.mode=off belongs in your default image; develop,debug belongs only where you are debugging. Mode is the cost control — debug adds per-request connection work, profile and trace write to disk on every request, and coverage instruments every line.

services:
  app:
    environment:
      # Overrides the ini file; flip per developer, per machine
      XDEBUG_MODE: "off"

For CI coverage, do not reach for Xdebug at all — PCOV is dramatically faster for the same line coverage, as covered in PCOV vs Xdebug for Laravel code coverage. And if your problem is a slow endpoint rather than a wrong one, a profiler is the right tool: see profiling a slow Laravel endpoint.

Gotchas and Edge Cases#

PHP-FPM and CLI load different ini files. The same image often has separate conf.d directories per SAPI, so web requests debug fine and artisan does nothing. Check both: php --ini for CLI, phpinfo() for FPM.

A stale container keeps the old ini. Editing xdebug.ini on the host does nothing if it was COPYed into the image. Rebuild, or mount the file as a volume while you iterate.

xdebug.idekey is not how VS Code matches sessions. It matters for PhpStorm's server mapping via PHP_IDE_CONFIG; the VS Code extension listens for whatever arrives on the port.

Breakpoints in vendor code need the vendor path mapped. If you mount vendor/ as a named volume for performance, it is not under your workspace folder and needs its own pathMappings entry.

Port 9000 is not the default any more. If your IDE is listening on 9000 out of habit and the container dials 9003, you will see nothing in the log at the IDE end and a refusal at the container end.

If you use Herd instead#

Running under Laravel Herd rather than Docker removes almost all of this: Xdebug ships with Herd's PHP builds and is a toggle per PHP version, the IDE is on the same machine so client_host stays localhost, and there are no path mappings at all because the container path is the host path. The trigger and CLI advice above still applies unchanged.

Wrapping Up#

Fix the host first, then the path mappings, and read xdebug.log before changing anything else — those three cover nearly every failure. Keep xdebug.mode=off in your default image and trigger sessions explicitly, so the cost only lands when you asked for it.

When the bug is in a running system rather than a single request, step debugging is the wrong shape of tool: tailing logs in real time with Pail and debugging Livewire and Eloquent with Ray both fit that better.

FAQ#

Why does Xdebug not connect from a Docker container?

Almost always because xdebug.client_host points somewhere the container cannot reach. Inside a container, localhost is the container itself, not your machine, so the IDE is unreachable at that address. Set xdebug.client_host=host.docker.internal and, on Linux, add extra_hosts: ["host.docker.internal:host-gateway"] to the service. Check /tmp/xdebug.log to confirm whether a connection was even attempted.

What should xdebug.client_host be in Docker?

host.docker.internal on Docker Desktop for macOS and Windows, where it resolves to the host automatically. On Linux the name does not exist by default, so map it to host-gateway via extra_hosts in your Compose file and then use the same value. Avoid xdebug.discover_client_host=1 behind a reverse proxy, because it will pick up the proxy's address rather than yours.

How do I debug an artisan command with Xdebug?

Set the trigger as an environment variable on the command itself, because there is no browser or extension involved on the CLI. Run docker compose exec -e XDEBUG_TRIGGER=1 app php artisan your:command with your IDE already listening on port 9003. The same path mappings used for web requests apply, so breakpoints resolve without extra configuration.

How do I debug a Laravel queue worker?

Start your IDE listener first, then run the worker with the trigger set and --once so it processes a single job and exits: docker compose exec -e XDEBUG_TRIGGER=1 app php artisan queue:work --once. Without --once the worker keeps pulling jobs and you re-enter the breakpoint repeatedly. A worker that was already running before you started listening will not connect — restart it.

What are pathMappings in VS Code Xdebug config?

They tell VS Code which file on your disk corresponds to a path Xdebug reports from inside the container. The key is the container path and the value is the host path, typically "/var/www/html": "${workspaceFolder}". When they are wrong the debugger still connects but breakpoints show as hollow circles, because VS Code cannot match the file it has open to the file the engine is executing.

Does Xdebug slow down Laravel?

Yes, and how much depends entirely on xdebug.mode. off has essentially no cost, debug adds per-request work plus a connection attempt, and coverage, profile and trace are significantly heavier. Keep the default off in shared and CI images, enable develop,debug only on the machine doing the debugging, and use PCOV rather than Xdebug when you only need line coverage.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.