How to Put One Go Service Beside a PHP Monolith Without a Rewrite
The first Go service next to PHP is an operations problem: table ownership, message contracts, deploy, and a rollback that does not need a Saturday.
The first Go binary next to a PHP monolith is not a rewrite. It is a distributed system you did not have yesterday. That can be the right trade. It is still a trade. Teams skip the boring parts — who owns which table, who publishes which message, how you deploy two artefacts, how you undo the cutover — and then spend a quarter debugging double writes and auth cookies that do not mean the same thing on both sides of the hop.
Adding a Go service next to PHP is an operations design with a small amount of new code. The incremental PHP to Go migration guide covers which workloads deserve a new language. This article is the first service: the network cost you will keep paying, table ownership, outbox, auth, observability, a first workload that is not checkout, and a rollback that does not require a hero.
If you cannot name the owner of the data and the abort switch, do not write the handler yet. The handler is the easy part.
The network hop is a cost you will keep paying
In-process PHP is a function call. Across the network it is serialisation, a socket, timeouts, retries, and a new failure mode that looks like “the site is down” when the site is up and the sidecar is not.
Measure the hop before you fall in love with the diagram. A p95 of 8 ms on loopback in staging is not the number you will see between two Kubernetes namespaces in another AZ with mTLS and a service mesh sidecar. For a worker that already waited 200 ms on a provider, 10–40 ms extra is noise. For a checkout page that was 120 ms of PHP plus three queries, a synchronous call to Go on the hot path is a product change. Treat it as one.
Synchronous HTTP from PHP to Go should be rare on the first service. It couples availability. PHP now fails when Go is slow, and Go’s SLO becomes PHP’s SLO. If you need a decision from the monolith, prefer a message the PHP app already understands, or keep the decision in PHP and let Go do mechanical work. The strangler pattern for PHP to Go is about routing and ownership, not about turning every method into an HTTP round trip.
Retries without idempotency are how you double-charge. Timeouts without a budget are how you pile retries on a saturated service. Both are easier to get wrong in Go because the runtime will happily open more work. Bound it. The PHP process counted as backpressure. The goroutine does not.
Table ownership is the first design decision
Two writers to the same table is the failure mode that ends migrations. Doctrine or Eloquent on one side, database/sql on the other, both updating orders, neither seeing the other’s lifecycle hooks, caches, or version columns. You will not debug that with tracing. You will debug it with finance.
Pick one owner per table, written down:
- PHP owns the row. Go reads, or Go never touches the table and asks PHP. Reads still need a story for replica lag if you point Go at a replica.
- Go owns the row. PHP stops writing. PHP may read. Migrations for that table come from the Go repo, or from a single migration pipeline that both teams agree is canonical. Dual migration folders for one table is how you get drift.
- Neither owns it yet. Then you are not ready to extract. Extracting anyway produces a distributed monolith: every feature still needs a coordinated deploy.
Views, triggers, and stored procedures count as writers. So do cron jobs and that one admin script on a laptop. Inventory them. The owner is not the service you intend to write. The owner is everything that currently INSERTs.
If Go must produce an effect that PHP currently implements as a Doctrine listener, you either move the listener’s contract into an explicit message PHP still consumes, or you move the listener to Go and prove PHP no longer depends on it. Implicit side effects do not survive a language boundary. They just go silent.
Schema changes during dual-run need expand/contract. Add nullable columns or new tables first. Deploy readers. Deploy writers. Remove the old column last. A destructive change in the same release as the Go cutover is how you lose the rollback.
Messages, outbox, and not sharing a write
The clean boundary is a message. PHP already has a queue. Use it. Moving PHP workers to Go is the usual first extraction because the contract is a payload, not an HTML response.
Do not have Go write the same side effect PHP writes “just in case.” Pick a producer. Dual producers are dual writes with extra steps.
If PHP must write a row and enqueue work atomically, use an outbox in the database PHP already owns: insert the row and the outbox record in one transaction, have a relay publish to the broker. Go consumes the broker. If you INSERT and then publish without a transaction, you will drop messages on process death and you will argue about it for months.
Payloads must be explicit. Do not serialise a Doctrine entity or a Laravel SerializesModels envelope and hope Go hydrates it. Send IDs, primitives, a schema version. Version the contract. Add fields. Do not reuse field meanings.
Idempotency is part of the contract, not a later hardening pass. At-least-once delivery is what you have. A unique key derived from the message, inserted in the same transaction as the effect, is the boring solution. If the effect is an HTTP call to a payment provider, use their idempotency key. If you cannot, you do not have a safe consumer yet.
Poison messages and a DLQ belong to the first service, not to “the platform later.” A first consumer that retries invalid JSON forever is a self-inflicted outage.
Auth, sessions, and who trusts whom
PHP session files or Redis sessions are not a Go auth story. Neither is “copy the cookie name.”
Options that work in production:
- Go is an internal worker. No user auth. Network policy, mTLS, a queue that only PHP publishes to. This is the correct default for the first service.
- Go sits behind the same gateway and receives an identity the gateway already verified. Go trusts the gateway, not the raw cookie. Headers like a signed JWT from the edge, short-lived, audience-bound.
- Go calls PHP to authorise a user-facing action. Slow, but it keeps the policy in one place while you learn. Do not clone the ACL into Go on day one. You will diverge, and the divergence will be a security bug.
What fails: sharing an encrypted Laravel cookie and reimplementing the encrypter. Sharing a Doctrine user table and querying it with a different password hasher. “We’ll just read users.roles.” Roles without the same middleware stack are a different product.
Service-to-service auth is separate from user auth. A shared secret in an env file is acceptable for a first internal consumer if the network is private and the secret rotates. It is not acceptable as the story for anything on the public internet.
Observability has to exist before the cutover
You cannot compare PHP and Go if they do not emit the same fields.
Minimum before traffic moves:
- Structured logs with
trace_id,message_idorrequest_id,outcome,duration_ms. Same keys on both sides. - Trace context across the hop (
traceparenton HTTP, copied into message headers on the queue). - Metrics: rate, errors, duration histogram, queue age, in-flight, DLQ depth. Queue age predicts a customer noticing. CPU does not.
- Dashboards and alerts that a human on PHP-on-call can read without learning PromQL from scratch on the night of the cutover.
If the first time you look at a Go pprof is during an incident, you added a runtime you cannot operate. Long-lived Go processes leak. PHP hid that with process recycle. Budget time for pprof and for a memory cap on the pod that pages someone before OOMKill loops.
Staging will lie about cache hit rates and about the partner API. Shadow on production data where the side effects can be made inert. If they cannot, pick a different first workload.
Pick a first workload that is not checkout
Checkout is where money, tax, inventory, gift cards, and fraud rules meet. It is the worst place to learn a new deployment topology. The first Go service should be boring to fail.
Good first workloads:
- A queue consumer that is CPU-heavy or memory-heavy and already isolated behind a message contract.
- An export or import that runs on a schedule, with output you can diff against PHP.
- A fan-out notifier that currently blocks a request thread and can be made async without changing the user contract.
Bad first workloads:
- The monolith’s login.
- Anything that must dual-write
orders. - A “platform” service that every future service will depend on. You will design it twice, once in ignorance and once in production.
Keep PHP as the producer at first. Swap the consumer. Keep PHP running. Compare. Then raise the Go share. This is the strangler in miniature. It does not require a mesh and a platform team. It requires a flag and two consumers.
Deploy, rollback, and keeping PHP in the loop
Two artefacts means two pipelines, or one pipeline that builds both and can release them independently. Independent is the point. A Go service that can only ship when the PHP monolith ships is not extracted. It is a subdirectory with extra latency.
Rollback for a consumer: set the Go share to zero. PHP is still running. That requires you not to delete the PHP worker on the day you reach 50%. Config change, not a revert commit, not a schema down-migration.
Rollback for a synchronous HTTP extract: the gateway routes back to PHP. If you put the route inside the Go binary, you cannot abort without deploying Go. Put the route at the edge, or in PHP as a feature flag that never calls Go.
Health checks must fail when the service cannot do useful work: database, broker, required upstream. A Go process that returns 200 while it cannot publish is a liar. Kubernetes will keep it.
On-call must include the new service on day one. A binary only one person can debug is a bus factor, not a migration. If the PHP team cannot read the logs and roll back the flag, you are not done. You have a hobby in production.
Do not freeze the product while this happens. The first service is small enough that product work continues in PHP. If the extract requires a feature freeze, the slice is too big. Cut it again.
One service is a test of the organisation
The point of the first Go service next to PHP is evidence: can you own a table, a contract, a deploy, and a rollback without becoming a two-system company by accident? If yes, you have a method. If no, a second service will not fix it.
A workload-first PHP to Go migration stays optional until that method exists. Workers remain the usual proof. Checkout remains PHP until you have a reason that survives contact with ownership.
If you want the first slice chosen with evidence rather than enthusiasm, a Migration Readiness Audit at €1,200 is the written answer: which workload, who owns the data, what the abort switch is, and whether you should stay in PHP. Implementation from €402/day on the PHP to Go migration engagement is only for a slice that document named. I would rather tell you to keep the monolith and fix the worker’s indexes than help you put a Go service on the checkout path because the diagram looked clean.