I've rebuilt the same Laravel monolith into microservices three times now, at three different companies, and I've also talked at least two teams out of doing it. That second part is the important one. Every conference talk about microservices shows you the payoff — independent deploys, teams that don't step on each other, services that scale on their own. Almost none of them show you the six months of pain before the payoff shows up, or the projects where the payoff never showed up at all because the team didn't actually have the problem microservices solve. This post is the version of that conversation I keep having with engineers who are about to split up a perfectly healthy Laravel app.
What microservices actually solve — and when they don't
A monolith gets you in trouble in a few specific, recognizable ways: your test suite takes twenty minutes and everyone starts skipping it, three teams are fighting over the same app/Models directory, one noisy background job takes down checkout because it shares a process pool with everything else, and you can't deploy a fix to the invoicing code without redeploying — and re-testing — the entire product. If you're nodding at two or more of those, microservices are a reasonable tool to reach for. They let you scale one part of the system independently, deploy teams independently, and contain failure so a runaway job in the reporting service doesn't take down checkout.
If none of that describes you, stop reading the hype and keep shipping the monolith. A single Laravel app with clean module boundaries, a good queue setup, and decent test coverage will comfortably carry a five-to-fifteen-person engineering team well past the point most founders expect it to break. I've seen a team of four burn three months building a service mesh for an app that had four thousand users. The complexity tax of microservices — network calls where you used to have function calls, distributed debugging, eventual consistency, duplicated auth logic, a small platform team's worth of DevOps overhead — is real and it's paid every single day, whether or not you're actually getting the scaling benefit. Pay it when you have the organizational problem (teams blocking each other) or the technical problem (one component needs to scale or fail independently of the rest), not because a blog post told you monoliths don't scale. They scale fine. It's coordination that doesn't.
Where Laravel fits into the picture
Laravel is a genuinely good fit for microservices, mostly because it's a genuinely good fit for building focused, boring API services fast. A service that owns "invoicing" or "notifications" doesn't need the full framework ceremony — you're usually looking at a handful of routes, a queue worker, some Eloquent models scoped to that service's own tables, and a health check endpoint. Laravel's routing, validation, and job system get you there in a day, not a sprint.
Auth between services
Two patterns cover almost every case. For service-to-service calls where a user is technically not present but you still want the target service to know who's calling, use signed requests — sign an HMAC over the payload plus a timestamp using a shared secret per service pair, and reject anything outside a short time window to kill replay attacks. For calls that carry a real user's identity across services, issue short-lived Sanctum tokens from your auth service and pass them through; each downstream service validates the token against a shared signing key or a lightweight introspection endpoint rather than hitting the auth database directly. Resist the urge to have every service share the same users table "just for auth" — that's the data-ownership problem in disguise, and I'll get to why it matters below.
Queues as the connective tissue
Laravel's queue abstraction is, in my experience, the single most useful piece of the framework for this architecture. Point it at Redis (or the database driver if you want zero extra infrastructure to start) and you get a reliable, retryable way for one service to tell another "this happened" without waiting around for a response. Order service publishes OrderPlaced, inventory service and notification service each have their own listener, and none of them are coupled to whether the others are even online right now.
Monolith-first, modular inside
The move I recommend most often: don't start with separate services at all. Build the monolith with strict internal module boundaries — separate namespaces per domain, no cross-module Eloquent relationships, communication between modules through service classes or in-process events instead of direct model access. This gets you almost all the organizational clarity of microservices — you can reason about "the billing module" as a unit — without paying the network-and-infrastructure tax yet. When a specific module actually needs to scale independently or ship on its own cadence, it's a much smaller lift to extract it into its own service, because the boundary was already clean.
Docker fundamentals for a Laravel service
Every service gets its own image, its own container, its own lifecycle. Here's a minimal Dockerfile I'd actually use for a Laravel API service:
FROM php:8.3-fpm
RUN apt-get update && apt-get install -y \
git unzip libpq-dev libzip-dev \
&& docker-php-ext-install pdo pdo_pgsql zip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize
RUN chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache
CMD ["php-fpm"]
And a docker-compose.yml wiring together the app, its own database, and Redis for local development against other services:
version: '3.8'
services:
app:
build: .
volumes:
- .:/var/www
depends_on:
- db
- redis
environment:
- DB_HOST=db
- REDIS_HOST=redis
queue:
build: .
command: php artisan queue:work
depends_on:
- db
- redis
environment:
- DB_HOST=db
- REDIS_HOST=redis
db:
image: postgres:16
environment:
- POSTGRES_DB=orders_service
- POSTGRES_PASSWORD=secret
volumes:
- db_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
db_data:
Notice the queue service is a separate container running the same image with a different command — that's the pattern I use everywhere, because it means the web process and the worker process scale and restart independently, which is half the point of doing any of this.
How services actually talk to each other
You've got two real options, and most systems need both. Synchronous REST (or gRPC) calls are the right tool when the caller genuinely needs an answer before it can proceed — checking whether a payment method is valid before you let someone submit an order. The cost is coupling: if the payment service is slow or down, the caller is slow or down too, and latency stacks up across every hop in the chain. Async communication via events and queues is the right tool for everything that doesn't need an immediate answer — "an order was placed, go do your thing" — and it buys you resilience, because a listener being offline doesn't block the publisher. The cost there is eventual consistency: for a window of time, however small, the rest of the system doesn't know what just happened. Most of the outages I've debugged in microservice systems trace back to treating a should-be-async operation as synchronous, or to a team just not deciding and letting it happen by accident.
Own your own data, no exceptions
The single most common mistake I see teams make when "adopting microservices" is splitting the codebase into services while leaving them all pointed at one shared database. That's not microservices — it's a monolith with extra network hops and none of the benefits. The database is where coupling actually lives: if the orders service and the inventory service both read and write the same products table, you cannot deploy a schema change to one without coordinating with the other, you cannot scale their databases independently, and a lock held by one service's slow query blocks the other. Each service needs to own its own schema — its own database or at minimum its own schema namespace — and any other service that needs that data either asks for it through an API or subscribes to events that keep a local, denormalized copy in sync. It feels wasteful the first time you duplicate a chunk of data across two services. It's the thing that actually makes them independent.
Pitfalls I've personally paid for
- Premature decomposition. Splitting a system into eight services before you understand the domain boundaries means you'll spend the next year moving code between services because you drew the lines in the wrong place — much more expensive than moving code between modules in a monolith.
- Distributed transactions. "Just wrap it in a DB transaction" doesn't exist once state is split across services. You need sagas or compensating actions (cancel the reservation if the payment fails), and that logic has to be written and tested deliberately — it doesn't fall out for free.
- No centralized observability. If you can't trace one request across five services with a single correlation ID, you will spend entire days grep-ing through logs on five different boxes trying to reconstruct what happened. Set up centralized logging and distributed tracing before you need it, not after the first 3am incident.
- Assuming the network is reliable. It isn't. Timeouts, retries with backoff, and circuit breakers aren't optional extras — a service that calls another service without a timeout will eventually hang forever waiting on a dependency that's never coming back.
How to actually get there
Don't rewrite. Pick the one module in your monolith that's causing real, measurable pain — the one that needs to scale differently, or the one two teams keep fighting over — and extract just that one, behind a stable API, with its own database, deployed on its own pipeline. Run it in production. Learn what breaks about your assumptions on observability, auth, and retries with one service before you commit to a plan involving ten. Then, and only then, look at what's next. Most of the successful microservice migrations I've been part of took eighteen months to two years and never fully finished, because by the time you've extracted the five or six modules that actually needed it, the rest of the monolith is doing its job just fine and nobody has a reason to touch it. That's not an unfinished migration. That's the correct end state.
Tagged under
Related Articles
Implementing CI/CD Pipelines with GitHub Actions
Step-by-step guide to setting up continuous integration and deployment with GitHub Actions...
Building Multi-Tenant Systems in Laravel: A Practical Guide
How to design and build multi-tenant SaaS architecture in Laravel — the three core tenancy...
Laravel's Role in the Age of AI: How the Framework Fits Modern AI-Powered Apps
Laravel isn't an AI framework — but its HTTP client, queues, and retry handling make it on...