How to Get Started With Docker and Laravel

May 21, 2021
Written by
Oluyemi Olususi
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

You've recently released an app only to find that, within a pretty short period of time, it's all the rage. People can't get enough of it. As a result, requests grow so rapidly that the development team starts talking about "scaling up".

Once upon a time, this would have meant paying for a more powerful server (vertical scaling). However, the rise of containers in recent years means that it is now possible to rapidly—and relatively cost-effectively—add more containers and scale your application horizontally.

Doing so gives you two key advantages. First, you don't have to incur additional costs for a more powerful server. Second, you can scale your application up—and down—based on current customer demand.

In this article, I will show you how to use Docker with a Laravel project. This is the first step in building an application that can be scaled accordingly to handle both surges and dips in application usage.

Apache will be used as the webserver, and PostgreSQL will provide the database engine. The application to be built will display famous quotes made by renowned historians.

Prerequisites

To follow along with this tutorial you will need:

  • A basic understanding of PHP and Laravel.
  • Understanding of several basic Docker terms such as container, image, network, and service. Jeff Hale wrote a brilliant series that explains these terms, feel free to go through it if any of this looks unfamiliar.
  • Docker Desktop

Getting started

To get started, create a new directory named laravel_docker

mkdir laravel_docker
cd laravel_docker

Because we have different components of our application that need to communicate among themselves, we will use Docker Compose to define our services. Given that, in the root of the laravel_docker directory, create a new file called docker-compose.yml.

touch docker-compose.yml

This file will hold all the configuration for the containers to be built in our application's configuration from how the containers are to be built to the networks and volumes accessible to the containers.

In docker-compose.yml, add the following.

version: '3.8'

services:

The version refers to the schema version and the services will define the list of containers our application stack will consist of. Services are really just “containers in production.

In the following sections, we'll describe the containers for our database, Apache web server and PHP.

Build the database container

In docker-compose.yml, update the services entry as follows:

services:
  database:
    image: postgres
    container_name: database
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: laravel_docker
    volumes:
      - ./postgres-data:/var/lib/postgresql/data
    ports:
      - '5432:5432'

container_name sets the name of the container when it runs, rather than letting Docker Compose generate it automatically.

image lets Docker know what blueprint we want to build the container from, in this case, we specify postgres because we want to use PostgreSQL as the database engine.

The environment key specifies a list of environment variables, such as the name of the default database and password. Because we did not specify a username, the database username will be "postgres".

Using the ports key, we map a port on our local development machine to a port in the container  so that we can connect to the database using a GUI tool. The port specified on the left of the colon is the port on the computer. The port specified on the right is the port in the container.

Note: If you have a PostgreSQL instance running or port 4306 is otherwise occupied you can specify a different port on your computer.

Next, we declare a volume using the volume key. According to the Docker documentation:

Volumes are the preferred mechanism for persisting data generated by and used by Docker containers

We declare a volume, in this case, so that our database won't be lost when the containers are destroyed or rebuilt.

Build the PHP and Apache container

Unlike the database container, we need to specify some additional instructions to set up our PHP/Apache container. To do this, we will build the PHP container from a Dockerfile. In the root directory laravel_docker, create a directory named php. Then, in laravel_docker/php, create a file named Dockerfile.

Note: this file has no extension.

mkdir php
touch php/Dockerfile

In laravel_docker/php/Dockerfile, add the following.

Animation showing how Code Coverage tools insert additional code
FROM php:8.0-apache

RUN apt update \
        && apt install -y \
            g++ \
            libicu-dev \
            libpq-dev \
            libzip-dev \
            zip \
            zlib1g-dev \
        && docker-php-ext-install \
            intl \
            opcache \
            pdo \
            pdo_pgsql \
            pgsql \

WORKDIR /var/www/laravel_docker

RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

The official PHP docker image provides a variation that comes with the Apache server. By specifying this in the FROM instruction, our base container comes with Apache installed.

