From Monolith to Microservices: A Practical Guide to Migrating Laravel Applications with Docker and AWS ECS
Deconstructing the Monolith: Identifying Service Boundaries
The first, and arguably most critical, step in migrating a Laravel monolith to microservices is identifying logical service boundaries. This isn’t a purely technical exercise; it requires a deep understanding of the business domain. We’re looking for cohesive units of functionality that can operate independently. For a typical e-commerce Laravel application, common candidates include:
- User Management: Authentication, authorization, profile management.
- Product Catalog: Product details, categories, search indexing.
- Order Processing: Cart management, checkout, payment gateway integration.
- Inventory Management: Stock levels, warehouse operations.
- Notification Service: Email, SMS, push notifications.
A good heuristic is to consider whether a functional area has its own distinct data model, its own set of business rules, and whether it can be reasonably updated and deployed without impacting other areas. Avoid creating services that are too granular (leading to excessive inter-service communication overhead) or too coarse (defeating the purpose of microservices).
Containerizing the Monolith with Docker
Before we can break apart the monolith, we need to containerize it. This provides a consistent, isolated environment for development, testing, and deployment. We’ll use Docker to achieve this. A typical Dockerfile for a Laravel application might look like this:
Create a file named Dockerfile in the root of your Laravel project:
# Use an official PHP runtime as a parent image
FROM php:8.2-fpm
# Set the working directory in the container
WORKDIR /var/www/html
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libonig-dev \
libxml2-dev \
zip \
acl \
supervisor \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd \
&& docker-php-ext-install pdo pdo_mysql zip exif pcntl opcache
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Copy application code
COPY . .
# Set permissions for storage and bootstrap/cache
RUN chown -R www-data:www-data storage bootstrap/cache && chmod -R 775 storage bootstrap/cache
# Install Composer dependencies
RUN composer install --no-dev --optimize-autoloader
# Copy supervisor configuration
COPY docker/supervisor/laravel-worker.conf /etc/supervisor/conf.d/laravel-worker.conf
COPY docker/supervisor/nginx.conf /etc/supervisor/conf.d/nginx.conf
# Expose port 80 for Nginx
EXPOSE 80
# Start supervisor to manage Nginx and queue worker
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
We also need a supervisor configuration to manage Nginx and potentially a queue worker. Create a directory docker/supervisor and add the following files:
[program:laravel-worker] process_name=%(program_name)s_%(process_num)02d command=php artisan queue:work --tries=3 --timeout=300 autostart=true autorestart=true user=www-data numprocs=4 redirect_stderr=true stdout_logfile=/var/log/supervisor/queue-worker.log stderr_logfile=/var/log/supervisor/queue-worker.err.log [program:nginx] command=/usr/sbin/nginx -g "daemon off;" autostart=true autorestart=true user=root stdout_logfile=/var/log/supervisor/nginx.log stderr_logfile=/var/log/supervisor/nginx.err.log
server {
listen 80;
index index.php index.html;
root /var/www/html/public;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
location ~ /\.ht {
deny all;
}
}
And a basic supervisord.conf in the root of your project:
[supervisord] nodaemon=true user=root [include] files = /etc/supervisor/conf.d/*.conf
With these files in place, you can build and run your containerized monolith:
docker build -t my-laravel-app . docker run -d -p 8080:80 my-laravel-app
Extracting the First Microservice: User Management
Let’s start by extracting the User Management service. This involves:
- Creating a new, independent Laravel project for the User service.
- Migrating relevant models, controllers, routes, and middleware.
- Setting up a separate database for this service.
- Defining an API for other services to interact with.
1. New Laravel Project for User Service:
composer create-project --prefer-dist laravel/laravel user-service cd user-service
2. Migrating Code: Copy relevant files from your monolith (e.g., app/Models/User.php, authentication controllers, routes related to users) into the new user-service project. You’ll need to adjust namespaces and dependencies as necessary. For instance, if your monolith uses a custom User model, ensure it’s correctly placed and configured in the new service.
3. Database Setup: Configure a new database for the User service. This could be a separate MySQL instance, a PostgreSQL database, or even a NoSQL solution depending on the service’s needs. Update the .env file in the user-service project accordingly.
DB_CONNECTION=mysql DB_HOST=user-db DB_PORT=3306 DB_DATABASE=user_db DB_USERNAME=user_db_user DB_PASSWORD=user_db_password
Run migrations for the new service:
php artisan migrate
4. API Definition: Define API routes for user registration, login, profile updates, etc. Use Laravel’s API resources to format responses.
// routes/api.php in user-service
use App\Http\Controllers\AuthController;
use App\Http\Controllers\UserController;
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:api')->group(function () {
Route::get('/user', [UserController::class, 'show']);
Route::put('/user', [UserController::class, 'update']);
});
5. Containerizing the User Service: Create a Dockerfile for the User service, similar to the monolith’s but without Nginx and potentially without the queue worker if it’s not needed for this specific service.
FROM php:8.2-fpm
WORKDIR /var/www/html
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libonig-dev \
libxml2-dev \
zip \
acl \
&& rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd \
&& docker-php-ext-install pdo pdo_mysql zip exif pcntl opcache
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
COPY . .
RUN chown -R www-data:www-data storage bootstrap/cache && chmod -R 775 storage bootstrap/cache
RUN composer install --no-dev --optimize-autoloader
CMD ["php-fpm"]
Orchestrating with AWS ECS and Docker Compose
AWS Elastic Container Service (ECS) is a powerful, scalable container orchestration service. We’ll use it to deploy and manage our microservices. For local development and testing, Docker Compose is invaluable.
1. Docker Compose for Local Development: Create a docker-compose.yml file in the root of your project directory (or a dedicated directory for orchestration). This file will define your services, networks, and volumes.
version: '3.8'
services:
nginx-proxy:
image: nginx:latest
container_name: nginx_proxy
ports:
- "80:80"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d
depends_on:
- user-service
- monolith-app # Assuming you'll keep the monolith running for a while
networks:
- app-network
user-service:
build:
context: ./user-service
dockerfile: Dockerfile
container_name: user_service
ports:
- "9000:9000" # If user-service exposes an API port
volumes:
- ./user-service:/var/www/html
networks:
- app-network
environment:
DB_HOST: user-db
# Other environment variables for user-service
user-db:
image: mysql:8.0
container_name: user_db
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: user_db
MYSQL_USER: user_db_user
MYSQL_PASSWORD: user_db_password
volumes:
- user_db_data:/var/lib/mysql
networks:
- app-network
monolith-app:
build:
context: . # Assuming monolith is in the root
dockerfile: Dockerfile
container_name: monolith_app
ports:
- "8080:80"
volumes:
- .:/var/www/html
networks:
- app-network
environment:
DB_HOST: monolith-db
# Other environment variables for monolith
monolith-db:
image: mysql:8.0
container_name: monolith_db
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: monolith_db
MYSQL_USER: monolith_db_user
MYSQL_PASSWORD: monolith_db_password
volumes:
- monolith_db_data:/var/lib/mysql
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
user_db_data:
monolith_db_data:
You’ll also need an Nginx configuration in ./nginx/conf.d/default.conf to route traffic:
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://monolith-app:80; # Default to monolith
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/users/ {
rewrite ^/api/users/(.*)$ /$1 break;
proxy_pass http://user-service:9000; # Assuming user-service API is on port 9000
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Add more locations for other microservices as they are extracted
}
Run your local environment:
docker-compose up -d
2. AWS ECS Deployment:
- Create Task Definitions: For each microservice (and the monolith initially), create an ECS Task Definition. This specifies the Docker image, CPU/memory requirements, environment variables, and port mappings.
- Create Services: Within an ECS Cluster, create a Service for each Task Definition. This manages the desired number of tasks (containers) and how they are launched.
- Load Balancer: Use an Application Load Balancer (ALB) to distribute traffic across your ECS services. Configure listeners and target groups for each service.
- API Gateway (Optional but Recommended): For more complex routing, authentication, and rate limiting, consider using AWS API Gateway in front of your ALB or directly to your services.
- Database Management: For production, use managed database services like AWS RDS or Aurora instead of running databases within ECS containers.
When deploying to ECS, you’ll typically push your Docker images to Amazon ECR (Elastic Container Registry). Your ECS Task Definitions will then reference these ECR images.
Inter-Service Communication Strategies
As you break down the monolith, services will need to communicate. Several patterns exist:
- Synchronous (REST/gRPC): Services make direct HTTP requests (using libraries like Guzzle in PHP) or RPC calls to each other. This is simple but can lead to tight coupling and cascading failures.
- Asynchronous (Message Queues): Services communicate via a message broker like Amazon SQS, RabbitMQ, or Kafka. The monolith (or a dedicated service) publishes events (e.g., ‘OrderPlaced’), and other services subscribe to these events. This promotes loose coupling and resilience.
- Shared Database (Anti-Pattern): Services directly accessing each other’s databases is generally discouraged as it creates strong coupling and hinders independent evolution.
For the User service, other services might need to verify user authentication. Instead of the monolith handling all auth, the User service can expose an endpoint like GET /api/users/me that returns user details based on a token. The calling service (e.g., Order service) would then forward the token to the User service.
Consider implementing a centralized authentication service or using JWTs (JSON Web Tokens) signed by a shared secret, allowing services to validate tokens without direct calls to the User service for every request.
Phased Migration and Rollback Strategies
Migrating a monolith is a marathon, not a sprint. A phased approach is crucial:
- Strangler Fig Pattern: Gradually replace pieces of the monolith’s functionality with new microservices. Route traffic for specific features to the new service while the monolith continues to handle the rest.
- Feature Toggles: Use feature flags to enable/disable microservices or specific functionalities, allowing for controlled rollouts and quick rollbacks.
- Data Synchronization: During the transition, you might need mechanisms to synchronize data between the monolith’s database and the new microservice databases. This can be complex and may involve event sourcing or batch jobs.
- Automated Testing: Robust unit, integration, and end-to-end tests are non-negotiable. They provide confidence during refactoring and ensure that new services behave as expected.
- Monitoring and Alerting: Implement comprehensive monitoring (e.g., CloudWatch, Prometheus, Grafana) for all services to quickly detect and diagnose issues.
Rollback is as important as deployment. Ensure you have automated pipelines that can quickly revert to a previous stable state, whether it’s redeploying the monolith or rolling back a specific microservice.