Laravel to Go Migration Without Rewriting Everything
Laravel is not the problem. This is a selective extraction strategy, which workloads leave, which stay, and how to run Go alongside Laravel without breaking auth, queues or the schema.
A Laravel to Go migration goes wrong the moment someone frames it as escaping Laravel. Laravel is not the reason your queue workers are consuming 12 GB, and replacing it wholesale will not make your product better. Laravel is one of the most productive application frameworks in any language: Eloquent turns a data model into working code in an afternoon, the queue abstraction with Horizon gives you retries, backoff, batching and a usable dashboard for free, the validation and authorization layers are genuinely good, and the hiring pool across Europe is deep. Those are real engineering assets and you should be reluctant to give them up.
What is worth discussing is narrower: a handful of workloads inside a Laravel application are constrained by the PHP execution model rather than by Laravel, and those specific workloads are often much cheaper to run in Go. This article is about identifying them, extracting them safely, and leaving the rest of the application alone.
Why Laravel teams start looking at Go
The pattern repeats with enough regularity that you can almost predict which part of the system triggered it.
The first is Horizon memory. A queue:work process holds the full application container for the life of the process. Suppose, illustratively, your supervisor keeps each worker under a 256 MB limit and the framework plus your job dependencies already sit around 130 MB resident, you are paying most of that memory to keep a socket waiting on an HTTP response from a payment provider. Scale to 80 concurrent jobs and the memory bill is entirely disconnected from the actual work being done.
The second is anything requiring a persistent connection. Laravel Reverb and Echo make WebSockets pleasant to develop against, but a connection-heavy real-time layer is a poor fit for a per-request runtime, and teams either run a separate service or pay for a hosted one.
The third is fan-out latency. An endpoint that queries five internal services sequentially takes the sum of five round trips. In Laravel you can reach for concurrent HTTP pools or fibers, but the surrounding code and most libraries are synchronous.
The fourth is CPU-bound processing: PDF generation, image pipelines, large CSV or XML imports, cryptographic work, or anything that streams tens of thousands of rows. These jobs are slow in PHP for reasons that have nothing to do with your code quality.
Notice that none of those are complaints about Eloquent, Blade, routing, or the service container.
Workers and queues: the correct first extraction
Queue consumers are almost always where a Laravel to Go migration should start, for a reason that has nothing to do with performance: the contract is small. A job is a serialized payload with a name. There is no HTTP response shape to preserve, no Blade template, no middleware stack, no session. You can write a Go consumer for one job class and leave the other forty in PHP.
The mechanics matter more than the code.
Decide who dispatches. Keep Laravel as the producer at first. dispatch() writes to Redis, SQS, or your database queue exactly as before. Only the consumer changes.
Match the payload format. Laravel’s queue payload is a JSON envelope containing a serialized command object. If you are dispatching Eloquent models, SerializesModels stores a class name and a key, and your Go consumer has no way to hydrate that. The fix is to dispatch plain, explicit payloads for any job you intend to move: primitive fields and IDs, with an explicit version field. That is a small refactor inside Laravel and you should do it before writing any Go.
Laravel producer ──▶ Redis / SQS ──┬──▶ PHP consumer
└──▶ Go consumer
(one job type)
Assume at-least-once delivery. Both Laravel and any Go consumer you write will occasionally process the same message twice, after a timeout, a redelivery, a deploy, or an incident. Every handler you move needs an idempotency key derived from the payload (not generated at handling time) and a uniquely-indexed table of processed keys. Insert the key in the same transaction as the side effect, and let a unique-constraint violation mean “already done.”
Bound your concurrency. A Go consumer can trivially run 500 goroutines and then exhaust your database connection pool or overwhelm a partner API. Use a bounded worker pool, size SetMaxOpenConns deliberately, and honour rate limits explicitly. Backpressure is your responsibility now; Horizon was doing some of it for you.
Run both consumers in parallel during cutover. Point the Go consumer at the same queue, give it a small share of the traffic, and compare results. Keep the PHP consumer deployable until you are past a full billing or reporting cycle. The deeper mechanics of dual-running consumers are covered in migrating PHP queue workers to Go.
High-concurrency services
The second category worth extracting is anything that holds many connections or makes many simultaneous outbound calls: a WebSocket or SSE gateway, a webhook ingestion endpoint that must absorb bursts without dropping anything, a notification fan-out service, or an aggregation endpoint calling several partner APIs under one deadline.
These are good candidates because they are usually thin. A webhook receiver validates a signature, writes a row, and returns 200. There is almost no business logic to reproduce, and the failure mode you care about, dropping events during a spike, is precisely what the Go execution model is good at avoiding.
What is not a good candidate: the checkout flow, the pricing engine, the subscription lifecycle, the admin panel. Those are where your business rules live, they change often, and Laravel is the reason they are cheap to change.
API boundaries between Laravel and Go
Once you have two services, you need a boundary that is boring.
Put a routing layer in front of both, nginx, Traefik, or your existing API gateway, so that moving a route between Laravel and Go is a configuration change you can revert in under a minute. Do not let the frontend decide which backend to call; if the client knows about the split, every future move becomes a coordinated release.
For internal calls, be explicit about direction. Laravel calling Go over HTTP is straightforward. Go calling back into Laravel is where people create cycles: a Go consumer that calls a Laravel endpoint which dispatches a job that the Go consumer then handles is a loop you will debug at midnight. Prefer one direction, and prefer events over synchronous calls when the caller does not need an answer.
Version the contract from day one. A tiny OpenAPI document or a shared JSON schema, checked into the repository and validated in CI on both sides, prevents most of the drift.
Shared database or isolated storage
This is the decision that determines how much pain you experience.
Sharing the database between Laravel and Go is the pragmatic starting point, and it is fine, with one rule: for every table, exactly one side owns writes. Two codebases writing the same rows will eventually disagree about defaults, created_at handling, enum casing, soft deletes, or JSON column shape, and you will find out weeks later during an audit.
Things Eloquent does that a Go service will not do unless you make it:
| Eloquent behaviour | What Go must replicate |
|---|---|
| Model events and observers | Explicit calls, or nothing at all |
Soft deletes (deleted_at) |
Filter on every read, set on delete |
| Timestamps | Write created_at / updated_at yourself |
| Attribute casts | Match JSON, boolean, decimal handling |
| Global scopes | Reimplement the filter in every query |
| Accessors and mutators | Duplicate the transformation |
Every row in that table is a place where a Go service can silently write data that Laravel later reads incorrectly. The safest sequence is: let the Go service read only, then let it own writes for tables Laravel no longer touches, and only much later consider separate storage.
Migrations stay in Laravel. One schema, one migration history, one source of truth. A Go service that runs its own migrations against a Doctrine-style or Eloquent-managed schema is a deployment-ordering problem waiting to happen.
Authentication across the boundary
You need a request that authenticated in Laravel to be trusted by Go without Go reimplementing Laravel’s auth stack.
If you are using Sanctum with API tokens, the token is hashed in the database and validating it in Go means a lookup, workable, but it couples Go to Laravel’s tables. If you are using session cookies, Go would need to decrypt Laravel’s encrypted cookie and read the session store, which is possible and unpleasant.
The clean answer is to terminate authentication once, at the edge, and pass a short-lived signed token inward. Have Laravel issue a JWT signed with a key both services know, containing the user ID, tenant ID, and the handful of claims Go actually needs, with a short expiry. Go verifies the signature and the expiry and does nothing else. Authorization for anything non-trivial stays in Laravel, where your policies already live.
Whatever you choose, do not copy Laravel’s password hashing, session serialization, or permission logic into Go. That is duplicated security-critical code with two places to get it wrong.
Events and cross-service side effects
Laravel’s event system is in-process. Once part of the work happens in Go, an event dispatched in PHP no longer reaches every listener that cares.
The workable pattern is to keep Laravel events as the internal mechanism and add one listener that publishes a durable message to the queue or a stream for anything Go needs to react to. Publish that message from inside the database transaction using an outbox table, then relay it, otherwise you will publish events for transactions that later roll back.
Go’s side reacts to the message, not to the Laravel event. That keeps the coupling to a payload contract instead of framework internals.
Deployment and observability
Laravel deploys as code plus a runtime; Go deploys as a binary. Do not let that difference create two separate operational cultures.
Use the same pipeline shape for both: same environment naming, same secret management, same health check convention, same graceful shutdown behaviour on SIGTERM. A Go consumer must finish its in-flight message and stop pulling new ones when the orchestrator signals it, exactly as queue:restart handling does in Laravel.
Propagate trace context across the boundary. Use OpenTelemetry on both sides and pass traceparent in HTTP headers and in queue payload metadata, so a request that starts in Laravel and finishes in a Go consumer is one trace and not two unrelated ones. Log in the same structured format with the same field names for request ID, user ID, and tenant. This costs a day and saves the first incident.
A concrete migration sequence
- Instrument first. Get profiling and per-job timing in Laravel so you know which workloads actually cost money. Extract nothing before you can name the metric you intend to improve.
- Fix the cheap things. Missing indexes, N+1 queries in jobs, unbounded
->get()calls, jobs that load a whole table into memory. This step alone frequently removes the reason for the migration. - Normalise the payloads of the one or two job classes you intend to move: plain fields, explicit versioning, no
SerializesModels. - Add idempotency keys and a processed-keys table on the PHP side. Verify it works while still in PHP.
- Write the Go consumer for one job class. Run it in shadow mode against mirrored messages, writing to a staging table, and diff its output against the PHP consumer for at least a week.
- Cut over that one job class by percentage. Keep the PHP consumer deployed and one config change away.
- Extract the second workload only after the first has run in production for a month.
- Re-evaluate. If the pain is gone, stop. Stopping is a legitimate and common end state.
If you would like this sequence mapped against your actual job classes and traffic, that assessment is the core of my PHP to Go migration work, a fixed-scope readiness audit that produces the extraction order, the table ownership map, and an explicit list of what should stay in Laravel. This sequencing follows the general strangler pattern for PHP to Go, applied to the parts of Laravel that make it specific.
The anti-pattern: translating controllers line by line
The most expensive failure mode in a Laravel to Go migration is opening a controller, opening a Go file, and translating.
It fails because Laravel controllers are thin by design. The real behaviour is spread across form requests, middleware, policies, Eloquent relationships and scopes, observers, events, and casts. A line-by-line translation reproduces the visible ten percent and silently drops the rest, the authorization check in a policy, the global scope filtering soft-deleted rows, the observer that recalculates a total.
It also produces Go that reads like PHP: a service container nobody needs, an in-house ORM, magic-string dependency wiring, and exceptions modelled as panics. You end up with the ergonomics of neither language.
The alternative is to migrate capabilities, not files. Define what the workload does in terms of inputs, outputs, and side effects. Write down every side effect explicitly, including the ones hiding in observers. Then implement that specification idiomatically in Go and verify it against the running PHP implementation with real traffic before it serves a single user.
If a capability turns out to be inseparable from the framework, that is useful information: it means the capability belongs in Laravel. Leave it there and move on to the next one.