Microservices vs monolith — the tradeoffs for PHP apps
Concept
Monolith vs Microservices is one of the most important architectural decisions. For PHP applications (especially Laravel), the default is a monolith — and that's usually the right choice for a very long time.
Monolith: A single deployable unit. All code in one application. Database shared by all features.
Microservices: The system is split into small, independently deployable services. Each service owns its data. Services communicate over HTTP or messaging.
Monolith advantages:
- Simple to develop, test, and deploy.
- No network latency between features.
- Transactions work naturally (one database).
- Easy to refactor and move code.
- Cheap to run (one server).
- The right default for most projects.
Microservices advantages:
- Independent deployment of services.
- Independent scaling (scale only the bottleneck).
- Technology heterogeneity (one service in PHP, another in Go).
- Fault isolation (one service fails, others can continue).
Microservices costs (often underestimated):
- Distributed systems problems: network failures, partial failures, eventual consistency.
- Service discovery, load balancing, circuit breakers.
- Distributed tracing (a request spans 5 services).
- Data consistency without cross-service transactions.
- Operational overhead: multiple deployments, databases, monitoring dashboards.
- Testing is harder: integration tests need multiple services running.
The Majestic Monolith: A well-structured monolith with clear internal module boundaries. Can be split later if needed. Martin Fowler's recommendation: start monolith, extract services when needed.
Modular Monolith: The middle ground. A single deployable unit with code organized into domain modules (each with its own Service/Repository layers). Modules communicate via interfaces, not direct class calls. Easy to extract to microservices later if a module has clear boundaries.
When microservices make sense:
- Team size >50 engineers — independent teams need to deploy independently.
- Genuinely different scaling requirements per component.
- Different reliability requirements (core API vs analytics).
- Already proven the domain model is stable (easier to split after domain is understood).
Code Example
<?php
// MODULAR MONOLITH pattern — clear module boundaries in a single app
// Each module is a namespace with its own Service/Repository layers
// Modules ONLY talk to each other through service interfaces, not direct model access
// ---- ORDERING MODULE ----
namespace App\Ordering;
use App\Inventory\InventoryServiceInterface; // depends on interface, not implementation
use App\Payments\PaymentServiceInterface;
class OrderService
{
public function __construct(
private readonly InventoryServiceInterface $inventory,
private readonly PaymentServiceInterface $payments,
) {}
public function placeOrder(string $customerId, array $items, string $paymentToken): string
{
// Check inventory — through interface (not Eloquent Product directly)
foreach ($items as $item) {
if (!$this->inventory->isAvailable($item['productId'], $item['quantity'])) {
throw new \DomainException("Product {$item['productId']} out of stock");
}
}
$total = array_sum(array_map(fn($i) => $i['price'] * $i['quantity'], $items));
// Process payment — through interface
$paymentId = $this->payments->charge($total, $paymentToken);
// Create order in ORDERING module's own database table
$orderId = (string) \Illuminate\Support\Str::uuid();
\DB::table('orders')->insert([
'id' => $orderId,
'customer_id' => $customerId,
'payment_id' => $paymentId,
'status' => 'placed',
]);
return $orderId;
}
}
// ---- INVENTORY MODULE ----
namespace App\Inventory;
interface InventoryServiceInterface
{
public function isAvailable(string $productId, int $quantity): bool;
public function reserve(string $productId, int $quantity): void;
}
class InventoryService implements InventoryServiceInterface
{
public function isAvailable(string $productId, int $quantity): bool
{
return \DB::table('inventory')
->where('product_id', $productId)
->where('quantity', '>=', $quantity)
->exists();
}
public function reserve(string $productId, int $quantity): void
{
\DB::table('inventory')
->where('product_id', $productId)
->decrement('quantity', $quantity);
}
}
// ---- MONOLITH TO MICROSERVICE EXTRACTION ----
// When Ordering becomes too large, replace InventoryService with an HTTP adapter:
namespace App\Inventory;
class HttpInventoryService implements InventoryServiceInterface
{
public function __construct(private readonly \Illuminate\Http\Client\PendingRequest $http) {}
public function isAvailable(string $productId, int $quantity): bool
{
return $this->http->get("/products/{$productId}/availability", [
'quantity' => $quantity,
])->json('available');
}
public function reserve(string $productId, int $quantity): void
{
$this->http->post("/products/{$productId}/reserve", ['quantity' => $quantity]);
}
}
// The OrderService doesn't change — only which implementation is bound in the container.
// This is the key: module boundaries make extraction possible without rewrites.