Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Docker for PHP Developers: Images, Containers and Volumes Finally Explained

About Post

A lot of PHP developers have a slightly awkward relationship with Docker. They use it every day, because the project came with a docker-compose.yml, but if you ask them what the difference between an image and a container is, or where their database data actually lives, the answer gets vague.

That's fine until something breaks. Then "I'll just delete everything and rebuild" wipes the local database, and the vagueness becomes expensive.

The good news: if you understand PHP classes and objects, you already understand Docker. Let me show you.

The mental model in three lines

  • An image is a class. A read-only template: an operating system base, PHP, extensions, your code, all frozen at build time.
  • A container is an object. A running instance of an image. You can run many containers from one image, each with its own state.
  • A volume is the database. Well, the place where data that must survive goes. Containers are disposable; volumes aren't.

And the Dockerfile is the class definition: the instructions to build the image. Docker Compose is the bootstrap file that says which objects to create and how they talk to each other.

The most important consequence of this model: anything you change inside a running container, outside a volume, is gone when the container is removed. Install a PHP extension by hand inside a container and it disappears on the next rebuild, exactly like setting a property on an object and then creating a new one.

A Dockerfile for a Laravel app

Here's a simplified Dockerfile using the official PHP image with Apache. It's a good starting point for local development and a base you can harden for production:

FROM php:8.4-apache

RUN apt-get update && apt-get install -y git unzip libzip-dev \
    && docker-php-ext-install pdo_mysql zip \
    && a2enmod rewrite

# Serve Laravel's public/ folder instead of the default web root
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public
RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html

COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader

COPY . .
RUN composer dump-autoload --optimize \
    && chown -R www-data:www-data storage bootstrap/cache

Two things worth noticing:

  • Layer order matters. Each instruction creates a cached layer. By copying composer.json and composer.lock first and installing dependencies before copying the rest of the code, Docker can reuse the slow composer install layer whenever only your code changes. Copy everything first and every tiny edit reinstalls all your packages.
  • Composer comes from its own image. COPY --from=composer:2 grabs the binary from the official Composer image, no download script needed.

Also add a .dockerignore with at least vendor, node_modules, .env and .git. Otherwise you'll copy your local secrets and a few hundred megabytes of junk into the image.

Compose: the app and MySQL together

services:
  app:
    build: .
    ports:
      - "8000:80"
    environment:
      DB_CONNECTION: mysql
      DB_HOST: mysql
      DB_DATABASE: app
      DB_USERNAME: root
      DB_PASSWORD: secret
    depends_on:
      - mysql

  mysql:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: app
      MYSQL_ROOT_PASSWORD: secret
    volumes:
      - dbdata:/var/lib/mysql

volumes:
  dbdata:

Run docker compose up -d --build, then docker compose exec app php artisan migrate, and your app is on http://localhost:8000. (Using root and a plain-text password is fine for a local setup, never for anything shared.)

Notice DB_HOST: mysql. Inside the Compose network, each service is reachable by its service name. 127.0.0.1 inside the app container means the app container itself, where there's no MySQL. This one line is behind a huge share of "connection refused" questions.

Also, depends_on only waits for the MySQL container to start, not for MySQL to be ready to accept connections. If your app runs migrations on boot, add a healthcheck or a retry.

Volumes: where your data actually lives

There are two kinds you'll use constantly, and mixing them up causes most of the confusion:

Named volumeBind mount
Looks likedbdata:/var/lib/mysql./:/var/www/html
Managed byDockerYou (it's a folder on your machine)
Best forDatabase files, anything to persistLive-editing code during development
Survives docker compose downYesYes, it's your folder
Survives down -vNo, it's deletedYes

That last row is the one that hurts. docker compose down -v removes named volumes, which means your local database is gone. Know what -v does before you type it.

The mistakes everyone makes first

  • The bind mount that hides vendor. For development you'll probably mount your code with - .:/var/www/html so edits show up instantly. But that mount covers everything at that path, including the vendor folder installed during the build. If your local folder has no vendor, the app suddenly can't find its autoloader. Run composer install inside the container after mounting, or keep the mount and the build path separate.
  • Treating containers like servers. SSH-ing in and installing things by hand. Put it in the Dockerfile or it doesn't exist.
  • Permission errors on storage/. Apache runs as www-data, and with bind mounts on Linux, files belong to your host user. The classic "laravel.log could not be opened" error. Fix ownership, or run the container with a matching user ID.
  • Baking secrets into the image. Copying .env into an image means anyone with the image has your keys. Pass config as environment variables at runtime.
  • Using latest tags. php:latest today and php:latest in six months are different PHP versions. Pin versions like php:8.4-apache and mysql:8.4.
  • Forgetting extensions. The official PHP image is minimal. If you need gd, intl or bcmath, add them with docker-php-ext-install (some need system libraries installed first).

Remember: image = class, container = object, volume = the only place data survives. If something matters, it's either in the Dockerfile or in a volume. Everything else is temporary.

Do you even need to write this yourself?

For local Laravel development, Laravel Sail gives you a ready-made Compose setup with PHP, MySQL, Redis and more. It's a great way to start. But understanding the pieces underneath is what lets you fix it when it breaks, build a production image, or run the same setup in CI so your tests run on the same stack as production.

That last part is the real prize. Docker's biggest gift isn't convenience. It's that "works on my machine" and "works on the server" finally mean the same thing.

What was the Docker concept that took you longest to really understand? My bet is that volumes top the list.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close