EN

Dockerising Laravel: what's the difference between the development and production container?

A development container is built for fast feedback, a production one for stability. They are not one file with different variables, but two layers.

Two doors on one shipping container: one sealed shut, the other standing open on a dark interior.
Photo: ardhito giovanni (Unsplash)

In a lot of Laravel projects, the first attempt at Docker starts with a single Dockerfile that then gets toggled between development and production mode with environment variables. That works for a while, then starts to hurt: the development container wants Xdebug, live source syncing and verbose error messages, while in production those exact same things are a security risk or a performance cost.

Two goals pulling in opposite directions

The job of the development container is fast feedback: I want to see the effect of a save immediately, and when something breaks I want the full stack trace. The job of the production container is stability and a small surface: the less there is inside it, the less there is to break or to exploit.

Those two goals genuinely pull in opposite directions, and that is precisely why one image cannot be turned into the other with a couple of environment variables. An environment variable changes behaviour; it does not change what is inside the image.

Multi-stage build: two targets, one file

For us, the two containers are two separate build targets of the same Dockerfile, using a multi-stage build. The shared layer holds everything both need: the PHP base, the system libraries and the Composer dependencies.

The development layer includes Xdebug and mounts the source code as a volume, so a change shows up the instant you save it: no rebuild, no waiting. The .env comes from the developer's machine, error messages are verbose, and the development tooling (tests, static analysis, code formatter) is present.

The production layer, by contrast, copies the source code into the image rather than mounting it, turns debug mode off, uses an optimised autoloader and pre-compiled configuration, and includes only the PHP extensions the application actually needs. Development dependencies never enter it at all (--no-dev).

What has to be identical in both

Some things must match, or the whole exercise loses its point: the PHP major version, the list of extensions, and the way the application is served. If the developer's machine runs PHP 8.4 and production runs 8.3, the container has not protected anyone from anything. It has merely relocated the surprise.

The same goes for queue and scheduler workers. If they run as separate services in production, they should run that way in the development compose file too, otherwise the first asynchronous bug shows up during a live demo.

The build flag, and the decision behind it

In practice, that difference comes down to one argument on the build command (--target=dev or --target=production), but the decision behind it isn't a technical footnote: a production image that still carries Xdebug or live source syncing is slower and more exposed than it needs to be.

A development image that uses the production optimisations, on the other hand, forces a multi-second rebuild after every save. That adds up dozens of times a day, and it is exactly the kind of friction that eventually pushes developers back to working without a container at all. The expensive failure, then, is not the slow build, it is the abandoned practice.

Why keep it in one file

The multi-stage Dockerfile doesn't hide that tension, it makes it explicit: a single file shows exactly what's only needed while developing, and what should never be present in production at all. Two separate files would solve the same problem, for a while. Then an extension gets added to one and not the other, and the difference only surfaces in production.