Before Rewriting PHP in Go, Find Out What Is Actually Slow
A rewrite is a very expensive profiler. This is the order I investigate a slow PHP application in, and the point at which changing language actually becomes the right lever.
Rewriting a slow PHP application in Go before profiling it is an expensive way to find out that the bottleneck was PostgreSQL. Runtime speed is real and it is measurable, but a production request spends most of its life waiting, on a query planner that chose a sequential scan, on a payment provider’s API, on a cache that stampedes every five minutes, on serialising an object graph nobody needed. Change the language and all of that comes with you, now in a codebase your team has been writing for three weeks. Serious PHP performance optimization starts with measurement, and measurement usually reveals that the language is somewhere around fourth on the list.
This is not an argument against Go. I make a living moving PHP workloads to Go where that is the right call. It is an argument for knowing which problem you have before you pick a solution that costs two quarters.
Measure first: wall time, CPU time, and where they diverge
The first thing to settle is which number you are optimising. Wall time is what the user experiences. CPU time is what the language runtime affects. When a 900 ms endpoint burns 80 ms of CPU, the remaining 820 ms is waiting, and the fastest runtime in the world will not recover it. When a 900 ms endpoint burns 850 ms of CPU, you have a genuinely compute-bound path and Go is suddenly interesting.
Three kinds of tooling answer three different questions, and you want all three:
- APM traces (Datadog, New Relic, Tideways, Blackfire’s monitoring, or OpenTelemetry with a collector you run yourself) tell you which endpoints matter and how a request’s wall time splits between PHP, SQL, cache and outbound HTTP. Start here. Sample in production; staging lies about data volume and cache hit rates.
- A sampling profiler (
excimer, SPX, or a continuous profiler) tells you which PHP functions consume CPU under real traffic, at low enough overhead to leave on. Prefer this to a tracing profiler for production work. - A tracing profiler (Blackfire, Xdebug) tells you exact call counts on one request. Invaluable for finding N+1 patterns, misleading for wall-clock attribution because of its own overhead.
Rank by total contribution, not by worst case. An endpoint at 3 seconds called forty times a day is a curiosity; a 180 ms endpoint called two million times a day is your infrastructure bill. pg_stat_statements sorted by total_exec_time answers the same question on the database side, and it usually disagrees with whatever the team believed.
Then fix things in this order. It is not arbitrary, it is ordered by how much wall time is typically hiding in each layer versus how much effort it takes to recover it.
Step 1: the database
In most legacy PHP applications I look at, this is where the majority of recoverable wall time lives. Not because PostgreSQL or MySQL is slow, but because queries were written against a table with 40,000 rows and are now running against 40 million.
Run EXPLAIN (ANALYZE, BUFFERS) on the top offenders from pg_stat_statements and read three things: whether estimated rows are close to actual rows, what the node types are, and where the buffers came from. A planner estimate that is off by two orders of magnitude means stale statistics, a correlation the planner cannot see, or a predicate it cannot use, and it will keep choosing bad plans until you fix the cause. A Sort node with external merge Disk means work_mem is too small for that query. High shared read versus shared hit means you are going to disk for data you expected to be cached.
Indexes and selectivity
An index only helps if it is selective enough that the planner prefers it. An index on a boolean is_active column where 95% of rows are active is dead weight: it slows writes, occupies cache, and the planner will correctly ignore it. Composite index column order matters, (tenant_id, created_at) serves both a tenant filter and a per-tenant date range, while (created_at, tenant_id) serves neither well. Partial indexes (WHERE status = 'pending') are the right tool for the queue-shaped tables that every legacy application accumulates, and covering indexes can turn a heap fetch into an index-only scan.
Also look for the things that silently disable an index: a function or cast on the indexed column, a LIKE '%term' leading wildcard, an implicit type coercion between a varchar column and an integer parameter, and OR conditions across different columns.
N+1 queries
The single most common finding, and the one with the best effort-to-payoff ratio. A list page renders 50 rows, each row lazily loads a relation, and you have 51 round trips where 2 would do. Each is fast in isolation, which is exactly why it hides, it never shows up in a slow query log, only in the trace’s call count.
Fix it by eager-loading the relation, and then check what you actually did. Eloquent’s with() and Doctrine’s fetch="EAGER" or an explicit JOIN with addSelect solve the query count; they can also hydrate several thousand full entity objects to render six fields, replacing a query problem with a memory and CPU problem. For read paths, a hand-written query returning a flat array or DTO beats full hydration by a wide margin. Add a test-environment assertion on query count per endpoint so the pattern cannot come back quietly.
Step 2: outbound network calls in the request path
Every synchronous call to a third party, tax lookup, address validation, payment tokenisation, an internal service, puts that provider’s tail latency inside your own. Check whether each one has an explicit connect and read timeout, because a missing CURLOPT_TIMEOUT means your worst case is however long their load balancer takes to give up. Check whether the connection is reused or renegotiates TLS each time. Then ask the harder question: does this need to happen before the response, or can it move behind a queue? A surprising share of request-path calls exist only because writing an asynchronous path felt like more work at the time.
Remember the structural cost in PHP: a blocking outbound call holds an entire PHP-FPM child. If a child holds, say, 120 MB resident and the pool allows 40 children, a single slow dependency can exhaust your concurrency long before CPU becomes a factor.
Step 3: caching, and the ways Redis goes wrong
Redis is fast, so cache problems present as mysterious rather than slow.
Stampedes. A popular key expires, two hundred concurrent requests miss simultaneously, and all of them run the expensive query. Fix with a short lock so one request recomputes while others serve stale, or with probabilistic early recomputation. Then add jitter to every TTL, identical TTLs set during a deploy expire together, and you have built a synchronised load spike.
Wrong TTLs. Long TTLs on data that changes create support tickets; short TTLs on expensive derived data create load. Where correctness matters, invalidate on write rather than guessing an interval.
Round trips. Fifty individual GET calls in a loop is an N+1 against Redis. MGET or pipelining collapses it. The same applies to session and cache-tag lookups that happen implicitly on every request.
Redis as a database. Unbounded key growth, large hashes used as primary storage, KEYS in production code, and a hot key that pins one shard. Redis is single-threaded per instance for command execution, so one SMEMBERS over a million-member set blocks everything else on that instance.
Serialisation. If you cache large object graphs, the cost of serialize/unserialize or json_decode can exceed the query you avoided. Cache the smallest useful shape, a DTO or a flat array, not a hydrated ORM aggregate.
Step 4: queue design and what does not belong in a request
Look at what your endpoints do after the useful work: sending mail, generating PDFs, writing to a search index, calling webhooks, resizing images, recalculating aggregates. Each one moved behind a queue is latency removed from the user’s path with no change in language.
Then look at the queue itself, because a badly designed queue creates its own problems: jobs that carry entire serialised entities instead of an ID, so payloads go stale; no visibility into depth or age, so nobody notices a backlog until customers do; retries without idempotency, so a retried job sends a second invoice. Worker throughput and concurrency behaviour is a separate topic and the place where Go earns its keep most often, I cover the specifics of moving queue consumers off PHP and the safe way to cut over separately.
Step 5: the PHP runtime itself
Now, and only now, the language. There is real performance available here and it is cheap to collect.
Version. If you are on PHP 7.x, upgrading to a current 8.x release is the single highest-return runtime change available, and it is usually a smaller project than teams fear. The engine improvements between 7.0 and 8.x are substantial and broadly documented; you do not need a rewrite to get them.
OPcache. Verify it is actually enabled in production with a sane opcache.memory_consumption, a high enough opcache.max_accelerated_files to hold your whole codebase plus vendor, and opcache.validate_timestamps=0 on immutable deploys. A cache that evicts because it is too small produces intermittent slowness that looks like a database problem.
Preloading. opcache.preload loads and links your framework’s hot classes once at startup instead of per request. For a Symfony or Laravel application with a deep class graph this removes real per-request work, at the cost of requiring a restart to pick up code changes. Measure it; the benefit varies a lot by application shape.
Realpath cache. With a large vendor/ tree, stat calls on file resolution add up. Raise realpath_cache_size and realpath_cache_ttl and check the hit rate before assuming it is fine.
Autoloader and containers. Composer’s optimised autoloader with an authoritative classmap in production, plus a compiled DI container and compiled routes, removes a layer of filesystem work per request.
JIT, expectations. Be honest about this one. PHP’s JIT helps tight numeric and CPU-bound loops. Typical web request handling, which is dominated by I/O and array and string manipulation across many short-lived functions, sees little to nothing, and occasionally regresses. If your profiler shows the request is 85% waiting, the JIT has nothing to work with.
FPM sizing. Measure real memory per child under production traffic and set pm.max_children from available memory rather than a copied config. Too many children means swapping and OOM kills; too few means requests queueing in the listen backlog while CPU sits idle. pm.max_requests exists to bound the effect of leaks in long-lived children.
Step 6: application architecture
The remaining wall time is usually structural. Middleware stacks that run authorisation queries three times per request. Event listeners registered eagerly so every request pays for a subsystem it does not use. Serializers walking an entire object graph to emit a summary. Templates that trigger lazy loads during rendering. Permission checks that hit the database per row instead of loading a set once.
None of this is a language problem, and all of it survives a rewrite unless you change the design. That is the core point: a rewrite that reproduces the same architecture in Go inherits the same query patterns, and you will spend the last month of the project rediscovering the indexes you should have added first.
When Go actually becomes the relevant lever
Having done the above, the cases where changing runtime is the right answer are quite specific, and they are real:
- Concurrency shape. You need to fan out to many dependencies per request, hold tens of thousands of open connections, run websockets or SSE, or coordinate work in flight. A process-per-request model is a poor fit; goroutines are a good one.
- Long-lived processes. Consumers, schedulers, stream processors and anything that benefits from warm in-process state, connection pooling and a shared cache across jobs rather than bootstrapping a framework per unit of work.
- CPU-bound work at volume. Encoding, parsing, cryptography, image and document pipelines, large-scale data transformation, where the profiler genuinely shows CPU time in your own code.
- Memory density per unit of concurrency. When your concurrency ceiling is set by resident memory per worker rather than by CPU, a single process with N goroutines changes the arithmetic. The broader trade-offs between the two runtimes are worth reading before you commit to either.
If none of those describe your situation, Go will not fix your p99, and the rewrite will consume the capacity you needed for the work that would have.
How to run a PHP performance optimization review
Two weeks of measurement, in this order, traces, then pg_stat_statements and EXPLAIN ANALYZE, then cache behaviour, then runtime configuration, then architecture, reliably produces a ranked list where each item has an estimated recovery and an estimated cost. Most teams find that the top three items are database work and configuration, deliverable inside a sprint, with no migration required. Some find a genuine runtime mismatch, and then extracting one component into Go is a well-scoped project instead of a rewrite.
When teams bring me in as a PHP performance consultant, that ranked list is the deliverable, and it deliberately includes the items that make a migration unnecessary. A fixed-scope Migration Readiness Audit at €2,500 exists precisely to answer the “should we rewrite this” question with evidence, you can see how that decision process works and what it produces. Sometimes the recommendation is to extract a service in Go. Sometimes it is to add four indexes, fix an N+1, enable preloading, and revisit the question next year. The second outcome is cheaper for you and it is a perfectly good result.
The worst outcome is a rewrite that starts before anyone ran EXPLAIN ANALYZE.