[PHP-DEV] PHP True Async RFC - Stage 2

Hello everyone,
I’d like to ask for your help regarding the syntax.

Goal: I want to get rid of the BoundedScope object while still providing a convenient built-in tool for controlling wait time.

To achieve this, we could extend the await expression so that it allows explicitly specifying a limit.
For example:

await $some with 5s;

Or a more general approach:

[<resultExp> = ] await <AwaitExp> [bounded <CancellationExp>];

I’m concerned that no other programming language has a similar construct.

On the other hand, if Cancellation is defined in a different way (not through syntax), it requires a separate function. For example:

await all([$task1, $task2], $cancellation);

This approach is actually used in all other languages.

My question is: Should cancellation have its own syntax, or should we follow the conventional approach?

await $some with 5s;

Maybe

await $some limit 5s;

···

Iliya Miroslavov Iliev
i.miroslavov@gmail.com

await $some limit 5s;

Yes, limit is also a good keyword.

And some else examples with “until”:

CancellationExp:

  • A variable of the Awaitable interface
$cancellation = new Future();
$result = await $coroutine until $cancellation;
  • A function that returns an Awaitable object
function getCancellation(): \Async\Awaitable {
return new Future();
}

$result = await $coroutine until getCancellation();
  • A new coroutine
$result = await $coroutine until spawn sleep(5);

On 20 March 2025 07:06:16 GMT, Edmond Dantes <edmond.ht@gmail.com> wrote:

I forgot to include some relevant text to quote, but I absolutely agree that syntax #2 should be our default.

I think the only thing I'm still unsure of is whether we need anything else on top of it, and if so what.

4: keyword inline_function

This option can be considered a special case of option #2. And that’s
exactly the case.

In terms of the grammar, it is a special case of #1, because the inline_function is evaluated in full as an expression, and then we *fabricate* a zero-argument function call to the resulting value.

Or to use your breakdown, #2 includes both elements we need: the callable, and the parameter list; #1, #3 and #4 all include just one element, the callable, and make the assumption that the argument list is empty.

This is less confusing, but has one surprising effect: if you refactor
the inline function to be a variable, you have to replace it with "$foo()"
not just "$foo", so that you hit rule #2

A completely logical transformation that does not contradict anything.

This example maybe helps explain why this might be surprising:

function foo() {
    yield  function() { whatever(); };
    spawn  function() { whatever(); };
    return function() { whatever(); };
}

Three identical values, so let's replace with a shared variable:

function foo() {
    $action = function() { whatever(); }
    yield $action;
    spawn $action;
    return $action; 
}

Looks right, but isn't - we needed to write "spawn $action()". Not a huge rule to learn, but a fairly arbitrary one from the point of view of the user.

The meaning of option #4 is different:

  1. I want to define a closure at point A.
  2. I want to use it at point A.
  3. Point A knows what the closure looks like, so there is no need to
  define arguments — it's the same place in the code.

