0

Microservices vs monolith — the tradeoffs for PHP apps

Advanced5 min read·eng-05-008
interview

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
<?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.