Docker Container Roles Pattern for Laravel Apps

Dmitry Khorev
5 min readAug 27, 2022

--

Docker and Laravel development
Docker and Laravel development

In this story, I will show you how I build small-to-large sized Laravel applications using a hybrid monolith pattern.

I have one repository, and one docker container with the app, but each container is a separate application, almost like it was deployed from another repo!

This is achieved by container role pattern and Laravel’s ecosystem packages and internal commands.

Problem

When developing a complex Laravel project, transitioning from MVP to enterprise-level software you often face the challenge of scaling your apps.

Your workflows transition from synchronous to asynchronous, and you will add a scheduler, and asynchronous workers like queue:work or Horizon. You need to run migrations when deploying updates. You need to serve API and Frontend separately.

Then you will need to scale each of the tasks individually.

Objective

Launch event-driven monolith Laravel application using just a single container image.

My application will have an API route for math operation add and will consume two arguments A and B.

Math add operation and its arguments will be queued and processed by a background worker node. The result will be stored in a log file (real-world — database, external service, WebSocket broadcast — whatever).

This application will have one cron job that runs minutely and just logs the time.

We will also have one instance responsible for applying our schema changes if they ever happen — migrator app.

Solution

We’ll build a container that can run our app and its dependencies, but it will have a different entry point based on the container’s role in the stack.

Our example app will have those roles:

  • API service to ingest data
  • Service to manage queues
  • Service to run cron jobs
  • Service to apply DB migrations

Our goal is to build similar architecture but with only 1 Laravel application. Thanks to all the tools available, we can do that.

Target Laravel stack overview
Target Laravel stack overview

Example application

Demo code for this application: https://github.com/dkhorev/docker-container-roles-pattern-laravel

Backend API route handler.

Receive A and B and pass them to an event.

Queues a job to process (event).

An event is just a data transfer object for received arguments.

This is what the EventServiceProviderevent bus looks like.

MathAddListener will react to MathAddEvent being emitted.

Finally, the listener just adds A + B and stores the result in a log file. ShouldQueue interface makes it run async via Horizon.

Adds A and B arguments received via API and stores them.

And, finally, a sample queued cron job for our app, that will be running minutely by our container. Also has a ShouldQueue interface that makes it run async via Horizon.

This cron job just logs a line to file.

This example is simplified to keep dependencies for this demo short.

In real life scenario, you can imagine, jobs and events being a bunch of SQL queries and some complex processing.

When developing middle-size and bigger projects you always will have something similar to this architecture (and usually a more complex series of events), where your API just receives data and background workers process it.

Building the container

Dockerfile for the app looks like this.

We use a multi-stage build to keep the final image smaller. First composer dependencies are installed (including dev for this demo). Second, they are copied over to a fresh image.

The interesting part is we provide a custom entry point file on lines 54 and 62.

Container entry point

The custom entry point file looks like this.

First, it will try and access ENV vars named CONTAINER_ROLE and APP_ENV.

If APP_ENV is set to production it will execute Laravel’s cache commands.

Next, we have effectively a switch statement:

  • role: app — starts the php-fpm process
  • role: migrator — just runs a migrations script
  • role: scheduler — runs Laravel’s schedule script in an infinite loop
  • role: horizon — starts a Horizon instance workers
  • default: just exit as no role specified

Demo docker-compose file

This is what our service map looks like with the docker-compose file.

Docker compose yaml for deploying Laravel app

Notice every service that has image: role-app:latest has a CONTAINER_ROLE set.

App and Horizon services are replicated to run 2 instances each to simulate scaled deployment.

Running the demo

I recommend building and pulling the required images first.

We can start our stack by running this command:

env $(cat .env | grep ^[A-Z] | xargs) docker-compose up -d

It will populate env vars from your local .env so docker-compose can use them. The main interest is the secret APP_KEY.

Docker stack is now running with required setup
Docker stack is now running with the required setup

We can trigger our API with simple curl requests via console:

curl http://127.0.0.1:8181/api/1+2

curl http://127.0.0.1:8181/api/1+3

curl http://127.0.0.1:8181/api/2+3

curl http://127.0.0.1:8181/api/5+1

Now check horizon logs with docker logs docker-container-roles-pattern-laravel_horizon_1 or docker logs docker-container-roles-pattern-laravel_horizon_2. Jobs are distributed between them so your results may vary.

Jobs processed by the backend services
Jobs were processed by the backend services

As you can see our app has already processed a few scheduler jobs and API requests.

Check scheduler logs: docker logs docker-container-roles-pattern-laravel_scheduler_1

Scheduler is only releasing jobs to a queue every minute (they have ShouldQueue interface and go directly to Horizon workers)
The scheduler is only releasing jobs to a queue every minute (they have a ShouldQueue interface and go directly to Horizon workers)

Now check what the app’s API instance is doing: docker logs docker-container-roles-pattern-laravel_app_1

API instance only receives requests
API instance only receives requests

Finally, checking the migrator instance docker logs docker-container-roles-pattern-laravel_migrator_1

As expected this container crushed because it has a hard dependency on DB being available and we don’t have this in our stack. We don’t need migrations for our app, so this was started just to demonstrate that migrator role works.
As expected this container crushed because it has a hard dependency on DB being available and we don’t have this in our stack. We don’t need migrations for our app, so this was started just to demonstrate that the migrator role works.

Now let’s see the log file in storage/logs.

All expected operations are stored in the log file
All expected operations are stored in the log file

Conclusion

We have seen from the logs and screenshots above - our monolith Laravel application can be launched with separate scalable services by just passing CONTAINER_ROLE to each of them.

This approach has its drawbacks as you still keep the monolith and the codebase can grow quite large. But if you have a strong team, that can extend this project in a modular way, then it can bring you very far without the complexity of managing microservice deploys/architecture/communication.

Effectively you have a set of microservices running from a single monolith repo:

  • API layer
  • Workers pool (horizon)
  • Cron scheduler
  • DB migrator (you need to start manually each deployment — out of scope for this article tho)

Possible to add with the same approach:

  • Frontend layer (php or nginx depending if you have SPA vs. Blade)
  • Any container to run custom Laravel commands
  • Provide secret keys only to containers that need them

Demo code for this application: https://github.com/dkhorev/docker-container-roles-pattern-laravel

If you’re interested in Node.js, then check this article that has a demo of the same pattern applied with NestJS example: https://medium.com/@dkhorev/docker-container-roles-pattern-for-nestjs-apps-ca8b07a08a9a

--

--

Dmitry Khorev

Sharing my experience in software engineering (NestJS, Laravel, System Design, DevOps)