This feels like a stretch to me: it's not that anything knows what arguments to pass, it's just that the syntax is restricted to passing no arguments. (You could presumably define a closure that *expects* arguments, but couldn't actually pass any, within the shorthand syntax.)

Therefore, the `keyword closure` does not break the clarity of the
description, whereas the `keyword $something` does.

From the user's point of view, it might be just as obvious that the closure put into a variable two lines above can also be called with zero arguments. It's only as unclear as any other code involving a variable - if it's badly named and defined 100 lines away, you'll have a problem, but no syntax can solve that.

6: keyword_bar function_call

This contains even more characters than the original and yet adds nothing
useful.

I tried to make clear that the keywords could stand in for anything. There's no reason that "two different keywords" has to mean "more characters". It could be "go foo();" and "run $closure;", and the point I was trying to illustrate would still hold.

But I do want to come back to the question I asked in my last e-mail:

what is the use case we're trying to cater for?

Goal #1: Improve code readability. Make it easier to understand.
Goal #2: Reduce the number of characters.

That's answering a different question, I want to know *what code* we are optimizing the readability of. What is the user trying to do, which we want to make readable and shorter?

Specifically, what is the use case where syntax #2, "spawn function_call" is not good enough, leading us to add a special case into the grammar.

The advantage of option #4 is not just that it removes parentheses, but
also that it keeps the code readable.

One is a consequence of the other. I don't disagree, but I personally find that introducing a temporary variable has much the same effect, without any special case grammar.

The second reason why option #4 makes sense: it will be used frequently.

Will it? By who, when? Honest question, and comes back to my point about identifying the use case.

For example, `spawn fn() => file_get_content()` won’t be, because it
doesn’t make sense.

If return values end up somewhere, I don't think it would be hard to come up with examples that were slightly more than one function call, but still fit in a single-expression closure.

Rowan Tommins
[IMSoP]

In terms of the grammar, it is a special case of #1
yes.

This example maybe helps explain why this might be surprising:
spawn $action;

Aha, so if I can write spawn closure, why can’t I do the same with a variable?
Yes, this creates an inconsistency.
that’s to be expected since the parentheses were removed.

This feels like a stretch to me

From the perspective of how responsibility is distributed in the code, this would be correct.
But it has nothing to do with syntax consistency.

From the user’s point of view, it might be just as obvious that the closure put into a variable two lines above can also be called with zero arguments.
It’s only as unclear as any other code involving a variable - if it’s badly named and defined 100 lines away,
you’ll have a problem, but no syntax can solve that.

That’s correct. But I’m not sure it’s that destructive in practice.

I tried to make clear that the keywords could stand in for anything

Yes, you want to keep the concise syntax while eliminating ambiguity with the variable.

Specifically, what is the use case where syntax #2, “spawn function_call” is not good enough, leading us to add a special case into the grammar.

Additional parentheses around + parentheses after. That is, (closure)(). The goal is to get rid of this construct.

Will it? By who, when? Honest question, and comes back to my point about identifying the use case.

Honest answer: I can’t say for sure.

I can assume that closures help define a small sub-area within a piece of code that performs a specific task. How common is this situation in the context of coroutines? Maybe not that much.

A safer approach would be to implement only syntax 2 and consider the alternative option only if user feedback suggests it’s needed. Sounds like a solution without drawbacks…

If return values end up somewhere, I don’t think it would be hard to come up with examples that were slightly more than one function call, but still fit in a single-expression closure.

like:

spawn fn() => [file_get_content(), file_get_content(), file_get_content()]

At this point, I haven’t been able to come up with examples where such a closure would actually be convenient.
Maybe this use case will emerge later.

On Thu, Mar 20, 2025, at 12:01, Edmond Dantes wrote:


spawn fn() => [file_get_content(), file_get_content(), file_get_content()]

This example highlights one of the concerns I have with fibers and this approach in general. That example will still execute synchronously, taking file_get_contents() * 3, even though it is in a coroutine function.

If you wanted to make it asynchronous, you’d have to do something like so:

$x = [spawn fn() => file_get_contents($a), spawn fn() => file_get_contents($b), spawn fn() => file_get_contents($c)];

foreach($x as $i => $spawn) $x[$i] = await $spawn;

That is quite a bit more work than I’d like just to get async file reading done.

— Rob

This example highlights one of the concerns I have with fibers and this approach in general. That example will still execute synchronously, taking file_get_contents() * 3, even though it is in a coroutine function.

Is that really a problem? If a programmer wrote the code $x = 1 / 0, then the issue is definitely not with the division operation.

If you wanted to make it asynchronous, you’d have to do something like so:

Because this is more of an anti-example :slight_smile: You shouldn’t write code like this. But if you really want to, at least do it like this:

$x = await spawn fn => [spawn file_get_contents($a), spawn file_get_contents($b), spawn file_get_contents($c)];

But this is also an anti-example because what’s the point of writing the same code three times when you can use a concurrent iterator?

$x = await Async\map([$a, $b, $c], "file_get_contents");

(The functions will be included in another RFC)

On Thu, Mar 20, 2025, at 2:06 AM, Edmond Dantes wrote:

This is simply a wonderful explanation. I will be able to go through each point.

But before that, let's recall what spawn essentially is.
Spawn is an operation that creates a separate execution context and
then calls a function within it.
To perform this, spawn requires two things:
1. **callable** – something that can be called; this is an expression
or the result of an expression.
2. **argument list** – a list of arguments.

Nitpick to make sure we're talking about the same thing: What does "Separate execution context" mean here? Because a keyword whose description includes "and" is always a yellow flag at least. (See also: readonly.) One thing should not do two things. Unless what you mean here is it creates a logical coroutine, within the current async scope.

(I suspect this level of nitpickiness is where the confusion between us lies.)

--Larry Garfield

This example highlights one of the concerns I have with fibers and this approach in general. That example will still execute synchronously, taking file_get_contents() * 3, even though it is in a coroutine function.

Is that really a problem? If a programmer wrote the code $x = 1 / 0, then the issue is definitely not with the division operation.

It is a problem. IO file operations are async on linux. You have to manually type the sync command to be sure if you copy something to another drive. So having a file_get_contents x3 will surely be executed but file_put_contents will delay

···

Iliya Miroslavov Iliev
i.miroslavov@gmail.com

Nitpick to make sure we’re talking about the same thing: What does “Separate execution context” mean here? Because a keyword whose description includes “and” is always a yellow flag at least.

At the language abstraction level, we can say that spawn performs a single operation: it creates an execution context. In this case, the execution context is a low-level term that refers to the combination of processor register states and the call stack (as well as the state of the Zend engine).

At the language abstraction level, we can say that spawn performs a single operation: it creates an execution context. In this case, the execution context is a low-level term that refers to the combination of processor register states and the call stack (as well as the state of the Zend engine).

Correct.

···

Iliya Miroslavov Iliev
i.miroslavov@gmail.com

On 20/03/2025 11:01, Edmond Dantes wrote:
> >
> > Specifically, what is the use case where syntax #2, "spawn function_call" is not good enough, leading us to add a special case into the grammar.
> >
> Additional parentheses around + parentheses after. That is, (closure)(). The goal is to get rid of this construct.

Again, that's the *how*, not the *why*. We only need to "get rid of" the parentheses if there's some reason to type them in the first place.

> A safer approach would be to implement only syntax 2 and consider the alternative option only if user feedback suggests it's needed. Sounds like a solution without drawbacks...

This is pretty much where my mind is going - if we can't articulate an actual reason why "define an inline closure and pass it to spawn" is so common it requires special implementation, then let's keep it simple.

> ```php
> spawn fn() => [file_get_content(), file_get_content(), file_get_content()]
> ```
>
> At this point, I haven't been able to come up with examples where such a closure would actually be convenient.
> Maybe this use case will emerge later.

I was thinking of something like this maybe:

$contentFuture = spawn ( fn() => file_exists($filename) ? file_get_contents($filename) : '!! no such file !!' )();

Or even:

spawn ( fn() => do_something( fetch_something($input) ) )();

Which looks like it would be equivalent to this, but isn't:

spawn do_something( fetch_something($input) );

(It calls fetch_something() inside the coroutine, rather than outside it.)

If anything, this seems like a better candidate for a shorthand than the full closure syntax, because we already have the rules for automatic capture and automatic return available to reuse.

Maybe it could be as short as this:

spawn => do_something( fetch_something($input) ) );

