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.
Need help with this?
Our team handles this kind of work daily. Let us take care of your infrastructure.
Related Articles
Why MySQL Fails to Start: A Guide to Common Errors and Solutions on Ubuntu
A practical walkthrough for diagnosing and resolving MySQL and MariaDB startup failures on Ubuntu, covering service status checks, log analysis, permission issues, and configuration repairs.
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.
LaravelStep-by-Step Guide to Deploying a Laravel App on AWS with Laravel Forge
A complete walkthrough of deploying a Laravel application on AWS EC2 using Laravel Forge, covering instance setup, Forge configuration, environment variables, SSL, and production best practices.