Sunday, 23 May, 2021 UTC


Summary

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.
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.
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.
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.
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/[email protected]/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/[email protected]/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.
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:
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.
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.
  • Twitter: https://twitter.com/yemiwebby
  • GitHub: https://github.com/yemiwebby
  • Website: https://yemiwebby.com.ng/