Back to blog

Leaving PHP, Part 6: async, concurrency, and streaming are still awkward

Part 1 looked at hiring. Part 2 looked at the runtime. Part 3 looked at the type system. Part 4 looked at the ecosystem. Part 5 looked at the seam between backend and frontend. Part 6 looks at concurrency: PHP can do it, but the rest of the world it lives in cannot.

8.1
The PHP release that landed fibers, giving the language a real primitive to build cooperative concurrency on.
~3
Roughly the number of production-credible async runtimes for PHP: Swoole, ReactPHP, AmPHP. Each is its own ecosystem.
~10 yrs
That Node, Python, Go, and Rust library authors have been writing with async in mind. Most PHP authors still are not.

PHP is async-capable

The language has the pieces. Fibers landed in 8.1 and gave the runtime a primitive that any cooperative scheduler can suspend and resume. Generators have been around since 5.5 and cover a lot of streaming patterns without needing fibers at all. Swoole ships a coroutine runtime that competes on raw throughput with Node and Go for I/O-bound workloads. ReactPHP and AmPHP offer event loops, async clients, and promise-style APIs that mirror the Node design closely.

The benchmarks are real. A Swoole HTTP server on commodity hardware will out-throughput a Node service on the same code shape, sometimes by a wide margin. The async PHP runtimes are not a toy. The problem is not capability; the problem is what happens when you try to do anything with that capability beyond the runtime itself.

The ecosystem is not

Your dependencies are mostly synchronous. The big database libraries assume blocking PDO. The HTTP clients assume blocking file_get_contents or blocking curl. The cache libraries, the queue clients, the storage SDKs, the metrics exporters: nearly all assume a single request, a single thread of control, and a return value the caller waits for. Picking one async runtime forces you to find an async-aware version of each of those, or to wrap the sync version in a thread or process so it does not block the event loop.

Your framework is synchronous too. Laravel and Symfony are designed around the request-response cycle. The lifecycle of a request is one input, one output, one terminating handler. There is async support being added at the edges (queued jobs, long-running tasks, Octane workers), but the core abstractions assume that the controller method returns, and returns once. Building a long-lived connection-oriented application on top of that means working against the framework, not with it.

Your team's mental model is synchronous as well. Most PHP documentation, most Stack Overflow answers, most tutorials, and most prior codebases assume the request-response shape. An engineer who has spent ten years writing PHP has spent ten years not having to think about cancellation, backpressure, structured concurrency, or what happens when two suspended fibers want the same database connection. Acquiring that mental model is real work, and the payoff inside a PHP team is unclear when the rest of the system still cannot use it.

Streaming is the same story, harder

Server-sent events, WebSockets, long-lived gRPC streams, chunked HTTP, and the whole class of applications that hold a connection open and push updates as they arrive: every one of these has the same shape, and PHP's standard SAPI model makes every one of these awkward. The CLI worker pattern (long-running script, talks to a queue, processes one job at a time) is the workaround, and it is fine for many cases, but it is not streaming. Real streaming wants an event loop, a connection table, and a way to interleave work across all of them. Which, again, exists: Swoole's WebSocket\Server, ReactPHP/Socket, the amphp/websocket stack. And again: the libraries you would normally reach for inside the handler still assume blocking I/O.

Compare to Node, where an HTTP request body is a stream by default, every database driver returns a promise, and every cache library has a non-blocking client out of the box. Or Go, where a goroutine is the language's primary unit of concurrency and every reasonable library expects to be called from one. Or Python, where async is messy and bifurcated but mainstream enough that any half-serious library ships an async-aware path next to the sync one. PHP's situation is different in kind: the runtimes are excellent, the ecosystem is shaped wrong.

A scorecard for concurrency

=
Runtime primitives are present
Fibers, generators, Swoole coroutines, ReactPHP, AmPHP. The language can support cooperative concurrency.
Most libraries are synchronous
PDO, curl wrappers, cache clients, SDKs. Wrapping each one in an executor is per-library work.
Frameworks assume request-response
Long-lived connections and streaming endpoints work against the grain of the framework lifecycle.
Documentation and team experience are sync-first
Every internal hire has to relearn the parts of the language a Node hire already knows.
Three competing runtimes, no winner
Swoole, ReactPHP, AmPHP. Each one wants its own ecosystem of clients. Picking one forecloses the others.

Async PHP is real, and it is good, and it is on a side path. The mainstream path is still synchronous, and the gap between the two is wide enough that most teams pick one and stop. The teams that pick async pay the per-library wrapping tax forever; the teams that stay sync ship the request-response shape and accept the ceiling that comes with it.

Up next in the series

The talent argument was about who. The runtime argument was about what it runs on. The type argument was about the artifact. The ecosystem argument was about what the artifact can reach. The type-sharing argument was about how the artifact talks to its other half. The concurrency argument is about what shapes the artifact cannot easily take. Part 7 looks at deployment: containers, serverless, edge runtimes, and the shared-hosting mental model the language still carries.

If you want to start the migration before Part 10 lands, book a demo and we will walk you through what Pext does to your codebase.