Let's Connect

Rows of interconnected data center server racks, representing independently deployed services in a Laravel microservices architecture

Most teams that reach for a Laravel microservices architecture end up with a distributed monolith: the same tightly coupled codebase, except now the function calls go over HTTP and fail in new and exciting ways. You get all the operational cost of distributed systems — network latency, partial failures, eventual consistency, five deploy pipelines — and almost none of the benefit. The fix is restraint. Start with a modular monolith, split by business capability only when a real scaling or team boundary forces it, give each service its own database, and treat the network as the hostile, slow, unreliable thing it actually is. This post is how I keep Laravel services sane in production.

Do you actually need microservices, or do you need module boundaries?

Be honest about the problem you are solving. Microservices buy you two things: independent deployability and independent scaling. If you cannot point at a part of the system that needs to deploy on its own cadence or scale on its own axis, you do not have a microservices problem — you have a code organization problem, and you can solve that inside one Laravel app.

A modular monolith gives you most of the discipline with none of the network. You enforce boundaries with namespaces, separate Eloquent models per module, and a rule that modules talk through public service interfaces rather than reaching into each other's tables. When a module genuinely needs to scale independently — say notifications spikes to millions of sends while the rest of the app idles — you already have a clean seam to extract it from. Good boundaries inside a monolith are what make a later extraction cheap; the same dependency discipline that keeps a single app maintainable is what lets you peel a service off later without rewriting it.

  • Split when a team needs to own and deploy a capability without coordinating with everyone else.
  • Split when one capability has a wildly different scaling profile (CPU-bound PDF rendering, burst notification fan-out).
  • Split when a capability has different availability or compliance requirements (payments, PII).
  • Do NOT split because the codebase 'feels big', because microservices are fashionable, or to mirror your org chart aspirationally.

Where do the service boundaries go?

Split by business capability, never by database table. Billing, Notifications, Auth, Catalog — these are nouns the business already uses, and each one owns a coherent slice of behavior and data. The classic mistake is carving services around tables: a 'users service' and an 'orders service' that both need to read and write the same rows, so they end up sharing a database and you are back to a monolith with extra steps.

The hard rule that makes this work: each service owns its data, and no other service touches that database. Not read-only. Not 'just for reporting'. The moment two services share a schema, you can no longer deploy or migrate either one independently, and a column rename in one breaks the other in production. If Billing needs a customer's email, it asks the Auth service over its API or keeps its own copy synced via events — it does not run a JOIN against Auth's tables.

Network cables patched into a switch, illustrating discrete services connected only through their public interfaces
Each service owns its data and exposes a contract. The network between them is the only allowed coupling — keep it thin.

How should services talk to each other?

Two patterns, and you pick by intent. Synchronous HTTP/JSON when one service needs an answer right now to continue (a query). Asynchronous events over a queue when one service needs to tell others that something happened, but does not need to wait on them (a side effect). Reaching for synchronous calls for everything is what turns a microservices architecture into a fragile chain where one slow service stalls the whole request.

Synchronous: HTTP with timeouts and retries, never naked

A synchronous call to another service is a network operation that will eventually time out, return a 500, or hang. Laravel's HTTP client makes the safe version easy — set an explicit timeout, a bounded retry with backoff, and propagate a correlation ID so you can trace the call across services. Never call another service without a timeout; the default is to wait far longer than your own request should live.

app/Services/Billing/AuthClient.php
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

class AuthClient
{
    public function customer(string $customerId): array
    {
        $response = Http::baseUrl(config('services.auth.url'))
            ->withToken(config('services.auth.token'))
            ->withHeaders([
                'X-Correlation-Id' => request()->header('X-Correlation-Id', (string) Str::uuid()),
            ])
            ->timeout(3)            // hard cap: fail fast, do not hang the request
            ->connectTimeout(1)
            ->retry(2, 200, throw: false) // 2 retries, 200ms backoff, do not throw on final fail
            ->get("/api/v1/customers/{$customerId}");

        $response->throwIfServerError(); // 5xx is our problem to handle, 4xx is data

        return $response->json();
    }
}

Note the API version in the path — /api/v1/. Services deploy independently, which means the caller and callee are almost never on the same version at the same time. Version your contracts from day one, additively (add fields, never repurpose or remove them without a new version), or a routine deploy of the callee will break every caller mid-flight.