In addition to scaffolding a container from the PHP image, we do the following:

  1. Install the PHP extensions Laravel depends on.
  2. Set the working directory of the container to /var/www/laravel_docker
  3. Install Composer

Next, update docker-compose.yml as follows:

services:
        # The existing database container configuration...
  php-apache:
    container_name: php-apache
    build:
      context: ./php
    ports:
        - '8080:80'
    volumes:
      - ./src:/var/www/laravel_docker
      - ./apache/default.conf:/etc/apache2/sites-enabled/000-default.conf
    depends_on:
      - database

The php-apache container is defined differently in docker-compose.yml than the database container. Instead of specifying an image, we specify a build context. This way, when the docker-compose command is run, the Dockerfile declared in the php directory will be used to build the container.

Next, port 8080 on the local development machine  is mapped to port 80 in the container, just as we did for the database container. Port 80 is used because the virtual host configuration specified in ./apache/default.conf listens on this port.

We declare a volume, again, to persist the data generated by the container. In this case, our Laravel application will be created in the /var/www/laravel_docker directory of the php-apache container. However, it will be persisted in the src directory in the project directory on the local development machine.

An additional volume is declared to link the virtual host configuration for our application with the 000-default.conf host which Apache enables by default. This saves us the stress of disabling the default configuration, enabling ours, and reloading the apache server every time a container is built or rebuilt.

After that, we introduce a new configuration key: depends_on. This lets Docker know that we want the database container to be built first before the php-apache container.

Create the src directory in the root directory of the project.

mkdir src

When we scaffold our Laravel project from the php-apache container, the project files will be persisted here. Then, create a directory named apache. In it, create a file called default.conf.

mkdir apache

touch apache/default.conf

Add the following to apache/default.conf.

<VirtualHost *:80>
   ServerName laravel_docker
   DocumentRoot /var/www/laravel_docker/public

   <Directory /var/www/laravel_docker>
       AllowOverride All
   </Directory>
   ErrorLog ${APACHE_LOG_DIR}/error.log
   CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

With this in place, we can finally build our containers. To do so, run the following command.

docker-compose up -d --build

If you open your Docker Desktop application, you should see your newly created container as shown in the screenshot below.

Docker desktop showing the running containers within the configuration

Set up the Laravel application

To set up the Laravel application, initiate a container in the php-apache container with the command below.

docker-compose exec php-apache /bin/bash

This opens a CLI in the php-apache container. Create the Laravel application using the following command.

composer create-project laravel/laravel .

Once this is completed, navigate to http://localhost:8080/ which should display the default Laravel welcome page.

Laravel&#x27;s default home page

Also, if you look in the src directory, you will see that the Laravel project files have been added there too.

In src/.env, edit the database parameters to match the following.

DB_CONNECTION=pgsql
DB_HOST=database
DB_PORT=5432
DB_DATABASE=laravel_docker
DB_USERNAME=postgres
DB_PASSWORD=secret

Build the application

We are building a quotes application so let's create the model for it. In your php-apache container run the following command:

php artisan make:model Quote -fms

The -f argument lets artisan know we want to create a factory for the quote. In the same way -m is for a migration, and -s is for the database seeder.

In src/database/migrations/YYYY_MM_DD_HHMMSS_create_quotes_table.php, update the up function to match the following.

public function up() {
        Schema::create(
            'quotes',
            function (Blueprint $table) {
                $table->id();
                $table->text('quote');
                $table->string('historian');
                $table->string('year');
                $table->timestamps();
            }
        );
    } 

Next, open src/database/seeders/QuoteSeeder.php and add the following to the run function.

Quote::factory()->times(50)->create();

This creates a quote from the QuoteFactory 50 times, saving it to the database each time.

Note: Don't forget to add this import:

use App\Models\Quote;

In src/database/seeders/DatabaseSeeder.php, add the following to the run function.

        $this->call(
            [
                QuoteSeeder::class
            ]
        ); 

