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.
Frequently asked questions
When should a Laravel monolith be split into microservices?
Splitting starts to pay off once an application has multiple teams, divergent scaling requirements or a need for independent deployment cycles. Begin with a modular monolith and extract a service only when its domain boundary is clear. Rushed decomposition creates distributed complexity without the benefits of independent scaling and deployment.
What does an API gateway do in a Laravel microservices architecture?
A Laravel API gateway is the single entry point for every client request. Built as a lightweight Laravel application, or with NGINX or Kong, the gateway handles authentication, rate limiting, request routing and response aggregation when a client needs data from several services. A GatewayRouter class can map service names to internal URLs and forward each call through Laravel's Http client with a 5 second timeout.
How do I rate limit each microservice separately in a Laravel API gateway?
Write a middleware that takes the service name as a parameter and uses Laravel's RateLimiter with a key built from the service and the user ID, falling back to the client IP. Give each service its own budget, for example 100 requests per 60 seconds for orders, 20 for payments and 200 as the default. When tooManyAttempts returns true, respond with HTTP 429 and a retry_after value from availableIn.
How should Laravel microservices communicate with each other?
Asynchronous events through a message broker such as RabbitMQ or Amazon SQS give eventual consistency between services. In an order flow, the Order Service fires an OrderPlaced event, and a ReserveInventory listener in the Inventory Service locks each product row with lockForUpdate and decrements stock. The listener then emits InventoryReserved, or InsufficientStock when a SKU runs short.
How do I add a circuit breaker to calls between Laravel services?
A cache-backed circuit breaker counts failures per service and opens after 5 of them. While open, calls fail straight away with a ServiceUnavailableException for a 30 second recovery timeout. After that the breaker moves to half-open and lets a call through, closing again on success, so one unhealthy downstream service cannot cause cascading failures.
Should each Laravel microservice have its own database?
Yes. Separate databases per service enforce data ownership, and each service exposes its data only through a well-defined API contract. Draw the boundaries around business capabilities such as users, orders, inventory and payments, and each service can then pick its own engine, for example MySQL for users and PostgreSQL for orders.
How do I trace a request across Laravel microservices?
Have the gateway forward an X-Request-ID header on every downstream call, generating a UUID when the client did not send one, along with X-Trace-ID and X-User-ID. Carry those correlation IDs through all services, send traces to Jaeger or AWS X-Ray, and centralize logs in the ELK stack or CloudWatch Logs so a single request can be followed across services.
How should Laravel microservices be deployed on Kubernetes?
Give each service its own Docker container and Kubernetes deployment, plus a health check endpoint. Run the gateway with higher replica counts than the domain services, since all traffic flows through it. Version the API contracts with backward compatibility, and manage the infrastructure as code so service deployments stay consistent.
Read how we handle it in Infrastructure Setup.
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.