PHP → Go

A Resident PHP Runtime Is Not a Go Migration

PHP-FPM spends a process per request. FrankenPHP and RoadRunner keep the process alive. That can remove the ceiling that teams cite when they ask for a Go rewrite, and it leaves the database exactly where it was.

A PHP shop hits a concurrency wall, reads that Go handles it with goroutines, and opens a migration document. Sometimes the wall is real and the language is the right lever. Often the wall is PHP-FPM’s process model, and a resident PHP runtime moves it without a second language, a second deploy pipeline, and a year of rewriting code the business already trusts.

FrankenPHP and RoadRunner are the two names that come up. Swoole is the third, with a different extension and a different set of surprises. They are not equivalent products. They are the same idea: stop booting the framework on every request, keep workers alive, and let one process hold many connections. I wrote the FPM side of the ceiling separately, in PHP-FPM concurrency limits. This article is the question that should come next: try a long-lived PHP worker before you fund a Go service, and know what that trial does not prove.

What you are actually paying FPM for

Each PHP-FPM child handles one request at a time. While it waits on PostgreSQL, a payment API, or the filesystem, it is still a child. pm.max_children times memory per child is the concurrency budget. The CPU is often idle. The listen queue is not.

A resident runtime changes the unit. A worker process stays up, the framework boots once, and the server accepts another request on the same process or on a pool of warm workers. Waiting no longer costs you a full OS process per in-flight call, as long as the runtime actually multiplexes those waits. That is the part you must read in the specific product, not in a conference slide. Some modes are still “one request per worker thread” with a warmer boot. That helps memory and startup. It does not give you a million idle connections. Other modes will park the request while the downstream call is in flight. That is the mode that resembles the reason people cite Go.

Measure before you pick a mode. If active processes sit at max_children and the profiler says the time is in a client library waiting on the network, a resident runtime is a candidate. If the time is in a sequential scan, neither FrankenPHP nor Go will make the scan cheap. Profile the PHP you have before you change the process model. A new server in front of the same EXPLAIN is a faster way to serve a bad query.

What FrankenPHP and RoadRunner change

Both keep application code in memory between requests. Opcache is no longer paying for a cold start on every hit. Framework boot, container compilation, and config parsing move to worker start. The first request after a deploy is slow. The next thousand are not. That alone fixes a class of latency that teams blame on “PHP” when they mean “we boot Laravel to render a health check.”

FrankenPHP embeds PHP in a server based on Caddy. It can speak HTTP/2 and HTTP/3, run as a single binary you are willing to operate, and offer a worker mode. Early-hints and the surrounding Caddy features are operational extras, not the reason to migrate. The reason is the worker. You should know which SAPIs and extensions your app uses before you assume they load. A build that works on FPM and fails because an extension was not in the FrankenPHP image is a packaging problem, and it is the kind you want in staging in a day rather than in a rewrite in a quarter.

RoadRunner is a Go process supervisor that runs PHP workers over a local protocol (commonly Goridge). Your PHP code stays PHP. The Go part is the server, the process manager, and a set of plugins for queues, jobs, and temporal-style features depending on how you adopt it. You do not have to write your domain in Go to use it. You do have to operate a Go binary you did not author, and you have to accept that your request now crosses a pipe between that binary and a PHP worker. That hop is small. It is still a hop, with its own timeouts.

Swoole and Open Swoole replace the server with a PHP extension and coroutines. They can be the highest-throughput option and the easiest way to corrupt a request, because code that assumed a clean process now shares one. I would not start there unless the team has already lived with coroutine PHP. FrankenPHP or RoadRunner is the smaller cultural change for a Symfony or Laravel FPM app.

What improves when the fit is real:

  • memory per in-flight request, once you are not paying a whole process for each waiter;
  • time to first byte on routes that were boot-bound;
  • the ability to hold a websocket or a server-sent stream without dedicating an FPM child for the life of the connection;
  • connection reuse to PostgreSQL and HTTP dependencies, which is also how you can blow the database’s connection limit if you are careless.

What you must add because FPM used to reset the world for you: a max number of requests per worker before recycle, a memory limit that actually restarts the worker, and a ban on anything stored in a global that belongs to one user.

The bugs process-per-request was hiding

Static properties, singletons, and static $cache inside a function become cross-request leaks the day the process survives the request. A static that remembered the current user will serve that user to the next request. This is not a theoretical CVE. It is the first bug teams hit, usually in a staging environment that only had one worker and one tester, so it passed.

Doctrine entity managers and Eloquent models that live for the process will accumulate identity maps. You will return stale entities and growing memory. The fix is to clear the manager between requests, or to use the integration the runtime’s Laravel or Symfony bridge already provides. If you bypass the bridge because it was annoying, you own the leak.

