Run Laravel 9 on Docker in 2022 [Tutorial Part 4]
Tags: php, laravel, docker, webdev
Published: 2022-06-28 07:35:36

<p>This article appeared first on <a href='https://www.pascallandau.com/'>https://www.pascallandau.com/</a> at <a href='https://www.pascallandau.com/blog/run-laravel-9-docker-in-2022/'>Run Laravel 9 on Docker in 2022 [Tutorial Part 4]</a></p>
<hr />

In this third subpart of the fourth part of this tutorial series on developing PHP on Docker we will install **Laravel and make sure our setup works for Artisan Commands, a Redis Queue and Controllers**
for the front end requests.


{% youtube BpsBzpMD87c %}


**All code samples are publicly available** in my [Docker PHP Tutorial repository on Github](https://github.com/paslandau/docker-php-tutorial/).   You find the branch for this tutorial at [part-4-3-run-laravel-9-docker-in-2022](https://github.com/paslandau/docker-php-tutorial/tree/part-4-3-run-laravel-9-docker-in-2022).

**All published parts of the Docker PHP Tutorial** are collected under a dedicated page at [Docker PHP Tutorial](https://www.pascallandau.com/docker-php-tutorial/). The previous part was [PhpStorm, Docker and Xdebug 3 on PHP 8.1 in 2022](https://www.pascallandau.com/blog/phpstorm-docker-xdebug-3-php-8-1-in-2022/) and the following one is [Set up PHP QA tools and control them via make](https://www.pascallandau.com/blog/php-qa-tools-make-docker/).

If you want to follow along, please subscribe to the [RSS feed](https://www.pascallandau.com/feed.xml) or [via email](https://www.pascallandau.com/blog/#newsletter) to get **automatic notifications** when the next part comes out :)

<!-- generated -->
<a id='table-of-contents'> </a>
<!-- /generated -->

## Table of contents

<!-- toc -->

- [Introduction](#introduction)
- [Install extensions](#install-extensions)
- [Install Laravel](#install-laravel)
- [Update the PHP POC](#update-the-php-poc)
    - [config](#config)
        - [database connection](#database-connection)
        - [queue connection](#queue-connection)
    - [Controllers](#controllers)
    - [Commands](#commands)
    - [Jobs and workers](#jobs-and-workers)
    - [Tests](#tests)
- [Makefile updates](#makefile-updates)
    - [Clearing the queue](#clearing-the-queue)
- [Running the POC](#running-the-poc)
- [Wrapping up](#wrapping-up)

<!-- /toc -->

<!-- generated -->
<a id='introduction'> </a>
<!-- /generated -->

## Introduction

The goal of this tutorial is to run the  [PHP POC from part 4.1](https://www.pascallandau.com/blog/docker-from-scratch-for-php-applications-in-2022/#php-poc) using Laravel as a framework instead of "plain PHP".  We'll use the newest version of Laravel (Laravel 9) that was [released at the beginning of February 2022](https://laravel-news.com/laravel-9-released).

<!-- generated -->
<a id='install-extensions'> </a>
<!-- /generated -->

## Install extensions

Before Laravel can be installed, we need to add the necessary extensions of the framework (and all its dependencies) to the `php-base` image:

```
# File: .docker/images/php/base/Dockerfile

# ...

RUN apk add --update --no-cache  \
        php-curl~=${TARGET_PHP_VERSION} \

```

<!-- generated -->
<a id='install-laravel'> </a>
<!-- /generated -->

## Install Laravel

We'll start by [creating a new Laravel project with composer](https://laravel.com/docs/9.x/installation#installation-via-composer)

```
composer create-project --prefer-dist laravel/laravel /tmp/laravel "9.*" --no-install --no-scripts
```

The files are added to `/tmp/laravel` because [composer projects cannot be created in non-empty folders](https://github.com/composer/composer/issues/1135#issuecomment-10358244) , so we need to create the project in a temporary location first and move it afterwards.

Since I don't have PHP 8 installed on my laptop, I'll execute the command in the `application` docker container via

```
make execute-in-container DOCKER_SERVICE_NAME="application" COMMAND='composer create-project --prefer-dist laravel/laravel /tmp/laravel "9.*" --no-install --no-scripts'
```

and then move the files into the application directory via

```
rm -rf public/ tests/ composer.* phpunit.xml
make execute-in-container DOCKER_SERVICE_NAME="application" COMMAND="bash -c 'mv -n /tmp/laravel/{.*,*} .' && rm -f /tmp/laravel"
cp .env.example .env
```

**Notes**

- `composer install` is skipped via `--no-install` to avoid having to copy over the `vendor/` folder
  (which is super slow on Docker Desktop)
- [existing directories cannot be overwritten by `mv`](https://unix.stackexchange.com/a/127713) 
  thus I remove `public/` and `tests/` upfront (as well as the `composer` and `phpunit` config 
  files)
- `mv` uses the `-n` flag so that existing files like our `.editorconfig` are not overwritten
- I need to use `bash -c` to run the command in the container because otherwise  [the `*` wildcard would have no effect in the container](https://github.com/moby/moby/issues/12558#issuecomment-94775867) 

To finalize the installation I need to install the composer dependencies and execute the  `create-project` scripts defined in  [`composer.json`](https://github.com/laravel/laravel/blob/9.x/composer.json#L45):

```
make composer ARGS=install
make composer ARGS="run-script post-create-project-cmd"
```

Since our nginx configuration was already pointing to the `public/` directory, we can immediately  open [http://127.0.0.1](http://127.0.0.1) in the browser and should see the frontpage of  a fresh Laravel installation.

<!-- generated -->
<a id='update-the-php-poc'> </a>
<!-- /generated -->

## Update the PHP POC

<!-- generated -->
<a id='config'> </a>
<!-- /generated -->

### config

We need to update the connection information for the database and the queue (previously  configured via `dependencies.php`) in the `.env` file

<!-- generated -->
<a id='database-connection'> </a>
<!-- /generated -->

#### database connection

```
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=application_db
DB_USERNAME=root
DB_PASSWORD=secret_mysql_root_password
```

<!-- generated -->
<a id='queue-connection'> </a>
<!-- /generated -->

#### queue connection

```
QUEUE_CONNECTION=redis

REDIS_HOST=redis
REDIS_PASSWORD=secret_redis_password
```

<!-- generated -->
<a id='controllers'> </a>
<!-- /generated -->

### Controllers

The functionality of the previous `public/index.php` file now lives in the `HomeController` at  `app/Http/Controllers/HomeController.php`

```php
class HomeController extends Controller
{
    use DispatchesJobs;

    public function __invoke(Request $request, QueueManager $queueManager, DatabaseManager $databaseManager): View
    {
        $jobId = $request->input("dispatch") ?? null;
        if ($jobId !== null) {
            $job = new InsertInDbJob($jobId);
            $this->dispatch($job);

            return $this->getView("Adding item '$jobId' to queue");
        }

        if ($request->has("queue")) {

            /**
             * @var RedisQueue $redisQueue
             */
            $redisQueue = $queueManager->connection();
            $redis =  $redisQueue->getRedis()->connection();
            $queueItems = $redis->lRange("queues:default", 0, 99999);

            $content = "Items in queue\n".var_export($queueItems, true);

            return $this->getView($content);
        }

        if ($request->has("db")) {
            $items = $databaseManager->select($databaseManager->raw("SELECT * FROM jobs"));

            $content = "Items in db\n".var_export($items, true);

            return $this->getView($content);
        }
        $content = <<<HTML
            <ul>
                <li><a href="?dispatch=foo">Dispatch job 'foo' to the queue.</a></li>
                <li><a href="?queue">Show the queue.</a></li>
                <li><a href="?db">Show the DB.</a></li>
            </ul>
            HTML;

        return $this->getView($content);
    }

    private function getView(string $content): View
    {
        return view('home')->with(["content" => $content]);
    }
}

```

Its content is displayed via the `home` view located at `resources/views/home.blade.php`:

```
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
    {!! $content !!}
    </body>
</html>
```

The controller is added as a route in `routes/web.php`:

```php
Route::get('/', \App\Http\Controllers\HomeController::class)->name("home");
```

<!-- generated -->
<a id='commands'> </a>
<!-- /generated -->

### Commands

We will replace the `setup.php` script with a `SetupDbCommand` that is located at  `app/Commands/SetupDbCommand.php`

```php
class SetupDbCommand extends Command
{
    /**
     * @var string
     */
    protected $name = "app:setup-db";

    /**
     * @var string
     */
    protected $description = "Run the application database setup";

    protected function getOptions(): array
    {
        return [
            [
                "drop",
                null,
                InputOption::VALUE_NONE,
                "If given, the existing database tables are dropped and recreated.",
            ],
        ];
    }

    public function handle()
    {
        $drop = $this->option("drop");
        if ($drop) {
            $this->info("Dropping all database tables...");

            $this->call(WipeCommand::class);

            $this->info("Done.");
        }

        $this->info("Running database migrations...");

        $this->call(MigrateCommand::class);

        $this->info("Done.");
    }
}
```

Register it the `AppServiceProvider` in `app/Providers/AppServiceProvider.php`

```php
    public function register()
    {
        $this->commands([
            \App\Commands\SetupDbCommand::class
        ]);
    }
```

and update the `setup-db` target in `.make/01-00-application-setup.mk` to run the `artisan` Command

```Makefile
.PHONY: setup-db
setup-db: ## Setup the DB tables
	$(EXECUTE_IN_APPLICATION_CONTAINER) php artisan app:setup-db $(ARGS);
```

We will also create a migration for the `jobs` table in  `database/migrations/2022_02_10_000000_create_jobs_table.php`:

```php
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('jobs', function (Blueprint $table) {
            $table->id();
            $table->string('value');
        });
    }
};
```

<!-- generated -->
<a id='jobs-and-workers'> </a>
<!-- /generated -->

### Jobs and workers

We will replace the `worker.php` script with `InsertInDbJob` located at  `app/Jobs/InsertInDbJob.php`

```php
class InsertInDbJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public function __construct(
        public readonly string $jobId
    ) {
    }

    public function handle(DatabaseManager $databaseManager)
    {
        $databaseManager->insert("INSERT INTO `jobs`(value) VALUES(?)", [$this->jobId]);
    }
}
```

though this will "only" handle the insertion part. For the worker itself we will use the native `\Illuminate\Queue\Console\WorkCommand` via

```
php artisan queue:work
```

We need to adjust the `.docker/images/php/worker/Dockerfile` and change

```
ARG PHP_WORKER_COMMAND="php $APP_CODE_PATH/worker.php"
```

to

```
ARG PHP_WORKER_COMMAND="php $APP_CODE_PATH/artisan queue:work"
```

Since this change takes place directly in the Dockerfile, we must now rebuild the image 

```
$ make docker-build-image DOCKER_SERVICE_NAME=php-worker
```

and restart it

```
$ make docker-up
```

<!-- generated -->
<a id='tests'> </a>
<!-- /generated -->

### Tests

I'd also like to take this opportunity to add a `Feature` test for the `HomeController` at `tests/Feature/App/Http/Controllers/HomeControllerTest.php`:

```php
class HomeControllerTest extends TestCase
{
    public function setUp(): void
    {
        parent::setUp();

        $this->setupDatabase();
        $this->setupQueue();
    }

    /**
     * @dataProvider __invoke_dataProvider
     */
    public function test___invoke(array $params, string $expected): void
    {
        $urlGenerator = $this->getDependency(UrlGenerator::class);

        $url = $urlGenerator->route("home", $params);

        $response = $this->get($url);

        $response
            ->assertStatus(200)
            ->assertSee($expected, false)
        ;
    }

    public function __invoke_dataProvider(): array
    {
        return [
            "default"           => [
                "params"   => [],
                "expected" => <<<TEXT
                        <li><a href="?dispatch=foo">Dispatch job 'foo' to the queue.</a></li>
                        <li><a href="?queue">Show the queue.</a></li>
                        <li><a href="?db">Show the DB.</a></li>
                    TEXT
                ,
            ],
            "database is empty" => [
                "params"   => ["db"],
                "expected" => <<<TEXT
                        Items in db
                    array (
                    )
                    TEXT
                ,
            ],
            "queue is empty"    => [
                "params"   => ["queue"],
                "expected" => <<<TEXT
                        Items in queue
                    array (
                    )
                    TEXT
                ,
            ],
        ];
    }

    public function test_shows_existing_items_in_database(): void
    {
        $databaseManager = $this->getDependency(DatabaseManager::class);

        $databaseManager->insert("INSERT INTO `jobs` (id, value) VALUES(1, 'foo');");

        $urlGenerator = $this->getDependency(UrlGenerator::class);

        $params = ["db"];
        $url    = $urlGenerator->route("home", $params);

        $response = $this->get($url);

        $expected = <<<TEXT
                Items in db
            array (
              0 => 
              (object) array(
                 'id' => 1,
                 'value' => 'foo',
              ),
            )
            TEXT;

        $response
            ->assertStatus(200)
            ->assertSee($expected, false)
        ;
    }

    public function test_shows_existing_items_in_queue(): void
    {
        $queueManager = $this->getDependency(QueueManager::class);

        $job = new InsertInDbJob("foo");
        $queueManager->push($job);

        $urlGenerator = $this->getDependency(UrlGenerator::class);

        $params = ["queue"];
        $url    = $urlGenerator->route("home", $params);

        $response = $this->get($url);

        $expectedJobsCount = <<<TEXT
                Items in queue
            array (
              0 => '{
            TEXT;

        $expected = <<<TEXT
            \\\\"jobId\\\\";s:3:\\\\"foo\\\\";
            TEXT;

        $response
            ->assertStatus(200)
            ->assertSee($expectedJobsCount, false)
            ->assertSee($expected, false)
        ;
    }
}
```

The test checks the database as well as the queue and uses the helper methods  `$this->setupDatabase()` and `$this->setupQueue()` that I defined in the base test case at `tests/TestCase.php` as follows

```php
   /**
     * @template T
     * @param class-string<T> $className
     * @return T
     */
    protected function getDependency(string $className)
    {
        return $this->app->get($className);
    }

    protected function setupDatabase(): void
    {
        $databaseManager = $this->getDependency(DatabaseManager::class);

        $actualConnection  = $databaseManager->getDefaultConnection();
        $testingConnection = "testing";
        if ($actualConnection !== $testingConnection) {
            throw new RuntimeException("Database tests are only allowed to run on default connection '$testingConnection'. The current default connection is '$actualConnection'.");
        }

        $this->ensureDatabaseExists($databaseManager);

        $this->artisan(SetupDbCommand::class, ["--drop" => true]);
    }

    protected function setupQueue(): void
    {
        $queueManager = $this->getDependency(QueueManager::class);

        $actualDriver  = $queueManager->getDefaultDriver();
        $testingDriver = "testing";
        if ($actualDriver !== $testingDriver) {
            throw new RuntimeException("Queue tests are only allowed to run on default driver '$testingDriver'. The current default driver is '$actualDriver'.");
        }

        $this->artisan(ClearCommand::class);
    }

    protected function ensureDatabaseExists(DatabaseManager $databaseManager): void
    {
        $connection = $databaseManager->connection();

        try {
            $connection->getPdo();
        } catch (PDOException $e) {
            // e.g. SQLSTATE[HY000] [1049] Unknown database 'testing'
            if ($e->getCode() !== 1049) {
                throw $e;
            }
            $config             = $connection->getConfig();
            $config["database"] = "";

            $connector = new MySqlConnector();
            $pdo       = $connector->connect($config);
            $database  = $connection->getDatabaseName();
            $pdo->exec("CREATE DATABASE IF NOT EXISTS `{$database}`;");
        }
    }
```

The methods ensure that the tests are only executed if the proper database connection and queue  driver is configured. This is done through environment variables and I like using [a dedicated `.env` file located at `.env.testing`](https://laravel.com/docs/9.x/testing#the-env-testing-environment-file)   for all testing `ENV` values instead of defining them in the `phpunit.xml` config file via `<env>`  elements:

```dotenv
# File: .env.testing

DB_CONNECTION=testing
DB_DATABASE=testing
QUEUE_CONNECTION=testing
REDIS_DB=1000
```

The corresponding connections have to be configured in the `config` files

```php
# File: config/database.php

return [
// ...
    'connections' => [
// ...
        'testing' => [
            'driver' => 'mysql',
            'url' => env('DATABASE_URL'),
            'host' => env('DB_HOST'),
            'port' => env('DB_PORT', '3306'),
            'database' => env('DB_DATABASE', 'testing'),
            'username' => env('DB_USERNAME'),
            'password' => env('DB_PASSWORD', ''),
            'unix_socket' => env('DB_SOCKET', ''),
            'charset' => 'utf8mb4',
            'collation' => 'utf8mb4_unicode_ci',
            'prefix' => '',
            'prefix_indexes' => true,
            'strict' => true,
            'engine' => null,
            'options' => extension_loaded('pdo_mysql') ? array_filter([
                PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
            ]) : [],
        ],
    ],
// ...
    'redis' => [
// ...
        'testing' => [
            'url' => env('REDIS_URL'),
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD'),
            'port' => env('REDIS_PORT', '6379'),
            'database' => env('REDIS_DB', '1000'),
        ],
    ],
];
```

```php
# File: config/queue.php

return [
// ...

    'connections' => [
// ...
        'testing' => [
            'driver' => 'redis',
            'connection' => 'testing', // => refers to the "database.redis.testing" config entry
            'queue' => env('REDIS_QUEUE', 'default'),
            'retry_after' => 90,
            'block_for' => null,
            'after_commit' => false,
        ],
    ],
];
```

The tests can be executed via `make test`

```
$ make test
ENV=local TAG=latest DOCKER_REGISTRY=docker.io DOCKER_NAMESPACE=dofroscra APP_USER_NAME=application APP_GROUP_NAME=application docker-compose -p dofroscra_local --env-file ./.docker/.env -f ./.docker/docker-compose/docker-compose.yml -f ./.docker/docker-compose/docker-compose.local.yml exec -T --user application php-worker vendor/bin/phpunit -c phpunit.xml
PHPUnit 9.5.13 by Sebastian Bergmann and contributors.

.......                                                             7 / 7 (100%)

Time: 00:02.709, Memory: 28.00 MB

OK (7 tests, 13 assertions)
```

<!-- generated -->
<a id='makefile-updates'> </a>
<!-- /generated -->

## Makefile updates

<!-- generated -->
<a id='clearing-the-queue'> </a>
<!-- /generated -->

### Clearing the queue

For convenience while testing I added a make target to clear all items from the queue in  `.make/01-01-application-commands.mk`

```Makefile
.PHONY: clear-queue
clear-queue: ## Clear the job queue
	$(EXECUTE_IN_APPLICATION_CONTAINER) php artisan queue:clear $(ARGS)
```

<!-- generated -->
<a id='running-the-poc'> </a>
<!-- /generated -->

## Running the POC

Since the POC only uses `make` targets and we basically just "refactored" them, there is no  modification necessary to make the existing `test.sh` work:


```
$ bash test.sh


  Building the docker setup


//...


  Starting the docker setup


//...


  Clearing DB


ENV=local TAG=latest DOCKER_REGISTRY=docker.io DOCKER_NAMESPACE=dofroscra APP_USER_NAME=application APP_GROUP_NAME=application docker-compose -p dofroscra_local --env-file ./.docker/.env -f ./.docker/docker-compose/docker-compose.yml -f ./.docker/docker-compose/docker-compose.local.yml exec -T --user application application php artisan app:setup-db --drop;
Dropping all database tables...
Dropped all tables successfully.
Done.
Running database migrations...
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table (64.04ms)
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table (50.06ms)
Migrating: 2019_08_19_000000_create_failed_jobs_table
Migrated:  2019_08_19_000000_create_failed_jobs_table (58.61ms)
Migrating: 2019_12_14_000001_create_personal_access_tokens_table
Migrated:  2019_12_14_000001_create_personal_access_tokens_table (94.03ms)
Migrating: 2022_02_10_000000_create_jobs_table
Migrated:  2022_02_10_000000_create_jobs_table (31.85ms)
Done.


  Stopping workers


ENV=local TAG=latest DOCKER_REGISTRY=docker.io DOCKER_NAMESPACE=dofroscra APP_USER_NAME=application APP_GROUP_NAME=application docker-compose -p dofroscra_local --env-file ./.docker/.env -f ./.docker/docker-compose/docker-compose.yml -f ./.docker/docker-compose/docker-compose.local.yml exec -T --user application php-worker supervisorctl stop worker:*;
worker:worker_00: stopped
worker:worker_01: stopped
worker:worker_02: stopped
worker:worker_03: stopped


  Ensuring that queue and db are empty


<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
    Items in queue
array (
)
    </body>
</html>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
    Items in db
array (
)
    </body>
</html>


  Dispatching a job 'foo'


<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
    Adding item 'foo' to queue
    </body>
</html>


  Asserting the job 'foo' is on the queue


<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
    Items in queue
array (
  0 => '{"uuid":"7ea63590-2a86-4739-abf8-8a059d41bd60","displayName":"App\\\\Jobs\\\\InsertInDbJob","job":"Illuminate\\\\Queue\\\\CallQueuedHandler@call","maxTries":null,"maxExceptions":null,"failOnTimeout":false,"backoff":null,"timeout":null,"retryUntil":null,"data":{"commandName":"App\\\\Jobs\\\\InsertInDbJob","command":"O:22:\\"App\\\\Jobs\\\\InsertInDbJob\\":11:{s:5:\\"jobId\\";s:3:\\"foo\\";s:3:\\"job\\";N;s:10:\\"connection\\";N;s:5:\\"queue\\";N;s:15:\\"chainConnection\\";N;s:10:\\"chainQueue\\";N;s:19:\\"chainCatchCallbacks\\";N;s:5:\\"delay\\";N;s:11:\\"afterCommit\\";N;s:10:\\"middleware\\";a:0:{}s:7:\\"chained\\";a:0:{}}"},"id":"I3k5PNyGZc6Z5XWCC4gt0qtSdqUZ84FU","attempts":0}',
)
    </body>
</html>


  Starting the workers


ENV=local TAG=latest DOCKER_REGISTRY=docker.io DOCKER_NAMESPACE=dofroscra APP_USER_NAME=application APP_GROUP_NAME=application docker-compose -p dofroscra_local --env-file ./.docker/.env -f ./.docker/docker-compose/docker-compose.yml -f ./.docker/docker-compose/docker-compose.local.yml exec -T --user application php-worker supervisorctl start worker:*;
worker:worker_00: started
worker:worker_01: started
worker:worker_02: started
worker:worker_03: started


  Asserting the queue is now empty


<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
    Items in queue
array (
)
    </body>
</html>


  Asserting the db now contains the job 'foo'


<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
    Items in db
array (
  0 =>
  (object) array(
     'id' => 1,
     'value' => 'foo',
  ),
)
    </body>
</html>

```

<!-- generated -->
<a id='wrapping-up'> </a>
<!-- /generated -->

## Wrapping up

Congratulations, you made it! If some things are not completely clear by now, don't hesitate to leave a comment. Laravel 9 should now be up and running on the previously set up docker infrastructure.

In the next part of this tutorial, we will [Set up PHP QA tools and control them via make](https://www.pascallandau.com/blog/php-qa-tools-make-docker/).

Please subscribe to the [RSS feed](https://www.pascallandau.com/feed.xml) or [via email](https://www.pascallandau.com/blog/#newsletter) to get automatic notifications when this next part comes out :)