Design and implement a microservices architecture with Laravel, using an API Gateway for routing, rate limiting, and service discovery patterns.
When Monoliths Hit Their Limits
Laravel excels as a monolithic framework, but as applications grow past certain thresholds - multiple teams, divergent scaling requirements, or independent deployment cycles - a microservices architecture becomes necessary. The API Gateway pattern provides a single entry point that routes requests, aggregates responses, and handles cross-cutting concerns like authentication and rate limiting.
This article covers how to decompose a Laravel monolith into microservices with a proper API Gateway, drawing on architecture planning patterns we apply in production environments.
Architecture Design
A typical Laravel microservices architecture consists of:
API Gateway: A lightweight Laravel application (or NGINX/Kong) that serves as the single entry point. It handles authentication, rate limiting, request routing, and response aggregation.
Domain Services: Independent Laravel applications, each owning a specific business domain - users, orders, inventory, payments, notifications.
Message Broker: RabbitMQ or Amazon SQS for asynchronous communication between services.
Service Registry: Consul or a simple configuration-based registry for service discovery.
Service Boundaries
Define service boundaries around business capabilities, not technical layers. Each service owns its database and exposes a well-defined API contract.
Client → API Gateway → User Service (MySQL)
→ Order Service (PostgreSQL)
→ Inventory Service (PostgreSQL)
→ Payment Service (MySQL)
→ Notification Service (Redis + SQS)
Building the API Gateway
The gateway routes incoming requests to the appropriate service and aggregates responses when a client needs data from multiple services:
// app/Services/GatewayRouter.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Request;
class GatewayRouter
{
private array $serviceMap = [
'users' => 'http://user-service:8001',
'orders' => 'http://order-service:8002',
'inventory' => 'http://inventory-service:8003',
'payments' => 'http://payment-service:8004',
];
public function forward(Request $request, string $service, string $path): mixed
{
$baseUrl = $this->serviceMap[$service]
?? throw new \RuntimeException("Unknown service: {$service}");
$response = Http::withHeaders([
'X-Request-ID' => $request->header('X-Request-ID', (string) str()->uuid()),
'X-User-ID' => $request->user()?->id,
'X-Trace-ID' => $request->header('X-Trace-ID'),
])->timeout(5)->send(
$request->method(),
"{$baseUrl}/api/{$path}",
['body' => $request->getContent()]
);
return response($response->body(), $response->status())
->withHeaders($response->headers());
}
}
Rate Limiting at the Gateway
// app/Http/Middleware/ServiceRateLimiter.php
namespace App\Http\Middleware;
use Illuminate\Cache\RateLimiter;
use Closure;
class ServiceRateLimiter
{
public function __construct(private RateLimiter $limiter) {}
public function handle($request, Closure $next, string $service)
{
$key = "gateway:{$service}:" . ($request->user()?->id ?? $request->ip());
$limits = [
'orders' => ['attempts' => 100, 'decay' => 60],
'payments' => ['attempts' => 20, 'decay' => 60],
'default' => ['attempts' => 200, 'decay' => 60],
];
$config = $limits[$service] ?? $limits['default'];
if ($this->limiter->tooManyAttempts($key, $config['attempts'])) {
return response()->json([
'error' => 'Rate limit exceeded',
'retry_after' => $this->limiter->availableIn($key),
], 429);
}
$this->limiter->hit($key, $config['decay']);
return $next($request);
}
}
Inter-Service Communication
Services communicate asynchronously through events for eventual consistency:
// In Order Service - app/Events/OrderPlaced.php
class OrderPlaced implements ShouldBroadcast
{
public function __construct(
public readonly int $orderId,
public readonly int $userId,
public readonly array $items,
public readonly float $total
) {}
public function broadcastOn(): string
{
return 'orders';
}
}
// In Inventory Service - app/Listeners/ReserveInventory.php
class ReserveInventory
{
public function handle(OrderPlaced $event): void
{
foreach ($event->items as $item) {
$product = Product::where('sku', $item['sku'])->lockForUpdate()->first();
if ($product->stock < $item['quantity']) {
event(new InsufficientStock($event->orderId, $item['sku']));
return;
}
$product->decrement('stock', $item['quantity']);
}
event(new InventoryReserved($event->orderId));
}
}
Circuit Breaker Pattern
Prevent cascading failures when a downstream service is unhealthy:
// app/Services/CircuitBreaker.php
class CircuitBreaker
{
private const FAILURE_THRESHOLD = 5;
private const RECOVERY_TIMEOUT = 30;
public function call(string $service, Closure $action): mixed
{
$state = Cache::get("circuit:{$service}", 'closed');
if ($state === 'open') {
$openedAt = Cache::get("circuit:{$service}:opened_at");
if (time() - $openedAt < self::RECOVERY_TIMEOUT) {
throw new ServiceUnavailableException("{$service} circuit is open");
}
Cache::put("circuit:{$service}", 'half-open', 60);
}
try {
$result = $action();
Cache::put("circuit:{$service}", 'closed', 300);
Cache::forget("circuit:{$service}:failures");
return $result;
} catch (\Exception $e) {
$failures = Cache::increment("circuit:{$service}:failures");
if ($failures >= self::FAILURE_THRESHOLD) {
Cache::put("circuit:{$service}", 'open', 300);
Cache::put("circuit:{$service}:opened_at", time(), 300);
}
throw $e;
}
}
}
Deployment Considerations
- Each service gets its own Docker container and Kubernetes deployment
- Use separate databases per service to enforce data ownership
- Deploy the gateway with higher replica counts since all traffic flows through it
- Implement distributed tracing with Jaeger or AWS X-Ray for debugging cross-service requests
- Manage infrastructure as code to keep service deployments consistent
Best Practices
- Start with a modular monolith - extract services only when you have clear domain boundaries
- Version your API contracts and maintain backward compatibility
- Implement health check endpoints in every service
- Use correlation IDs across all service calls for request tracing
- Centralize logging with ELK stack or CloudWatch Logs
The microservices journey requires careful planning and incremental execution. Rushing decomposition creates distributed complexity without the benefits of independent scaling and deployment.
Or read how we handle it in Architecture & Planning.
Related Articles
Mastering Laravel Workers: A Step-by-Step Guide to Setting Up a Laravel Worker Server on Ubuntu
A comprehensive guide to setting up Laravel queue workers on Ubuntu, covering Redis configuration, job creation, Supervisor process management, Horizon monitoring, scaling, and troubleshooting.
LaravelLaravel Octane Performance Optimization Guide
Boost Laravel throughput 10x with Octane using Swoole and RoadRunner, connection pooling, memory management, and production deployment strategies.
LaravelFrom Code to Production: A Guide to Automating Laravel Deployments with GitHub Actions
Learn how to build a fully automated CI/CD pipeline for Laravel using GitHub Actions, covering SSH key setup, workflow configuration, testing, and deployment to production servers.