Open file handles, curl multi handles, and PDO connections that were “closed when the process exited” now live until you close them. A worker that opens a connection per request and never returns it to a pool will look fine for an hour and then stop accepting work.

Superglobals and request-scoped containers must be reset. Code that reads $_GET in a service constructor captured the first request forever. The constructor ran once. That is the bug.

Output buffering and header maps that assumed a fresh SAPI will append to the previous request if something does not reset them. Use the runtime’s request lifecycle. Do not call fastcgi_finish_request and hope.

None of these require a new language. They require a checklist and a worker count of at least two in staging, plus a test that sends two requests as two users back to back on the same worker. If that test is missing, do not point production traffic at the new server.

What a resident runtime does not decide

It does not make PHP’s single-request mental model into Go’s. CPU-bound work still occupies the worker that runs it. A tight loop in PHP is still a tight loop in PHP. Goroutines are a different tool when you need many computations in flight with a scheduler that was built for that. PHP versus Go by workload still applies after FrankenPHP is green. You have removed boot and the FPM process tax. You have not acquired a new standard library, a static binary of your own code, or a team that writes Go.

It does not fix queries. An N+1 inside a warm worker is a faster N+1 until the database falls over, at which point it is a worse incident because each worker now has a persistent connection and the pool is larger than FPM’s child count was. Set a ceiling on database connections as a separate number from worker count. PgBouncer remains relevant.

It does not split a monolith. You still deploy one application. That is an advantage if the pain was latency, and a disadvantage if the pain was “every change to billing risks the storefront.” Process model is not a boundary. If you need a boundary, you still need a boundary, and the strangler discussion is about ownership, not about Caddy versus FPM.

It does not remove Composer, PHP upgrades, or abandoned packages. A Symfony application that cannot move forward on FPM cannot move forward on FrankenPHP either. The runtime is not an escape from the upgrade you are blocked on.

Queues are a partial overlap. RoadRunner will happily run PHP workers for jobs. That can be the right place to spend concurrency. It can also be a second queue stack next to Horizon or Messenger. Do not operate two job systems because a plugin looked convenient. If the reason to leave PHP workers is memory per long job, measure that. A resident PHP worker with a fixed prefetch often is the whole answer. A Go consumer is the answer when the job’s own code is the CPU or when the process model still cannot hold the fan-out after you have tried the resident runtime honestly.

A trial that is allowed to end in a week

Run it as an experiment with a rollback, not as a platform programme.

  1. Pick two routes: one boot-heavy HTML or JSON route, one route that waits on a downstream system. Leave checkout on FPM until the worker reset is proven.
  2. Put FrankenPHP or RoadRunner in staging with at least two workers. Send two different users through the same worker. Diff the responses.
  3. Watch memory after 10,000 requests. Recycle workers on a request count or an RSS ceiling from the start, not after the first leak in production.
  4. Cap outbound database connections. Load-test with the slow route included.
  5. Canary a slice of production traffic that is not the money path. Keep FPM in the load balancer as the target you can cut back to in one config change.
  6. Write down the result: p95, worker RSS, error rate, and the leaks you found.

If the numbers move and the leaks are fixed, you have bought yourself time. Spend that time on the queries and the release path. Revisit Go only for a workload the resident runtime still cannot hold: a large fan-out, a websocket estate, a CPU-bound pipeline you do not want in PHP.

If the numbers do not move, you learned that the ceiling was not FPM. That is a successful week. The rewrite you did not start is the budget you kept.

When Go is still the next sentence

Move a slice to Go when the thing you need is the language and the runtime together: many in-flight operations in one process you are willing to write in Go, a static binary your platform team actually wants, or a worker whose CPU profile is already in your own code and a PHP process will not make it cheaper. Also move it when the team has Go in production already and the cost of a second PHP server is higher than the cost of one more service in a system they operate.

Do not move it because a blog post said FPM is dated. Do not move it because FrankenPHP’s worker mode scared people in code review and a rewrite felt cleaner. A rewrite has the same shared-state bugs, plus every behaviour change you failed to characterize.

If you want the experiment scoped against your pm.max_children, your RSS, and the two routes that hurt, that is a Migration Readiness Audit at €1,200. A legitimate recommendation is “run FrankenPHP on these routes, do not open a Go repository.” Another is “the wait is a scan, add the index.” A third is a single Go service, implemented at €402/day as PHP to Go migration work, after the resident runtime has been ruled out with numbers rather than taste.

FPM’s ceiling is real. It is not, by itself, a reason to leave PHP.