From longest to shortest:

spawn ( function() use($input) { do_something( fetch_something($input) ); } )();
spawn function use($input) { do_something( fetch_something($input) ); };
spawn ( fn() => do_something( fetch_something($input) ) )();
spawn => do_something( fetch_something($input) ) );

Or, if we get Pipe syntax, it could end up like this:

spawn => $input |> fetch_something(...) |> do_something(...);

Again, though, this could easily be added later when a need becomes visible, as long as we don't do something weird now that closes the door on it.

I suggest we leave this sub-thread here; there's plenty of other things to discuss. :slight_smile:

--
Rowan Tommins
[IMSoP]

Good day, everyone.

As I write more code examples, I’m starting to get annoyed by the verbosity of the spawn in $scope construct—especially in situations where all spawns need to happen within the same context.

At the same time, in 80% of cases, it turns out that explicitly defining $scope is the only correct approach to avoid shooting yourself in the foot.
So, it turns out that the spawn in $scope construct is used far more frequently than a plain spawn.

I remembered an example that Larry came up with and decided to use it as syntactic sugar.

There’s some doubt because the actual gain in characters is minimal. This block doesn’t change the logic in any way.
The convenience lies in the fact that within the block, it’s clear which $scope is currently active.
However, this is more about visual organization than logical structure.

