Programming

Experimenting with async PHP using Swoole

PRG

PHP's traditional request model — spin up, handle one request, tear down — has always meant that anything requiring long-lived connections or high-throughput async I/O pushed teams toward Node or Go instead. Swoole, a PHP extension that adds an async, event-driven runtime, challenges that assumption, and we ran it on an internal service that needed to handle a large number of concurrent long-polling connections, a workload that our usual PHP-FPM setup was never designed to handle gracefully.

Why we even considered PHP for this workload

The obvious path for a connection-heavy async workload would have been to simply write the service in Node, which is what we would have defaulted to a year or two earlier without a second thought. What made Swoole worth a real evaluation this time was that the service in question needed to share a meaningful amount of business logic — validation rules, data transformation code — with an existing PHP application, and duplicating that logic in a second language carried its own real maintenance cost that a same-language async option could avoid entirely if it actually worked well enough in practice.

What Swoole actually changes under the hood

Swoole replaces PHP's usual process-per-request model with a long-running event loop, similar in spirit to Node's own event loop, letting a single worker process handle many concurrent connections through coroutines rather than spinning up a new process or thread per request. The performance difference over our usual PHP-FPM setup was substantial for this specific workload — Swoole's coroutine model let a single worker handle far more concurrent connections than PHP-FPM's one-process-per-request model ever could, since PHP-FPM's approach means every single concurrent connection consumes its own process with its own memory overhead, which simply does not scale to the connection counts a long-polling workload needs.

The mental model shift this required from the team

It does mean giving up some of the comfort of the traditional PHP request lifecycle, since a Swoole-based application is a long-running process, and mistakes like leaking global state between requests become possible in a way they simply are not in classic PHP, where every request starts from a genuinely clean slate by design. A static variable that used to safely reset itself between requests under the old model can now silently leak state from one request into the next if a developer is not deliberately careful, which is exactly the kind of subtle bug class that a team used to classic PHP's request isolation has to consciously unlearn old assumptions to avoid.

Specific bugs we actually hit during the transition

  • A cache object initialized once at application startup, intended as a shared long-lived cache, accidentally held onto data from an earlier request longer than intended because a developer assumed request-scoped behavior that Swoole's long-running process model does not provide by default
  • A third-party library making a blocking, synchronous I/O call inside an otherwise async coroutine, silently stalling the entire event loop for every other concurrent connection rather than just the one request that triggered it, since Swoole's concurrency model depends on every piece of code cooperating with its async execution model rather than blocking it
  • Error handling around a coroutine that failed silently rather than surfacing an exception the way a classic PHP request would have, which took real debugging time to trace back to a mismatched error-handling expectation carried over from our classic-PHP habits

Where we are keeping this scoped for now

We are keeping it scoped to this one internal service for now rather than pushing it onto client work — the mental model shift is real, and most client applications do not have the connection-heavy workload that makes it worth the added complexity. Recommending Swoole for a typical client CRUD application would introduce a genuinely different set of operational risks and debugging habits for very little practical benefit, since most client applications simply do not need this specific kind of concurrency.

Results

For the right problem, it is a legitimately compelling option that we did not expect from PHP, and this internal service has handled connection loads under Swoole that would have required either a complete rewrite in a different language or a considerably more expensive horizontally-scaled PHP-FPM deployment to achieve the same result. We expect to keep this pattern available in our toolkit specifically for the narrow class of problems it genuinely solves well, rather than treating it as PHP's new general-purpose default.

Load testing before trusting it with real traffic

Before routing any real traffic through the Swoole-based service, we ran an extended load test simulating several times the peak concurrent connection count we actually expected in production, specifically to catch any of the subtle async-related bugs described above under sustained load rather than during the more forgiving conditions of everyday development testing. This load test caught a slow memory growth pattern that would not have been visible in a short manual test, traced back to event listeners on closed connections not being properly cleaned up, a class of bug that Swoole's long-running process model makes possible in a way a per-request PHP-FPM process, which simply gets torn down and its memory reclaimed after every single request, never has to worry about.

How we monitor a Swoole-based service differently

Standard PHP-FPM monitoring tools assume the familiar process-per-request model and do not translate cleanly onto a long-running Swoole process, so we built custom metrics specifically tracking active coroutine count, event loop lag, and per-connection memory usage — metrics that simply do not exist as meaningful concepts in a traditional PHP-FPM deployment, but that are essential for understanding whether a long-running async PHP process is healthy or slowly degrading in a way that would eventually manifest as a much more visible production incident.

Comparing the actual infrastructure cost against the Node alternative we did not build

Since we seriously considered building this same service in Node before choosing Swoole, we tracked the actual infrastructure cost of the Swoole solution against a rough estimate of what an equivalent Node service would have cost to run, based on similar workloads elsewhere in our infrastructure. The two came out close enough that infrastructure cost alone did not meaningfully favor either option, which reinforced that the deciding factor here really was the shared business logic consideration rather than any dramatic performance or cost advantage either language held over the other for this specific workload.

What we would tell another PHP-heavy team considering Swoole

Do not reach for Swoole as a general performance upgrade to an existing PHP application — it solves a specific problem, long-lived high-concurrency connections, and applying it to a typical request-response CRUD application would introduce real complexity for no corresponding benefit, since that kind of workload is exactly what PHP-FPM's simpler, request-isolated model already handles well. Reach for it specifically when you have identified a genuine connection-heavy workload and a real reason to keep it in the same language as an existing PHP codebase, rather than as a first instinct whenever a workload feels slow.

← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients