PHP → Go

The Checkout Is the Worst First Extraction From a PHP Monolith

Checkout looks like the valuable first Go service. It is the most coupled write path you have. Extract workers, uploads or reports until you can take money without a dual-write fantasy.

The first extraction meeting on a PHP monolith often ends with checkout. It is where the money is. It is slow under load. It is embarrassing when it fails. A Go service that “owns orders” sounds like a strategy. It is usually the most expensive way to learn that your order table is not a service boundary.

Rewriting the PHP checkout in Go first fails for a boring reason: checkout is not a controller. It is a write path that touches inventory, promotions, tax, shipping quotes, identity, payments, invoices, emails, and a set of inbound webhooks that already disagree with each other at 2% of volume. A strangler pattern can move that, eventually. It should not move it as the experiment that teaches you whether Go is even a good fit.

I have watched teams spend a quarter dual-writing order rows while the PHP application continued to be the system of record for refunds. The Go service was “done.” Finance was not. This article is the argument for picking a first slice that cannot strand money, and for treating checkout as a late extraction with an explicit data-ownership plan.

Why checkout looks like the obvious first cut

The arguments are sincere, and each one is incomplete.

It is the hotspot. APM says /checkout or PlaceOrder dominates p95. That measurement is usually a mix of payment-provider latency, a promotion engine that loads too much, and a few queries that also run on other pages. Moving the HTTP handler to Go does not remove the provider or the queries. It moves the waiting into another binary.

It is valuable. Leadership will fund a migration that “protects revenue.” They will not fund “extract the PDF worker.” The funding argument is not an architectural argument. You can protect revenue by leaving checkout in PHP and taking load off it: reports, exports, image variants, search indexing, webhook fan-out.

It is a bounded context on the whiteboard. “Orders” looks like a hexagon. In the database it is a pile of foreign keys: user_id, cart_id, reservation_id, payment_intent_id, shipment_id, discount_id, tax_transaction_id. Whiteboards do not pay for dual writes.

Go will make it faster. Maybe, if the CPU in your code is the bottleneck. For most PHP checkouts the wall time is I/O. You will still be I/O in Go, now with a team that is learning a new runtime while money moves.

The PHP to Go migration guide starts from workloads, not from the page that hurts the most in a demo. Checkout is a workload. It is the wrong first workload.

What the money path actually couples to

Before you draw a Go box, list the side effects of a successful place-order in the current PHP application. Not the controller method. The transaction, the jobs it dispatches, the listeners, the HTTP calls.

A typical list:

  • Reserve or decrement stock (often a row lock, sometimes a separate reservation table).
  • Validate and consume a promotion, which may have its own remaining-uses counter.
  • Call a tax service or a local tax table.
  • Call a shipping-rate API, or select a cached rate that expires.
  • Create the order and line items.
  • Create a payment intent with a provider (Adyen, Stripe, a bank). Redirect or complete 3-D Secure.
  • Persist a mapping from provider payment id to order id.
  • Dispatch emails, analytics, search, ERP, warehouse.
  • Expose an endpoint the provider will call later: capture, fail, chargeback, partial refund.

Every one of those lines is a reason the first Go checkout is not “one service.” If Go owns the HTTP POST and PHP still owns refunds, you have two writers to order status. If Go owns payments and PHP still decrements stock, you have a distributed transaction you did not design.

Identity and cart are the silent couplings. Checkout reads a session, a customer record, stored instruments, addresses, VAT ids. Moving checkout without moving identity means Go calls PHP for the user on every attempt, or you replicate user rows. Both are possible. Neither is a first-slice project.

PCI and provider constraints make it worse. You may not want a new binary in the cardholder-data flow as the thing you are also using to learn Kubernetes, structured logging, and database/sql. Keep the existing, audited PHP integration until the surrounding system is boring.

Dual-write and dual-read on payments is how you lose orders

Incremental migration needs a period where old and new coexist. For a read-mostly endpoint, coexistence is a router flag. For checkout, coexistence is two ways to take money.

Dual write

The fantasy: PHP writes the order, Go writes the order, a worker reconciles. The reality: one of the writes fails, the payment provider already captured, the reconciliation job is a day late, support refunds the customer, the warehouse ships anyway.

If you insist on two writers, you need a single system of record for “this payment intent exists” and an idempotency key that both runtimes honour. That is a project. It is not a side effect of “extract checkout.” The usual honest pattern is: exactly one service writes the order tables. The other runtime sends a command and waits, or it does not participate.

During a strangler, that usually means PHP remains the writer for longer than people want. Go can still help around it.

Dual read

Admin, customer order history, invoices, and warehouse UIs all read the same rows. A Go checkout that writes to a new orders_v2 schema means you now have two histories. You will build a merge view. The merge view will be wrong for partial captures.

