Run Laravel 9 on Docker in 2022

Run Laravel 9 on Docker in 2022

Table of contents

Introduction

Install extensions

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

# ...

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

Install Laravel

composer create-project --prefer-dist laravel/laravel /tmp/laravel "9.*" --no-install --no-scripts
make execute-in-container DOCKER_SERVICE_NAME="application" COMMAND='composer create-project --prefer-dist laravel/laravel /tmp/laravel "9.*" --no-install --no-scripts'
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
make composer ARGS=install
make composer ARGS="run-script post-create-project-cmd"

Update the PHP POC

config

database connection

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

queue connection

QUEUE_CONNECTION=redis

REDIS_HOST=redis
REDIS_PASSWORD=secret_redis_password

Controllers

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]);
}
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
{!! $content !!}
</body>
</html>
Route::get('/', \App\Http\Controllers\HomeController::class)->name("home");

Commands

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.");
}
}
public function register()
{
$this->commands([
\App\Commands\SetupDbCommand::class
]);
}
.PHONY: setup-db
setup-db: ## Setup the DB tables
$(EXECUTE_IN_APPLICATION_CONTAINER) php artisan app:setup-db $(ARGS);
return new class extends Migration
{
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('value');
});
}
};

Jobs and workers

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]);
}
}
php artisan queue:work
ARG PHP_WORKER_COMMAND="php $APP_CODE_PATH/worker.php"
ARG PHP_WORKER_COMMAND="php $APP_CODE_PATH/artisan queue:work"
$ make docker-build-image DOCKER_SERVICE_NAME=php-worker
$ make docker-up

Tests

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)
;
}
}
/**
* @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}`;");
}
}
# File: .env.testing

DB_CONNECTION=testing
DB_DATABASE=testing
QUEUE_CONNECTION=testing
REDIS_DB=1000
# 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'),
],
],
];
# 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,
],
],
];
$ 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)

Makefile updates

Clearing the queue

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

Running the POC

$ 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>

Wrapping up

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store