From Monolith to Microservices: A Practical Guide to Migrating Legacy PHP Applications with Laravel, Docker, and AWS Lambda
Deconstructing the Monolith: Identifying Service Boundaries
The first, and arguably most critical, step in migrating a legacy PHP monolith to a microservices architecture is to identify logical service boundaries. This isn’t about arbitrarily splitting code; it’s about understanding the core business capabilities the application provides. We’re looking for cohesive units of functionality that can operate independently. Common candidates include:
- User Management (Authentication, Authorization, Profiles)
- Product Catalog (Inventory, Pricing, Details)
- Order Processing (Cart, Checkout, Payment Gateway Integration)
- Notification Service (Email, SMS)
- Reporting/Analytics
A good heuristic is the “bounded context” concept from Domain-Driven Design. If a module within the monolith has its own distinct set of entities, behaviors, and data, it’s a strong candidate for extraction. Tools like static analysis (e.g., PHPStan with appropriate rulesets) can help identify dependencies and coupling, but human domain expertise is indispensable here. We’ll use a hypothetical e-commerce monolith as our example, focusing on extracting the ‘Product Catalog’ service.
Extracting the Product Catalog Service with Laravel and Docker
Let’s assume our monolith has a monolithic database schema and a set of PHP classes handling product data. We’ll create a new, independent Laravel application for our Product Catalog service. This new application will have its own database schema, ideally a subset of the original, focusing only on product-related data.
First, set up the new Laravel project:
composer create-project laravel/laravel product-catalog-service cd product-catalog-service
Next, define the database schema for this service. For simplicity, let’s assume a `products` table. We’ll create a migration:
php artisan make:migration create_products_table --create=products
Edit the generated migration file (`database/migrations/YYYY_MM_DD_HHMMSS_create_products_table.php`):
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('sku')->unique();
$table->string('name');
$table->text('description')->nullable();
$table->decimal('price', 10, 2);
$table->integer('stock_quantity')->default(0);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('products');
}
};
Create a `Product` Eloquent model (`app/Models/Product.php`):
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
use HasFactory;
protected $fillable = [
'sku',
'name',
'description',
'price',
'stock_quantity',
];
// Define relationships if any, e.g., to categories, brands, etc.
}
Now, create a controller to handle API requests for products (`app/Http/Controllers/ProductController.php`):
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class ProductController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(): JsonResponse
{
$products = Product::all();
return response()->json($products);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request): JsonResponse
{
$validatedData = $request->validate([
'sku' => 'required|unique:products|max:255',
'name' => 'required|max:255',
'description' => 'nullable',
'price' => 'required|numeric|min:0',
'stock_quantity' => 'required|integer|min:0',
]);
$product = Product::create($validatedData);
return response()->json($product, 201);
}
/**
* Display the specified resource.
*/
public function show(string $id): JsonResponse
{
$product = Product::findOrFail($id);
return response()->json($product);
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id): JsonResponse
{
$product = Product::findOrFail($id);
$validatedData = $request->validate([
'sku' => 'required|max:255|unique:products,sku,' . $product->id,
'name' => 'required|max:255',
'description' => 'nullable',
'price' => 'required|numeric|min:0',
'stock_quantity' => 'required|integer|min:0',
]);
$product->update($validatedData);
return response()->json($product);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(string $id): JsonResponse
{
$product = Product::findOrFail($id);
$product->delete();
return response()->json(null, 204);
}
}
Define the API routes in `routes/api.php`:
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ProductController;
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
Route::apiResource('products', ProductController::class);
To containerize this service, create a `Dockerfile` in the project root:
FROM php:8.2-fpm
WORKDIR /var/www/html
COPY . .
RUN apt-get update && apt-get install -y \
git \
curl \
libzip-dev \
unzip \
&& docker-php-ext-install zip \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
RUN composer install --no-dev --optimize-autoloader
EXPOSE 9000
CMD ["php-fpm"]
And a `docker-compose.yml` for local development:
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "9000:9000"
volumes:
- .:/var/www/html
depends_on:
- db
db:
image: mysql:8.0
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: product_catalog_db
MYSQL_USER: user
MYSQL_PASSWORD: password
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
db_data:
Configure your `.env` file for the database connection:
DB_CONNECTION=mysql DB_HOST=db DB_PORT=3306 DB_DATABASE=product_catalog_db DB_USERNAME=user DB_PASSWORD=password
Run the migrations:
docker-compose up -d docker-compose exec app php artisan migrate
Data Synchronization Strategy
Extracting the service is only half the battle; we need to ensure data consistency between the monolith and the new microservice. Several strategies exist:
- One-Time Data Migration: Suitable for initial setup or if the monolith’s product data is relatively static. This involves a script to export data from the monolith’s database and import it into the new service’s database.
- Dual Writes: When a product is updated in the monolith, it’s also sent to the new service’s API. This requires modifying the monolith’s code.
- Event-Driven Synchronization: The monolith publishes events (e.g., `ProductCreated`, `ProductUpdated`) to a message queue (like RabbitMQ or Kafka). The microservice subscribes to these events and updates its own database. This is the most robust and scalable approach for ongoing synchronization.
- Change Data Capture (CDC): Tools like Debezium can capture database changes from the monolith’s database and stream them to a message queue, which the microservice consumes. This decouples the synchronization from the monolith’s application code.
For our example, let’s outline an event-driven approach. We’ll assume the monolith can be modified to publish events. If not, CDC is the next best option.
Monolith Modification (Conceptual – PHP):
// In the monolith's Product model or repository after a save/update operation use App\Events\ProductUpdated; // Assuming an event system exists // ... after $product->save(); or $product->update([...]); event(new ProductUpdated($product->toArray()));
Microservice Event Listener (Laravel):
// Create an event listener for product updates
// php artisan make:listener ProcessProductUpdatedEvent --event=ProductUpdated
// In app/Listeners/ProcessProductUpdatedEvent.php
namespace App\Listeners;
use App\Events\ProductUpdated; // Assuming this event is defined in the microservice
use App\Models\Product;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class ProcessProductUpdatedEvent implements ShouldQueue
{
use InteractsWithQueue;
/**
* Handle the event.
*/
public function handle(ProductUpdated $event): void
{
$productData = $event->productData; // Assuming event passes an array
// Find or create the product in the microservice's database
Product::updateOrCreate(
['sku' => $productData['sku']], // Unique identifier
[
'name' => $productData['name'],
'description' => $productData['description'] ?? null,
'price' => $productData['price'],
'stock_quantity' => $productData['stock_quantity'],
// Timestamps will be handled by Eloquent if not provided
]
);
}
}
You would need to configure your Laravel application to use a queue driver (e.g., Redis, SQS) and ensure the monolith is publishing events to a compatible message broker that the microservice can consume.
Integrating with AWS Lambda and API Gateway
For serverless deployment and cost-efficiency, we can deploy our Product Catalog service to AWS Lambda. This requires adapting the Laravel application to run within the Lambda environment. The popular Bref project is an excellent tool for this.
1. Install Bref:**
composer require bref/bref bref/laravel-bridge
2. Configure `serverless.yml`:**
service: product-catalog-service
provider:
name: aws
runtime: php8.2 # Match your PHP version
region: us-east-1 # Your preferred AWS region
memorySize: 512 # Adjust as needed
timeout: 30 # Adjust as needed
environment:
APP_ENV: production
APP_URL: ${env:APP_URL, 'https://your-api-gateway-url.execute-api.us-east-1.amazonaws.com/prod'} # Set your API Gateway URL
APP_KEY: base64:YOUR_APP_KEY_HERE # Generate with php artisan key:generate --show
DB_CONNECTION: mysql
DB_HOST: your-rds-endpoint.rds.amazonaws.com # Your RDS endpoint
DB_PORT: 3306
DB_DATABASE: product_catalog_db
DB_USERNAME: your_db_user
DB_PASSWORD: your_db_password
iam:
role:
statements:
- Effect: Allow
Action:
- rds-data:ExecuteStatement
- rds-data:BatchExecuteStatement
Resource: "arn:aws:rds-data:us-east-1:YOUR_ACCOUNT_ID:cluster:YOUR_RDS_CLUSTER_IDENTIFIER" # Or specific DB ARN
plugins:
- serverless-php-build
- serverless-dotenv-plugin
package:
individually: true
patterns:
- '!node_modules/**'
- '!vendor/bref/php-http-client/**' # Exclude if not needed for Lambda runtime
- '!vendor/laravel/sail/**' # Exclude development tools
functions:
api:
handler: public/index.php # Bref's entry point for Laravel
description: Handles all API requests for the product catalog
events:
- httpApi:
path: /
method: ANY
- httpApi:
path: /{proxy+}
method: ANY
# Optional: Configure VPC access if your RDS instance is in a private subnet
# vpc:
# securityGroupIds:
# - sg-xxxxxxxxxxxxxxxxx
# subnetIds:
# - subnet-xxxxxxxxxxxxxxxxx
# - subnet-yyyyyyyyyyyyyyyyy
# Optional: Configure RDS Proxy for better Lambda-RDS connectivity
# custom:
# serverless-php-build:
# php:
# version: 8.2
# extensions:
# - pdo_mysql
# - json
# - mbstring
# - xml
# dotenv:
# basePath: ./
# file: .env
Important Notes for `serverless.yml`:**
- Replace placeholders like `YOUR_ACCOUNT_ID`, `YOUR_RDS_CLUSTER_IDENTIFIER`, `your-rds-endpoint.rds.amazonaws.com`, `your_db_user`, `your_db_password`, and `YOUR_APP_KEY_HERE` with your actual AWS credentials and generated app key.
- The `iam.role.statements` grants permissions to interact with RDS Data API. If you’re not using RDS Proxy, you’ll need to configure network access (VPC settings) and potentially use a different database driver.
- `serverless-php-build` is crucial for compiling PHP extensions needed by Laravel and your application.
- `serverless-dotenv-plugin` helps manage environment variables.
- The `package.patterns` are important to keep the Lambda deployment package size small.
3. Deploy to AWS:**
# Install Serverless Framework if you haven't already npm install -g serverless # Deploy the service sls deploy
After deployment, Serverless Framework will output the API Gateway endpoint URL. You’ll need to configure your monolith (or other services) to call this endpoint for product-related data.
Refactoring the Monolith to Consume Microservice APIs
Once the Product Catalog service is deployed and accessible via API Gateway, the monolith needs to be refactored to consume its endpoints instead of accessing the database directly for product information. This is a gradual process.
1. Create an API Client (Laravel Monolith or a dedicated client library):
// In the monolith's application (assuming it's also Laravel for consistency)
// app/Services/ProductApiClient.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class ProductApiClient
{
protected string $baseUrl;
public function __construct()
{
$this->baseUrl = env('PRODUCT_CATALOG_API_URL'); // e.g., https://your-api-gateway-url.execute-api.us-east-1.amazonaws.com/prod
}
public function getProductBySku(string $sku): ?array
{
try {
$response = Http::get("{$this->baseUrl}/products", ['sku' => $sku]); // Assuming an endpoint to filter by SKU
if ($response->successful() && !empty($response->json())) {
return collect($response->json())->first(); // Get the first product if multiple are returned
}
return null;
} catch (\Exception $e) {
// Log error
report($e);
return null;
}
}
public function getProductById(string $id): ?array
{
try {
$response = Http::get("{$this->baseUrl}/products/{$id}");
if ($response->successful()) {
return $response->json();
}
return null;
} catch (\Exception $e) {
// Log error
report($e);
return null;
}
}
// Add methods for other product operations (e.g., updateStock)
}
Configure the API URL in the monolith’s `.env` file:
PRODUCT_CATALOG_API_URL=https://your-api-gateway-url.execute-api.us-east-1.amazonaws.com/prod
2. Replace Direct Database Access:**
// In a relevant part of the monolith's code (e.g., a controller or service)
// Instead of:
// $product = \App\Models\MonolithProduct::where('sku', $sku)->first();
// Use the API client:
use App\Services\ProductApiClient;
// ... inside a method
$productApiClient = new ProductApiClient();
$productData = $productApiClient->getProductBySku($sku);
if ($productData) {
// Use $productData to display information or perform actions
$productName = $productData['name'];
$productPrice = $productData['price'];
// ...
} else {
// Handle product not found
}
This refactoring should be done incrementally. Start by replacing read operations, then move to write operations (like updating stock), ensuring that the data synchronization strategy is robust enough to handle potential race conditions or inconsistencies during the transition.
Conclusion and Next Steps
Migrating from a monolith to microservices is a significant undertaking. This guide provides a practical, code-centric approach to extracting a Product Catalog service from a legacy PHP application using Laravel, Docker, and deploying it serverlessly on AWS Lambda. Key considerations include identifying service boundaries, managing data synchronization, and refactoring the monolith to consume new APIs.
Future steps would involve extracting other services (User Management, Order Processing), establishing robust inter-service communication patterns (e.g., using gRPC or asynchronous messaging), implementing comprehensive monitoring and logging across all services, and refining deployment pipelines for a fully automated CI/CD process.