Asynchronous: publish an event, let consumers react

For cross-service side effects, publish an event to a shared broker and let interested services consume it on their own time. When Billing finalizes a charge, it does not call Notifications and wait — it dispatches a job onto a queue both services share. The Notifications worker picks it up and sends the receipt; if Notifications is down, the message waits in the queue instead of failing the payment. This is also where you lean on a solid worker setup, which I cover in Laravel queue workers with Supervisor in production.

Publisher (Billing) and consumer (Notifications)
// --- Billing service: dispatch after the charge succeeds ---
// The same job class lives in both services (mirrored, or shared via a thin package),
// pushed onto a shared Redis/SQS connection both services point at.
SendInvoiceReceipt::dispatch([
    'event'          => 'invoice.paid',
    'version'        => 1,
    'invoice_id'     => $invoice->id,
    'customer_id'    => $invoice->customer_id,
    'amount_cents'   => $invoice->amount_cents,
    'correlation_id' => request()->header('X-Correlation-Id'),
    'occurred_at'    => now()->toIso8601String(),
])->onConnection('events')->onQueue('billing.events');

// --- Notifications service: the worker here resolves and runs this class ---
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class SendInvoiceReceipt implements ShouldQueue
{
    use Queueable;

    public function __construct(public array $event) {}

    public function handle(): void
    {
        // Idempotency: jobs can be delivered more than once. Guard on the id.
        if (ReceiptLog::where('invoice_id', $this->event['invoice_id'])->exists()) {
            return;
        }

        // ... send the receipt, then record it ...
        ReceiptLog::create(['invoice_id' => $this->event['invoice_id']]);
    }
}

The two things that bite people here: delivery is at-least-once, so every consumer must be idempotent (the where(...)->exists() guard above), and event payloads are a contract too — version them and keep them additive, exactly like your HTTP APIs. Laravel resolves a queued job by its serialized class name, so the publishing and consuming services must agree on that class; a thin shared package or a mirrored class is the honest way to share it.

If splitting a feature means it now needs three synchronous calls to render one page, you did not build microservices — you built a monolith with a latency tax.Md Raihan Hasan

What cross-cutting concerns do you have to solve up front?

The moment you have more than one service, a set of problems that were trivial in a monolith become real work. Solve them deliberately, not per-service-by-accident.

  • Identity: do not reimplement auth in every service. Issue identity once — at an API gateway or a central Auth service — and pass a signed token (a JWT or a Sanctum/Passport-issued token) that each service verifies locally. My Laravel API authentication with Sanctum, Passport, and JWT breakdown covers which one fits service-to-service calls.
  • API gateway: one ingress that handles TLS, rate limiting, routing, and auth so individual services do not each reinvent it.
  • Correlation IDs: generate one X-Correlation-Id at the edge and propagate it through every synchronous call and every event payload, so a single user action is traceable across all the logs it touches.
  • Contract versioning: /api/v1 in URLs and a version field in events, with additive-only changes, so independent deploys never break callers.

When you do deploy these services to real infrastructure, the worker tiers, queue backends, and gateway placement matter as much as the code — I walk through that layout in my AWS production architecture for Laravel write-up.

What recreates the coupling you were trying to escape?

Two traps undo all of this. The first is chatty synchronous calls: a request that fans out to four services in sequence is now only as available as the product of all four uptimes, and as slow as their combined latency. If you find a feature needs many synchronous hops, that is a signal the boundary is wrong — those things probably belong in one service.

The second is the shared library. It feels harmless to extract common models, validation, or 'domain' code into a Composer package every service requires. But now a change to that package forces a coordinated release of every service that depends on it — which is exactly the independent-deployability you gave up the monolith to get. Share narrow, stable things (a DTO definition, a client SDK), never your domain logic.

The honest default is this: build the modular monolith first. Enforce module boundaries, give each module its own models and a clean service interface, and resist the network until a concrete scaling or team-ownership boundary makes a service extraction pay for itself. When that day comes, you extract one capability — own database, versioned contract, idempotent consumers, timeouts and correlation IDs on every call — and leave the rest in the monolith. A Laravel microservices architecture is not a goal; it is a cost you take on deliberately, one service at a time, only when the alternative hurts more.