This runs the QuoteSeeder when the db:seed command is run. Next, in src/database/factories/QuoteFactory.php, add the following to the definition function.

return [
            'quote' => $this->faker->sentence(),
            'historian' => $this->faker->name(),
            'year' => $this->faker->year(),
        ];

To run your migrations and seed the database, run the following command in your php-apache container.

php artisan migrate:fresh --seed

To see what has been added to the database, open a command-line to the database container with the following command.

docker-compose exec database /bin/bash

Next log in to your PostgreSQL service with the following command.

psql -U postgres laravel_docker

Next, run an SQL query to get all the items in the quotes table

SELECT * FROM quotes;

The output should be similar to the screenshot below.

Lists of quotes in the database

With that in place, create a controller to handle requests for quotes using the following command.

php artisan make:controller QuoteController

Open src/app/Http/Controllers/QuoteController.php and update it to match the following:

<?php

namespace App\Http\Controllers;

use App\Models\Quote;
use Illuminate\Http\Request;

class QuoteController extends Controller
{
    public function index(){
        return view('quotes.index', ['quotes' => Quote::all()]);
    }
}

In the index function, we retrieve all the quotes from the database and pass them to the view ( quotes/index.blade.php) to be rendered accordingly. At the moment the view doesn't exist, so let's create it, by running the commands below.

mkdir resources/views/quotes
touch resources/views/quotes/index.blade.php

Open src/resources/views/quotes/index.blade.php and add the following.

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Top Quotes</title>
    <link
        href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css"
        rel="stylesheet"
        integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6"
        crossorigin="anonymous">
    <style>
        .wrapper {
            margin: 1em auto;
            width: 95%;
        }
    </style>
</head>
<body>
<div class="wrapper">
    <h1>Top Quotes</h1>
    <table class="table table-striped table-hover table-bordered">
        <thead>
        <tr>
            <th scope="col">#</th>
            <th scope="col">Quote</th>
            <th scope="col">Historian</th>
            <th scope="col">Year</th>
        </tr>
        </thead>
        <tbody>
        @foreach ($quotes as $quote)
            <tr>
                <td>{{ $loop->index + 1 }}</td>
                <td>{{ $quote->quote }}</td>
                <td>{{ $quote->historian }}</td>
                <td>{{ $quote->year }}</td>
            </tr>
        @endforeach
        </tbody>
    </table>
</div>
<script
    src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js"
    integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf"
    crossorigin="anonymous">
</script>
</body>
</html>

In this template, we use Bootstrap to style the table of quotes. Then we use a foreach loop directive to loop through the quotes received from the QuoteController and display the quote, historian, and year accordingly.

Finally, update the routing to load the quotes on the index page. To do this open src/routes/web.php and update the route declaration there to match the following.

Route::get('/', [App\Http\Controllers\QuoteController::class, 'index']);

By doing this the index function in the QuoteController will be called when the index page is visited instead of returning the welcome page.

Test the changes

To test the changes, navigate to http://localhost:8080/ where you should see the page render similar to the screenshot below.

Top quotes rendered in the default view.

Tear it all down

Earlier, we created and built our containers using the docker-compose up -d --build command. For some reasons, such as low hard disk space or redundant applications, you might want to clean up your systems. Docker already made a provision for that. Run the following command to stop and remove the containers and all associated networks:

docker-compose down

You will see the following output:

Output of docker-compose down command from the terminal

If you'd like to dive deeper into Docker Compose and learn loads more, such as how to debug Docker Compose configurations, download Deploy with Docker Compose. It's free.

Scaling the application

It is possible that one instance of a service may not be enough to sufficiently handle all the traffic to the application. For times like these, Docker supports scaling of services i.e., creating multiple instances of a service, by using the --scale flag. 

As an example, we'll scale up our php-apache service. To do this, open docker-compose.yml. In it, we'll make two changes. The first is to remove the container name configuration. This is because the container name should be unique, if we try to create multiple services with the same name Docker will throw an error. 