Here’s what I ended up with:

Async blocks

Consider the following code:

function generateReport(): void
{
$scope = Scope::inherit();

try {
[$employees, $salaries, $workHours] = await Async\all([
spawn in $scope fetchEmployees(),
spawn in $scope fetchSalaries(),
spawn in $scope fetchWorkHours()
]);

foreach ($employees as $id => $employee) {
$salary = $salaries[$id] ?? 'N/A';
$hours = $workHours[$id] ?? 'N/A';
echo "{$employee['name']}: salary = $salary, hours = $hours\n";
}

} catch (Exception $e) {
echo "Failed to generate report: ", $e->getMessage(), "\n";
}
}

with async

function generateReport(): void
{
try {

$scope = Scope::inherit();

async $scope {
[$employees, $salaries, $workHours] = await Async\all([
spawn fetchEmployees(),
spawn fetchSalaries(),
spawn fetchWorkHours()
]);

foreach ($employees as $id => $employee) {
$salary = $salaries[$id] ?? 'N/A';
$hours = $workHours[$id] ?? 'N/A';
echo "{$employee['name']}: salary = $salary, hours = $hours\n";
}
}

} catch (Exception $e) {
echo "Failed to generate report: ", $e->getMessage(), "\n";
}
}

async syntax

async <scope> {
<codeBlock>
}

As I write more code examples, I'm starting to get annoyed by the verbosity of the `spawn in $scope` construct—especially in situations where all spawns need to happen within the same context.

At the same time, in 80% of cases, it turns out that explicitly defining `$scope` is the only correct approach to avoid shooting yourself in the foot.
So, it turns out that the `spawn in $scope` construct is used far more frequently than a plain `spawn`.

with async

function generateReport(): void
{
    try {
   
        $scope = Scope::inherit();
       
        async $scope {
            [$employees, $salaries, $workHours] = await Async\all([
                spawn fetchEmployees(),
                spawn fetchSalaries(),
                spawn fetchWorkHours()
            ]);
   
            foreach ($employees as $id => $employee) {
                $salary = $salaries[$id] ?? 'N/A';
                $hours = $workHours[$id] ?? 'N/A';
                echo "{$employee['name']}: salary = $salary, hours = $hours\n";
            }        
        }
       
    } catch (Exception $e) {
        echo "Failed to generate report: ", $e->getMessage(), "\n";
    }
}

#### async syntax

async <scope> {
    <codeBlock>
}

I can see how you think that syntactic sugar is understandably needed for spawn in scope, but again, you’re still writing code that makes no sense: why do you care about fetchEmployees (a possible library function) not spawning any fiber?

You already explicitly await all fibers spawned in the generateReport function, you get all the data you need, any extra spawned fibers should not interest you for the purpose of the logic of generateReport.

This is because again, the main use case listed of making sure all fibers are done after a request is a footgun is a non-business-logic requirement, an exercise in functional purity that also reduces caching and concurrency opportunities, as mentioned before.

A (somewhat bikesheeding, but this has been the vast majority of the posts on this thread anyway) note is that await could also be made to accept an iterable of futures, avoiding the use of Async\all combinators.

Regards,
Daniil Gentili.

Again, that’s the how, not the why. We only need to “get rid of” the parentheses if there’s some reason to type them in the first place.

I’ve already understood that. You mean it’s not the reason, but one of the possible solutions.
But it’s one of the solutions that, from my point of view, causes less surprise.
Because the alternatives like spawn fn and spawn {} seem more radical to me compared to the current design.
In that case, perhaps spawn_fn or spawn closure would be better options.

spawn ( fn() => do_something( fetch_something($input) ) )();

Now this option, I’d say, looks realistic.

spawn => do_something( fetch_something($input) ) );

Looks good. But then, of course, it turns out that spawn plays two roles — including defining a closure in the language. That specific point is what makes me doubt it.
It’s like the language has TWO keywords for creating a closure.

Essentially, no matter how you approach it, some compromise will have to be made.

  • Either accept potential confusion between a variable and a function,
  • Or agree that a closure will have two forms, and everyone will have to learn them.

If we go with the second option, then we can use both forms:

spawn use {};
spawn => code;

Again, though, this could easily be added later when a need becomes
visible, as long as we don’t do something weird now that closes the door
on it.

I tend to agree. If there’s doubt, it’s better to postpone it.

spawn => $input |> fetch_something(…) |> do_something(…);

It can be made even cleaner by adding a parallelism operator.
That way, there’s no need to write spawn at all.

I suggest we leave this sub-thread here; there’s plenty of other things
to discuss. :slight_smile:

Ok!

You already explicitly await all fibers spawned in the generateReport function, you get all the data you need, any extra spawned fibers should not interest you for the purpose of the logic of generateReport.

In this specific code, it only awaits the tasks it has launched itself.
So, if another function mistakenly starts a coroutine in the current Scope, that coroutine will be cancelled when the scope is destroyed.

On the other hand, code that does await $scope assumes that the programmer intends to wait for everything and understands the implications.
This usually means that the functions being called are part of the same module and are designed with this in mind.

As for library functions — library functions MUST understand what they are doing.
If a library function creates resources indiscriminately and doesn’t clean them up, the language cannot be held responsible.
If library functions don’t manage resource ownership properly, the language cannot take responsibility for that either.

This is because again, the main use case listed of making sure all fibers are done after a request is a footgun is a non-business-logic requirement,
an exercise in functional purity that also reduces caching and concurrency opportunities, as mentioned before.

In this RFC, there is no such primary use case.
There is the await $scope construct, but it can no longer be used as the default.
There used to be a await currentScope() construct, which was a footgun — but it will no longer exist.

I also removed globalScope, because in 99% of cases it’s an anti-pattern and can be easily replaced with code that creates a coroutine in a separate Scope.
Through writing examples, this became clear.

A (somewhat bikesheeding, but this has been the vast majority of the posts on this thread anyway) note is that await could also be made to accept an iterable of futures, avoiding the use of Async\all combinators.

I considered this option — it looks nice with an array — but for some reason, it’s not implemented in any language.

And here’s why.

When you allow await to work with an array, it leads to significant complications.
Because once you support arrays, you naturally want to add more variants, like:

  • await first
  • await any
  • await ignore
    and so on.

And with additions like await until or await limit, it becomes a very large and complex statement.

After exploring different options, I also came to the conclusion that using a function which returns a Future from parameters is more flexible and better than having a multitude of options.

The only unresolved question is until, because it could be convenient.
But it doesn’t exist in any other language.

Hello.

So as an outline, I would recommend:

