How to Migrate a Laravel Monolith to Microservices Without Breaking Everything

Ingeniero de Software Dublín

Ingeniero de Software Dublín

Ingeniero Construido Para Escalar

Más Allá Del Código

Lo Que Dicen

Construyamos Juntos

Mensaje Recibido

Política de Privacidad

Términos de Uso

Política de Cookies

Aviso Legal

Últimas Noticias

Trabajo Seleccionado

Showcase

Lo Que Ofrecemos

Industrias Que Servimos

Más Allá De La Pantalla

Construido Juntos

View View
Nben Malla
Nben Malla

Software Engineer

A software engineer who builds systems that scale, modernizes platforms that matter and ships code that holds up long after the project ends.

Based in Dublin, Ireland, with a presence across Bristol, Groningen and Kathmandu, the work spans FinTech platforms, SaaS products and open source contributions, collaborating with engineering teams across Nepal, Ireland, the Netherlands, New Zealand and the United States.

From leading legacy modernization for global banking clients to architecting microservices in Go, Java and Python, the focus has always been the same. Understand the problem deeply, build it right and make sure the people depending on it never have to think about it failing.

  • Leer artículo Leer artículo

    Tutorials 11 mins

    How to Migrate a Laravel Monolith to Microservices Without Breaking Everything

    Nben M. 05 Sep, 2025 11 mins

    How to Migrate a Laravel Monolith to Microservices Without Breaking Everything

    Most Laravel monoliths are not failures. They are the natural result of a product that grew faster than its original architecture was designed for. The routes file is long, the models are entangled, the jobs queue does work that probably belongs in three separate services. But the system runs. It handles real traffic, real data, and real business logic that nobody has fully documented because it accumulated over years of iteration.

    The instinct when you inherit or outgrow a system like this is to start fresh. Draw a clean architecture diagram, pick your services, and build the new system in parallel while the monolith keeps running. That instinct is wrong often enough that I would describe it as a pattern to avoid rather than a strategy to consider.

    I have worked on Laravel codebases in production across multiple teams and countries. The migrations that succeeded were the ones that treated extraction as an ongoing discipline rather than a project with a start and end date. The ones that failed were the ones that tried to do too much at once, underestimated the implicit knowledge embedded in the monolith, and found themselves running two systems indefinitely because the cutover never quite happened.

    This article is about how to do it correctly.

    Understand What You Have Before You Touch Anything

    The most common mistake in a monolith migration is starting to extract services before you understand the monolith's real boundaries. The directory structure tells you how the code is organised. It does not tell you how the system actually behaves.

    Spend time mapping the domain before writing any new service. Identify the core capabilities the system provides and trace how data flows between them. In Laravel, this means reading the models and their relationships, tracing the jobs and listeners, and understanding what the queues are doing that the HTTP layer is not.

    php
    // This Eloquent relationship looks simple
    class Order extends Model
    {
        public function customer(): BelongsTo
        {
            return $this->belongsTo(Customer::class);
        }
    
        public function lineItems(): HasMany
        {
            return $this->hasMany(LineItem::class);
        }
    }
    
    // But the observer tells you more about how the domain actually works
    class OrderObserver
    {
        public function created(Order $order): void
        {
            InventoryService::reserve($order->lineItems);
            NotificationService::sendOrderConfirmation($order->customer);
            LedgerService::recordPendingCharge($order);
        }
    }

    That observer is three implicit dependencies between Order and three other domains. None of them appear in the model definition. If you extracted Order into its own service without understanding those dependencies, you would break inventory reservation, customer notifications and ledger recording simultaneously on the first deployment.

    Read the observers, the event listeners, the job chains and the service providers before you draw any boundary lines. The real coupling in a Laravel monolith lives in the event system, not the models.

    Map the Data, Not Just the Code

    Every table in the database is a potential coupling point. Laravel makes it easy to reach across domain boundaries at the database layer because there is only one database. An Eloquent model in the Billing namespace can join against a table owned by the Customer namespace with no friction and no visibility.

    Before extracting any service, audit which models read from which tables. A simple query against your query log or a grep across the codebase for table names gives you a dependency graph that is far more revealing than the directory structure.

    The tables that are read by the most models are the hardest to extract and should be among the last you touch. The tables that are read only by models in a single namespace are the first candidates for extraction.

    Extract the Strangler Fig Way

    The strangler fig pattern is the correct model for this work. You do not replace the monolith. You grow new services around it, incrementally route traffic to them, and let the monolith shrink as extraction progresses. The monolith keeps running throughout.

    The implementation in a Laravel context starts with a routing layer in front of the monolith. Nginx or a simple reverse proxy routes specific paths to new services while everything else continues to hit Laravel. No user-facing change, no downtime, no cutover date.

    nginx ·
    markdown
    server {
        listen 80;
    
        # New inventory service handles its own routes
        location /api/v1/inventory {
            proxy_pass http://inventory-service:8080;
            proxy_set_header X-Request-ID $request_id;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
    
        # Everything else still goes to Laravel
        location / {
            proxy_pass http://laravel-app:9000;
            proxy_set_header X-Request-ID $request_id;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
    }

    The new service owns its routes from day one. The monolith never touches those routes again. As each service matures, the Laravel codebase gets smaller, the routing config gets longer, and at some point the monolith becomes a thin shell handling only the parts of the system not yet extracted.

    This approach means you are always running in production with real traffic. You find the edge cases that your test suite did not cover because real users generate inputs you did not think to test. You find them on a Tuesday afternoon rather than on the day you cut over everything at once.

    Synchronise Data During the Transition

    The hardest part of extraction is data. The new service needs its own database. The monolith has years of data in its database. During the transition, both systems need to be consistent.

    The approach that works is dual-write with event sourcing. When the monolith writes to a table that is being migrated, it also publishes an event. The new service consumes that event and writes to its own database. Read operations can be gradually shifted to the new service once its data is verified against the monolith's.

    In Laravel, this is straightforward to implement using model observers and a message queue.

    php
    // Publish an event on every write to the inventory table
    class InventoryItemObserver
    {
        public function created(InventoryItem $item): void
        {
            InventoryItemCreated::dispatch($item->toArray());
        }
    
        public function updated(InventoryItem $item): void
        {
            InventoryItemUpdated::dispatch($item->id, $item->getChanges());
        }
    }
    go
    // The new inventory service consumes these events and builds its own state
    func (s *InventoryConsumer) HandleItemCreated(ctx context.Context, event InventoryItemCreatedEvent) error {
        item := domain.InventoryItem{
            ID:        event.ID,
            SKU:       event.SKU,
            Quantity:  event.Quantity,
            UpdatedAt: event.CreatedAt,
        }
        return s.store.Upsert(ctx, item)
    }

    Run the new service's database in shadow mode for at least two weeks before switching any read traffic to it. Compare its state against the monolith's on a schedule. When the counts and values match consistently, you have confidence that the event pipeline is reliable.

    Do not switch read traffic until you have validated write traffic. Switching writes first exposes data loss. Validating writes in parallel before switching reads is the conservative order that does not produce incidents.

    Handle the Backfill

    The event pipeline handles new writes. It does not handle the years of existing data in the monolith's database. Before the new service can own its domain, it needs that history.

    Write a one-time backfill script that reads from the monolith's database directly and populates the new service's database. Run it before activating the event consumer, then run the consumer to catch up on writes that happened during the backfill.

    php
    // Artisan command to backfill existing inventory data
    class BackfillInventoryItems extends Command
    {
        protected $signature = 'migrate:backfill-inventory {--chunk=500}';
    
        public function handle(): void
        {
            $count = 0;
            InventoryItem::query()
                ->orderBy('id')
                ->chunk((int) $this->option('chunk'), function ($items) use (&$count) {
                    foreach ($items as $item) {
                        InventoryItemCreated::dispatch($item->toArray());
                        $count++;
                    }
                    $this->info("Backfilled {$count} items");
                });
        }
    }

    Chunk the backfill. A single query that loads the entire table into memory will kill the process on any table with meaningful data volume. Five hundred rows at a time is a safe default. Adjust based on row size.

    Replace Internal Service Calls with HTTP or Queued Events

    Laravel monoliths accumulate internal service classes that call each other directly. When you extract one of those services, the internal call has to become either an HTTP request or an asynchronous event. The choice between them is the same as in any distributed system: if the calling code needs the result to proceed, use HTTP. If it does not, use an event.

    php
    // Before: direct internal call
    class OrderService
    {
        public function __construct(private InventoryService $inventory) {}
    
        public function placeOrder(array $data): Order
        {
            $order = Order::create($data);
            $this->inventory->reserve($order->lineItems); // synchronous, same process
            return $order;
        }
    }
    php
    // After: HTTP call to the extracted inventory service
    class OrderService
    {
        public function __construct(private InventoryClient $inventory) {}
    
        public function placeOrder(array $data): Order
        {
            $order = Order::create($data);
    
            try {
                $this->inventory->reserve($order->lineItems);
            } catch (InventoryUnavailableException $e) {
                $order->update(['status' => 'pending_inventory']);
                OrderRequiresInventoryReview::dispatch($order->id);
            }
    
            return $order;
        }
    }

    The extracted version has to handle the case where the inventory service is unavailable or returns an error. The monolith's internal call never had to consider this. Every synchronous internal call you replace with an HTTP call adds a failure mode you now have to design for.

    This is not a reason to avoid the extraction. It is a reason to think through the failure modes before you flip the switch, not after.

    Authentication Across Services

    Laravel's session-based authentication does not cross service boundaries. A session cookie issued by the monolith means nothing to a Go service. You need a shared authentication mechanism before any extracted service handles authenticated requests.

    The standard solution is JWT tokens issued by the monolith or a dedicated auth service, verified by every service independently.

    php
    // Laravel issues a JWT after authentication
    class AuthController extends Controller
    {
        public function login(LoginRequest $request): JsonResponse
        {
            $user = User::where('email', $request->email)->first();
    
            if (!$user || !Hash::check($request->password, $user->password)) {
                return response()->json(['error' => 'invalid credentials'], 401);
            }
    
            $token = JWT::encode([
                'sub'  => $user->id,
                'role' => $user->role,
                'exp'  => time() + 3600,
            ], config('app.jwt_secret'), 'HS256');
    
            return response()->json(['token' => $token]);
        }
    }
    go
    // Each Go service verifies the token independently
    func (m *AuthMiddleware) Verify(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
            claims, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
                return []byte(m.secret), nil
            })
            if err != nil || !claims.Valid {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }
            ctx := context.WithValue(r.Context(), contextKeyUserID, claims.(*Claims).Subject)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }

    Use a shared secret or a public/private key pair. The shared secret is simpler. The key pair is more secure because only the auth service holds the private key and services only need the public key to verify. For most extraction scenarios, the shared secret is fine to start with and can be rotated to a key pair later without changing the verification logic.

    Know When to Stop

    Not everything in a Laravel monolith should be extracted. The goal is not to run zero Laravel code. The goal is to run the right code in the right place.

    Extracting a domain that changes once a quarter and is owned by one developer creates operational overhead without a corresponding benefit. You now have a separately deployed service, a separate database, a separate logging setup and a separate on-call surface for code that was perfectly manageable as a module in the monolith.

    Apply extraction where the benefit is real: where a domain needs to scale independently, where a team boundary maps to a service boundary, where the domain's deployment frequency is materially different from the rest of the system, or where a domain has regulatory or security isolation requirements that the monolith cannot satisfy.

    Leave everything else where it is. A partially extracted monolith that runs reliably is better engineering than a fully extracted microservices architecture that the team struggles to operate.

    Conclusion

    Migrating a Laravel monolith to microservices is a discipline, not a project. The teams that do it well are the ones that extract incrementally, validate continuously, and resist the pressure to accelerate the timeline beyond what the system can safely absorb.

    The strangler fig approach, the dual-write data synchronisation, the careful handling of internal service calls and authentication: none of these are complicated ideas. They are consistent application of a principle that experienced engineers learn the hard way: the monolith knows things you do not know yet, and every shortcut in the extraction process is a debt that compounds until it becomes an incident.

    Take the time to understand the system before you move it. Extract one domain at a time. Validate each extraction before starting the next. The monolith will get smaller. The services will get more capable. The system will remain reliable throughout, which is the only outcome that actually matters.