Skip to main content

PHP Structured Concurrency and Beyond (1): history and vision

Merlin Rutz

Every step out of the stone age of programming made the same trade. Give up a freedom, get back a guarantee the machine can enforce.

goto went first. Then manual free(). Concurrency in PHP has not made that trade yet. We have fibers and an event loop, and nothing that owns them.

The pattern

Each trade buys local reasoning. You can tell what a piece of code does by reading it, without knowing what the rest of the program does. Every part of this series measures a design against that sentence.

In 1968 Dijkstra argued that goto was harmful, because you cannot read one block of a program that can jump into it from anywhere. The reply was that this took away a legitimate tool, and the argument ran for years. A 1987 paper is titled "'GOTO Considered Harmful' Considered Harmful", and it was not a joke. Today nobody experiences if, while and function calls as a restriction. A block has one entry and one exit, and control comes back where it left. Exceptions, stack traces, profilers and finally all rest on that property.

Memory went the same way. Manual allocation gives you a pointer and a promise to clean up. Ownership models replaced the promise with a reference count, a garbage collector, or a compile-time lifetime as in Rust. You lost the freedom to keep a pointer to whatever you like for as long as you like. You got back something PHP developers never think about. Objects go away by themselves.

Both times the same shape:

  • a hierarchy of ownership, such as a block, a scope or a stack
  • less freedom, on purpose
  • cleanup at the boundary, enforced rather than promised

A goto can arrive from anywhere, so no block is safe to read alone. A dangling pointer is written here and crashes there. Take the freedom away and the distance collapses.

Concurrency got there in 2016

Martin Sustrik and then Nathaniel J. Smith noticed that go, spawn, Thread::start and Amp\async() are goto in new clothes. You start something and control does not come back. The started thing outlives the function that started it, its errors surface somewhere else, and nothing connects it to its creator.

Structured concurrency applies the same fix. A task starts only inside a scope. The scope does not exit until every child has finished. If a child fails, the scope cancels its siblings and the error surfaces where the scope was opened. Concurrency gets one entry and one exit, like a block.

Structured concurrency does to coroutine leaks what managed memory did to memory leaks. It moves the job of preventing them from the programmer to the runtime. Part 2 says how far that goes in userland, and where it stops.

The idea landed. Python has Trio and asyncio.TaskGroup, Kotlin has coroutine scopes, Swift has task groups, Java has structured concurrency as a JEP, C++ has senders and receivers.

PHP has fibers, and a withdrawn RFC.

Where PHP stands

The True Async RFC went to a vote in November 2025, and the list asked for the vote to be cancelled the next day. A second vote, due to close in February 2026, was cancelled with no votes cast. Its author withdrew the RFC in July and has since proposed a much smaller scheduler ABI for the engine. Its companion, True Async Scope, proposed the model above, with Scope::spawn(), hierarchical inheritance and cancellation down the tree. It never reached a vote.

Two objections did the damage in the discussion. One was process. One author, hundreds of decisions, a rushed vote. The other was state. Nobody knows what async does to the global and static state the whole ecosystem is built on. That objection is correct. It is also the problem this series sets out to solve, which is awkward, because it sank the RFC that would have helped.

Two disagreements of my own.

The first is with the premise that the engine has to do this. Fibers landed in 8.1 and a scheduler exists in userland. What is missing above them is a library, not a keyword. This series is the argument, and the prototype is the evidence.

The second is with how the Scope RFC answered the community. The discussion asked for structured concurrency, and the RFC delivered it as an option. By default every coroutine lands in a global scope, which the RFC describes as behaviour "similar to coroutines in Go". Scope does not implement Awaitable, so nothing waits for children unless you ask. That is goto with a while loop available next to it. Ask what a function does, and the answer must not depend on whether its author reached for the good tool. Optional structured concurrency pays the price and forgoes the gain. The price is the scope you open and the freedom you lose inside it. The gain is local reasoning, and a reader gets none of it from a rule the code next door may have skipped. Structured control flow won because the compiler refused the goto, not because it offered the while.