Yes, your suggestion is interesting. I’ll think about that section next week.
However, I can say that I’ve already dropped the “philosophy” section. I decided to move it into a separate article that will be available as an artifact.
Such articles can be useful, but they overload the RFC. And the RFC is already large and complex, even considering that many things have been cut from it.

The vast majority of the examples are “print Hello World”

I’ll add more realistic examples. But I won’t completely drop the echo examples where they’re the most useful.

It’s all a series
of functions that call each other to end up on a DB factory that uses a static variable,
so nothing there is injectable or testable.

Do you mean this code?

function getGlobalConnectionPool(): ConnectionPool
{
static $pool = null;
if ($pool === null) {
$pool = new ConnectionPool();
}
return $pool;
}

so nothing there is injectable or testable.

  1. Why must the code use DI? The use of a tool should be driven by the requirements of the task. Where do you see such requirements in the example code? There are no rules that dictate always using one pattern or another. Such rules are an anti-pattern themselves.
  2. PHP has allowed testing code with factory functions using static variables for many years.

It has the same “should be avoided in real-world development” problem, which means it doesn’t tell me anything useful

If you remove the getGlobalConnectionPool function from the example, the meaning won’t change by even one percent. If a developer reading this example doesn’t understand that ConnectionPool is a dependency and how the example works in real life, then that’s clearly not a problem with the example.

Consider: Suppose this RFC passed, but the follow-up to add a Scheduler did not pass, for
whatever reason. What does that leave us with? I think it means we have an approved RFC
that cannot be implemented. That’s not-good.

Scheduler and Reactor are the implementation of this RFC. I don’t know whether they will be discussed in a separate RFC — perhaps a PR will be enough, but…

To avoid ambiguity in the wording, I can mention that such components exist, without stating that they will be accepted separately. As I understand it, this resolves all concerns.

That was not at all evident to me from reading it.

I’ll give this extra attention.

Let me ask this: With the spawn/start/whatever keyword, what is the expected return value?
Does it block until that’s done? Do I get back a future?

The spawn expression (I think this keyword will remain) returns a coroutine object.
This object implements the Awaitable interface. Awaitable is not a Future, but it’s a base interface for objects that can be awaited.

Since spawn returns a value that can be awaited, it can be used with await.
await spawn means: wait until that coroutine finishes.

await also returns a value — the result of the coroutine’s execution.

If the mental model is "take a function call like you already had

The mental model that has settled in my mind over the past 3–4 weeks looks like this:

  1. Create a new execution context

  2. Take this callable and its parameters and run them in the new context

  3. Do it when possible (though I don’t know exactly when), but not right now

As far as I understand the meaning of the word spawn, it should match this model.

Hello everyone,

It’s a nice Sunday evening, and I’d like to share some updates and thoughts from this week — kind of like a digest :slight_smile:

  1. Big thanks to Rowan Tommins for the syntax suggestions, ideas, and feedback. I decided to try using the spawn block syntax, and in practice, it turned out to be quite convenient. So I’ll include it in the next phase. You can check out the details via the link: https://github.com/EdmondDantes/php-true-async-rfc/blob/main/basic.md#spawn-closure-syntax
function startServer(): void
{
async $serverSupervisor {

// Secondary coroutine that listens for a shutdown signal
spawn use($serverSupervisor) {
await Async\signal(SIGINT);
$serverSupervisor->cancel(new CancellationException("Server shutdown"));
}

// Main coroutine that listens for incoming connections
await spawn {
while ($socket = stream_socket_accept($serverSocket, 0)) {
connectionHandler($socket);
}
};
}
}
  1. suspend has become a statement.

  2. The scope retrieval functions like currentScope, globalScope, and rootScope have been removed. This has consequences. One of them: it’s no longer possible to create a “detached” coroutine. But that’s good news.

  3. I decided to borrow Larry’s example and create a special code block "async block" that interacts with coroutine Scope in a special way.