If you keep one schema and let Go write through PHP’s tables, you have not extracted a service. You have a new client of the monolith’s database. That can be a temporary step. Call it that. Do not celebrate it as a bounded context.

Webhooks

Payment webhooks are the part of checkout that already has at-least-once delivery, out-of-order events, and retries. They often hit a PHP URL that has been stable for years. If Go takes the webhook first, PHP never learns the payment succeeded. If PHP keeps the webhook and Go took the place-order call, Go’s order stays pending until something copies status across.

The webhook is not an implementation detail. It is the other half of the money path. Any first extraction that does not include it is a half-checkout. Including it on day one is how the first slice becomes the whole payments domain.

Sagas

People reach for sagas when they notice they cannot have a single database transaction across stock, tax, and capture. PHP already had this problem; it papered over it with “do the HTTP calls inside the request and hope.” Go does not remove the saga. It makes the missing saga visible. Visible is good. Visible as your first migration is how the programme stalls on orchestration instead of on learning Go.

Better first slices: workers, uploads, reports

You want a first extraction with a narrow interface, no user waiting on the response, and a way to run old and new in parallel without two payment writers.

Queue workers. A consumer that resizes nothing money-shaped: search indexing, webhook outbound delivery, cache warming, notification fan-out, slow third-party sync. The contract is a message. The queue already retries. You can run PHP and Go consumers on copies of the stream and compare side effects. I wrote the operational detail as moving PHP queue workers to Go. That is a first slice that teaches you deployment, observability, and idempotency without taking a card.

Uploads and derivatives. Image variants, PDF invoices generated after the fact, virus scanning, transcoding. CPU or process-memory bound, isolated, easy to roll back (keep the PHP job, stop consuming with Go). The customer already has the HTTP 200 from the upload endpoint. You are not in the money path.

Reports and exports. The nightly CSV, the admin “download all orders,” the BI query that scans order_lines during the day and stalls checkout. Extracting reporting load is often a larger reliability win than a faster POST /checkout. Reporting still reads money, but it should not write it. A replica or a warehouse is allowed to be slightly stale. Checkout is not. Sequence reporting off the primary before you rewrite the writer.

New features that would otherwise land in the monolith. A new feed, a new integration, a new read model. No dual write because there is no old writer.

Read-heavy calculations with a small input set. Pricing display, if it is already a pure function of a product snapshot. Be careful: pricing that must match checkout totals is a money path in disguise. If the displayed price and the charged price can diverge, you have created a support queue.

Bad first slices remain what the strangler article already called out: identity, permissions, the core checkout write path. They sit in the middle. Everything else waits on plumbing.

How to get to checkout later without pretending you skipped the hard part

Checkout can move. It should move when the following are true:

  • You have already extracted something else to Go and run it in production. The runtime, the deploy, the on-call, the logging conventions are not also being invented.
  • Table ownership is written down. One writer for orders, one writer for payments mapping, one writer for stock, or an explicit decision that PHP still writes all three.
  • Webhooks have a single ingress and a recorded mapping to internal events.
  • Idempotency keys exist for place-order and for capture, and you have tested duplicate delivery.
  • Refunds, partial captures, and chargebacks have an owner. If they stay in PHP, Go must not invent a second status machine.
  • The router can send a cohort of traffic (staff, then one shop, then a percentage) without the client knowing.
  • You have a kill switch that is not “deploy the old PHP and hope in-flight payments finish.”

A practical sequence that respects that list:

  1. Take reporting and heavy reads off the primary so checkout is not competing with GROUP BY.
  2. Move a non-money worker to Go and prove cutover.
  3. Isolate payment-provider I/O behind a narrow PHP module if it is currently smeared across controllers. That is still PHP, and it is cheaper than a rewrite.
  4. Introduce an order read model if other services need it, still written from PHP.
  5. Only then consider a Go writer, behind a flag, with PHP as fallback, on a cohort that is not your peak market.

If the honest reading of the coupling map is “checkout is the monolith,” the answer may be to improve PHP checkout in place: timeouts, fewer queries, promotions loaded as a DTO, stock reservation as a single statement, webhooks made idempotent. A rewrite is a very expensive profiler. Language change is option four, not option one with a fashionable runtime.

If you want the sequence written against your tables

The commercial form of this advice is not “rewrite checkout in Go on a rate.” It is a readiness pass: which workloads justify extraction, which tables have one writer, which first slice is a worker or a report, and which parts should stay in PHP. That is the PHP to Go migration work I sell, including the outcome where checkout stays where it is.

I will say this in the first person because it is the part vendors dislike: if your plan is Go checkout in quarter one, I will argue you down. I would rather extract a worker you can revert than a money path you will be explaining to finance. If that is not the conversation you want, you do not want this engagement.