Meanwhile amphp and Revolt are maintained and stop one layer below ownership by design. Amp\async() returns a Future, and a Future is a handle, not ownership. Drop it and the coroutine keeps running. Cancellation is a token you create, pass down every layer, and hope each layer checks. Part 2 has the details. amphp is the right base. The missing layer sits above it.

And the state problem is worse

PHP frameworks are full of services holding global state, such as the current user, the negotiated language and the request stack. Under one request per process that is fine. Under fibers it is a bug, and under a persistent server the bug outlives the request. Drupal, api-platform and FrankenPHP have each filed it. Every answer so far either switches concurrency off or asks every service in the ecosystem to reset itself. Part 3 has the issue numbers and why neither holds.

What comes after

The move does not stop at structured concurrency. What follows is vision, not roadmap.

Structured state. Stateful services stop being global. The container resolves a service tagged stateful from a task-local store that inherits along the task tree and isolates between siblings. Switch the current user inside a fiber and the fiber next to it never sees it. No fiber trap, and no rewrite of every service to be stateless. That is part 3.

Structured concurrency in the frameworks. The layer is useless if it only runs in a test harness. I will do it in Drupal. Tag the stateful services, drive the kernel from Revolt, give the worker loop a clean state, and retire the fiber trap against the bugs that caused it. Symfony worker mode is the same shape with different names, and I would rather somebody who lives there wrote the adapter for it.

Structured channels. A channel is a dependency. The consumer needs what the producer makes. Today that edge carries data one way and nothing back. When a consumer stops reading, because it has enough results, it failed, or its scope is going down, the producer keeps computing for a reader who will never read. A structured channel carries the edge both ways. Abandon the consuming end and the channel cancels the production behind it, through however many stages the pipeline has. The idea comes from flow-based programming, where the graph is the program and a graph knows who depends on whom.

Structured parallelism. The same rules without shared memory. A scope owns worker processes instead of fibers, so it does not exit before its workers are done, and cancelling it has to reach across a process boundary instead of resuming a fiber. The task-local store is where the difference bites. A fiber inherits state by copying an object. A worker cannot receive one, so a stateful service either serialises into the worker or the worker builds it clean on its side. That is the tag part 3 puts on a stateful service, read in a place it was not written for. amphp/parallel and ext-parallel are the two bases to try.

Structured caching belongs next to this list rather than in it. Restrict what a cache entry is allowed to be and cross-node coordination stops being necessary. Different problem, same trade, and I will write about it separately.

Where this is going

Parts 2 and 3 are the concrete ones. Structured concurrency, and why its absence breaks persistent servers. Structured state, and why its absence breaks concurrency. Both rest on a prototype on unpatched PHP 8.4 with stock Revolt and amphp, no extension. Two fibers, one stateful service, different values in each, no bleed. A scope that exits while children run, and no leaked fibers afterwards.

The code is not public yet. Part 4 says why, how to get in, and what it takes to finish a PHP where starting a task is as safe as calling a function.

Talk to me

I am at DrupalCon Rotterdam, 28 September to 1 October, and at the International PHP Conference in Munich on 27 and 28 October. Otherwise, write to merlin [at] hook-dev-alter.com (merlin[at]hook-dev-alter[dot]com). Tell me which part of this history I got wrong, and which structured-whatever I missed.

Errata

  • 2026-09-25: Corrected the True Async vote history. The RFC was not voted down; it was withdrawn before any vote counted.

Disclaimer: my friend the babble-machine helped a lot with research, structure and wording. All errors are mine.

merlin portrait

Merlin Rutz

Based on 20 years in-depth drupal coding and a long-lasting contribution history Merlin helps teams to fix problems and introduce technical innovations. Founder and technical lead at HOOK_DEV_ALTER().

Alongside technical work, he brings experience with collaborative organizational models such as sociocracy, holacracy, and agile practices - helping teams improve how they share knowledge and make technical decisions.

merlinathook-dev-alter.com

Get notified about new articles.