function startServer(): void
{
async $serverSupervisor {

// Secondary coroutine that listens for a shutdown signal
spawn use($serverSupervisor) {
await Async\signal(SIGINT);
$serverSupervisor->cancel(new CancellationException("Server shutdown"));
}

// Main coroutine that listens for incoming connections
await spawn {
while ($socket = stream_socket_accept($serverSocket, 0)) {
connectionHandler($socket);
}
};
}
}

It looks nice, even though it’s syntactic sugar for:

$scope = new Scope();

try {
await spawn in $scope {
echo "Task 1\n";
};
} finally {
$scope->dispose();
}

This syntax creates a code block that limits the lifetime of coroutines until the block is exited. It doesn’t wait, it limits. Besides the fact that the block looks compact, it can be checked by static analysis for the presence of await, verify what exactly is being awaited, and report potential errors. In other words, such code is much easier to analyze and to establish relationships between groups of coroutines.

The downside is that it’s not suitable for classes with destructors. But that’s not really a drawback, since there’s a different approach for handling classes.

  1. I decided to abandon await all + scope.

Reason: it’s too tempting to shoot yourself in the foot.

Instead of await $scope, I want the programmer to explicitly choose what exactly they intend to wait for: only direct children or all others. If you’re going to shoot yourself in the foot — do it with full awareness :slight_smile:

Drawback: it complicates the logic. But on the other hand, this approach makes the code better.

The code only awaits the coroutines that were created inside the foreach:

function processAllUsers(string ...$users): array
{
$scope = new Scope();

foreach ($users as $user) {
spawn in $scope processUser($user);
}

return await $scope->tasks();
}

Code that waits until all child coroutines — at any depth — of the launched background tasks have completed.

function processBackgroundJobs(string ...$jobs): array
{
$scope = new Scope();

foreach ($jobs as $job) {
spawn with $scope processJob($users);
}

await $scope->all();
}

It doesn’t look terrible, but I’m concerned that this kind of functionality might feel “complex” from a learning curve perspective. On the other hand, Python’s approach to similar things is even more complex, largely because the language added async features in several stages.

My main doubts revolve around the fact that Scope is passed implicitly between function calls. This creates multiple usage scenarios — i.e., a kind of flexibility that no other language really has. And as we know, flexibility has a dark side: it opens up ways to break everything.

On one hand, this RFC effectively allows writing in the style of Go, Kotlin, C#, or even some other paradigms. On the other hand, identifying the “dark side of the force” becomes harder.

If you don’t use Scope — you’re writing Go-style code.
If you use Scope + all + async — it’s Kotlin.
If you use Scope + tasks() — it’s more like Elixir.

And you can also just pass $scope explicitly from function to function — then you get super-explicit structured concurrency.

So I keep asking myself: wouldn’t it have been simpler to just implement the Go model? :slight_smile:

How can you know in advance that the chosen solution won’t lead to twisted practices or eventually result in anti-patterns?
How can you tell if the chosen toolkit is strict enough — and not too flexible?

These questions are the main reason why the next revision won’t be released very soon. More code and examples are needed to understand how reliable this really is.

But in the meantime, you can keep an eye on this:

https://github.com/EdmondDantes/php-true-async-rfc/blob/main/basic.md

Good day, everyone.

Just a ping email — I haven’t disappeared, development is ongoing.

Since the task has a high level of interdependency, I have to cautiously try different combinations. The second-to-last version, in trying to satisfy all requirements, turned out too complex to be taken seriously.

However, the previous version was brought to a logical conclusion, which gave a clearer overall picture. Mind maps and feature-to-requirement diagrams were very helpful, as they allowed me to view the RFC as a balance between RFC complexity and code complexity.

I’ve finally managed to find a good balance, though this RFC still surpasses all existing models in terms of tooling. I know minimalists won’t like it, but this solution definitely deserves consideration.

Have a good day.