Similarly, we'll modify the service's port configuration so that Docker can automatically assign a port to each newly created service. Just like the container name, two services cannot be bound to the same port. 

To make these changes, update the php-apache service configuration to match the following.

php-apache:
    build:
      context: ./php
    ports:
        - '8080'
    volumes:
      - ./app:/var/www/laravel_docker
      - ./apache/default.conf:/etc/apache2/sites-enabled/000-default.conf
    depends_on:
      - database

With the configuration updated, let's create 10 instances of our php-apache service by running the following command.

docker-compose up --scale php-apache=10 -d

Once this is completed, you will see something similar to the screenshot below printed to the terminal.

database is up-to-date

Recreating php-apache ... done
Creating laravel_docker_php-apache_2  ... done 
Creating laravel_docker_php-apache_3  ... done
Creating laravel_docker_php-apache_4  ... done 
Creating laravel_docker_php-apache_5  ... done 
Creating laravel_docker_php-apache_6  ... done 
Creating laravel_docker_php-apache_7  ... done
Creating laravel_docker_php-apache_8  ... done 
Creating laravel_docker_php-apache_9  ... done 
Creating laravel_docker_php-apache_10 … done

To test that everything is working, run `docker-compose ps`. You should see output similar to the example below.

            Name                          Command               State                Ports             
-------------------------------------------------------------------------------------------------------
database                       docker-entrypoint.sh postgres    Up      0.0.0.0:5432->5432/tcp         
laravel_docker_php-apache_1    docker-php-entrypoint apac ...   Up      80/tcp, 0.0.0.0:55966->8080/tcp
laravel_docker_php-apache_10   docker-php-entrypoint apac ...   Up      80/tcp, 0.0.0.0:55970->8080/tcp
laravel_docker_php-apache_2    docker-php-entrypoint apac ...   Up      80/tcp, 0.0.0.0:55972->8080/tcp
laravel_docker_php-apache_3    docker-php-entrypoint apac ...   Up      80/tcp, 0.0.0.0:55968->8080/tcp
laravel_docker_php-apache_4    docker-php-entrypoint apac ...   Up      80/tcp, 0.0.0.0:55974->8080/tcp
laravel_docker_php-apache_5    docker-php-entrypoint apac ...   Up      80/tcp, 0.0.0.0:55973->8080/tcp
laravel_docker_php-apache_6    docker-php-entrypoint apac ...   Up      80/tcp, 0.0.0.0:55969->8080/tcp
laravel_docker_php-apache_7    docker-php-entrypoint apac ...   Up      80/tcp, 0.0.0.0:55967->8080/tcp
laravel_docker_php-apache_8    docker-php-entrypoint apac ...   Up      80/tcp, 0.0.0.0:55971->8080/tcp
laravel_docker_php-apache_9    docker-php-entrypoint apac ...   Up      80/tcp, 0.0.0.0:55975->8080/tcp

That's how to get started with Docker and Laravel

Not only were we able to build containers from images and Dockerfiles, but we were also able to make them communicate with one another, allowing us to run our Laravel application and database in separate containers.

This is the first step in building an application stack that is horizontally scalable. By taking advantage of container orchestration infrastructure, containers can be created/destroyed to meet the number of requests being handled by the server. If you'd like to learn more, check out Deep Dive into Laravel Development with Docker.

The entire codebase for this tutorial is available here on GitHub. Feel free to explore further. Happy coding!

Oluyemi is a tech enthusiast with a background in Telecommunication Engineering. With a keen interest to solve day-to-day problems encountered by users, he ventured into programming and has since directed his problem-solving skills at building software for both web and mobile.

A full-stack software engineer with a passion for sharing knowledge, Oluyemi has published a good number of technical articles and content on several blogs on the internet. Being tech-savvy, his hobbies include trying out new programming languages and frameworks.