Docker Container Roles Pattern for Laravel Apps
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.
Example application
Demo code for this application: https://github.com/dkhorev/docker-container-roles-pattern-laravel
Backend API route handler.
Queues a job to process (event).
This is what the EventServiceProvider
event bus looks like.
Finally, the listener just adds A + B and stores the result in a log file. ShouldQueue interface makes it run async via Horizon.
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 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.
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
.
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
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.
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
Now check what the app’s API instance is doing: docker logs docker-container-roles-pattern-laravel_app_1
Finally, checking the migrator instance docker logs docker-container-roles-pattern-laravel_migrator_1
Now let’s see the log file in storage/logs
.
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