php-fibers-the-missing-piece-for-elegant-multitasking
Guides & Tutorials

PHP Fibers: The Missing Piece for Elegant Cooperative Multitasking

Last Updated on November 24, 2025 β€’ 4 min read

If you follow me on Twitter, you might remember a post where I mentioned solving a particularly gnarly problem while working on the official PHP Model Context Protocol (MCP) SDK. I promised a deep dive into how we solved it, and this is it. This is the story of how PHP Fibers saved me from architectural disaster while building the client communication feature for the PHP MCP SDK.

The feature was introduced in PR #109, and the implementation showcases one of the most elegant uses of PHP Fibers I've encountered. But this isn't just a story about one problem. It's about a powerful PHP feature that's been hiding in plain sight since PHP 8.1, largely misunderstood and underutilized. When I finally understood what Fibers could do, it felt like discovering a secret passage in a room I'd been stuck in for hours.

Here's what you'll learn in this article:

  • What PHP Fibers really are (and what they're not)
  • When and why you should use them
  • How to think about cooperative multitasking
  • A Real-world implementation using Fibers
  • Patterns you can use in your own code

This is going to be a long one. Grab a drink, settle in, and let's talk PHP Fibers.

The Fiber Misconception

Let's start by addressing the elephant in the room: PHP Fibers are NOT async PHP. They're not parallelism. They're not threads. They're not about making PHP run multiple things at once.

When PHP 8.1 dropped with Fiber support in November 2021, many developers (myself included) looked at it with confusion. "Great, another async thing?" we thought. The confusion was understandable because the most visible use of Fibers has been in asynchronous libraries like ReactPHP and AmPHP.

ReactPHP even has a package called async that uses Fibers to make asynchronous code look synchronous:

// Before Fibers: Callback hell
$promise->then(function($result) {
return anotherAsyncCall($result);
})->then(function($finalResult) {
echo $finalResult;
});
 
// With Fibers: Looks synchronous!
$result = await($promise);
$finalResult = await(anotherAsyncCall($result));
echo $finalResult;

Seeing this, it's easy to think "Fibers = async magic." But that's missing the bigger picture.

Fibers are about cooperative multitasking. They're about giving your code the ability to pause execution, do something else, and then come back exactly where you left off, with all your variables, your call stack and your execution context perfectly preserved.

Yes, this is incredibly useful for async libraries. But it's just as useful in purely synchronous code when you need controlled interruption and resumption. And that's where most PHP developers miss the opportunity.

The slow adoption of Fibers isn't because they're not useful. It's because most developers don't know when to use them. And that's exactly what this article is going to fix.

Understanding Fibers: The Fundamentals

Before we get into complex examples, let's build a solid foundation. What exactly is a Fiber, and how does it work?

What is Cooperative Multitasking?

A good analogy to understand Fibers is to think of a standard PHP script as a train on a single track. It goes from Station A to Station B. It typically cannot stop until it reaches B. A Fiber allows the train to stop in the middle of the track, let the passengers off (or allow the passengers take some bathroom break), and while at it, even let another train use the track for a bit, and then resume exactly where it left off with all its luggage (variables and memory state) intact.

Another analogy is to imagine you're reading a book while cooking dinner. You read a few pages, then when the timer beeps, you bookmark your page, stir the pot, and go back to reading exactly where you left off. That's cooperative multitasking.

The key word is cooperative. You (the reader/cook) decide when to switch tasks. Nobody interrupts you forcefully, instead you yield control willingly when it makes sense.

In programming terms:

  • Preemptive multitasking: The operating system interrupts your code forcefully (threads, processes)
  • Cooperative multitasking: Your code decides when to yield control (coroutines, fibers)

Fibers are PHP's implementation of cooperative multitasking. They let you:

  1. Start executing a piece of code
  2. Pause it at any point (suspend)
  3. Do other things
  4. Resume exactly where you left off
  5. Repeat as many times as needed

The Anatomy of a Fiber

Let's look at a simple example:

<?php
 
$fiber = new Fiber(function(): string {
echo "1. Fiber started\n";
 
$value = Fiber::suspend('pause-1');
echo "3. Fiber resumed with: $value\n";
 
$value2 = Fiber::suspend('pause-2');
echo "5. Fiber resumed again with: $value2\n";
 
return 'final-result';
});
 
echo "0. Before starting fiber\n";
 
$suspended1 = $fiber->start();
echo "2. Fiber suspended with: $suspended1\n";
 
$suspended2 = $fiber->resume('data-1');
echo "4. Fiber suspended again with: $suspended2\n";
 
$result = $fiber->resume('data-2');
echo "6. Fiber returned: $result\n";

Output:

0. Before starting fiber
1. Fiber started
2. Fiber suspended with: pause-1
3. Fiber resumed with: data-1
4. Fiber suspended again with: pause-2
5. Fiber resumed again with: data-2
6. Fiber returned: final-result

I included the numbers so you see how execution jumps in and out of the Fiber. Suspend makes it jump out of the Fiber, resume makes it jump back in! For better clarity, let's break down what happened:

  1. Creation: new Fiber(function() {...}) creates a fiber but doesn't execute it yet
  2. Start: $fiber->start() begins execution until the first Fiber::suspend()
  3. Suspend: Fiber::suspend('pause-1') pauses execution and returns control to the caller
  4. Resume: $fiber->resume('data-1') continues execution from where it suspended
  5. Return: When the fiber completes, resume() returns the final value

The magic is in the execution context switching. When the fiber suspends:

  • All local variables are preserved
  • The call stack is saved
  • Execution jumps back to whoever called start() or resume()
  • The value passed to suspend() is returned to the caller

When you resume:

  • Execution jumps back into the fiber
  • The value passed to resume() becomes the return value of suspend()
  • Everything continues as if nothing happened

One crucial insight that makes Fibers powerful: code running inside a fiber doesn't need to know it's in a fiber.

Look at this:

function processData(int $id): string {
$data = fetchData($id); // This might suspend!
$result = transform($data); // This might also suspend!
return $result;
}
 
// Called inside a fiber
$fiber = new Fiber(fn() => processData(42));
$fiber->start();

From processData's perspective, it's just calling functions and returning results. It doesn't know that fetchData() and transform() might be suspending the fiber behind the scenes. The complexity is hidden.

This is what makes Fibers perfect for building clean APIs that hide complex behavior.

Fibers in Asynchronous Libraries

Now that we understand the basics, let's see why some people may associate Fibers with async code. This will also show us a concrete use case before we tackle the main problem.

The Async Problem

Asynchronous programming in PHP traditionally looks like this:

// Using promises (before Fibers)
function fetchUserData(int $userId): PromiseInterface {
return $this->httpClient->getAsync("/users/$userId")
->then(function($response) {
return json_decode($response->getBody());
})
->then(function($userData) use ($userId) {
return $this->cache->setAsync("user:$userId", $userData);
})
->then(function() use ($userId) {
return "User $userId cached";
});
}

This works, but it's hard to read and reason about. Error handling with catch() gets messy. Debugging is painful. And it doesn't "feel" like PHP.

The Fiber Solution

With Fibers, libraries like ReactPHP can offer this:

// Using Fibers (after PHP 8.1)
function fetchUserData(int $userId): string {
$response = await($this->httpClient->getAsync("/users/$userId"));
 
$userData = json_decode($response->getBody());
 
await($this->cache->setAsync("user:$userId", $userData));
 
return "User $userId cached";
}

Much better! But how does await() work? Let me show you a simplified version:

namespace React\Async;
 
function await(PromiseInterface $promise): mixed {
// Suspend the fiber and register promise callbacks
$result = Fiber::suspend([
'type' => 'await',
'promise' => $promise
]);
 
// When resumed, we'll have the result or exception
if ($result instanceof \Throwable) {
throw $result;
}
 
return $result;
}

And if you're feeling fancy, tools like PHPStan let you sprinkle in a little generics magic so await() knows exactly what's coming back from your Promise. Strong static analysis that feels like wizardry. How cool is that?

Here's what happens:

  1. User code calls await($promise) (inside a fiber)
  2. await() calls Fiber::suspend() with the promise
  3. The event loop sees the suspended fiber and promise
  4. The event loop continues processing other things as usual while the fiber is suspended
  5. When the promise resolves, the loop calls $fiber->resume($value)
  6. Execution continues in await(), which returns the value
  7. User code gets the value as if it was synchronous!

The fiber suspends while waiting for the async operation, but the user's code looks completely synchronous.

Taking It Further: Truly Transparent Async

But we can go even further! Libraries like AmPHP take this to the next level by creating fiber-aware wrappers around async operations. Instead of having separate getAsync() and await() calls, you just have methods that look completely synchronous:

// AmPHP approach: No await() needed!
function fetchUserData(int $userId): string {
$response = $this->httpClient->get("/users/$userId"); // Looks sync, is async!
 
$userData = json_decode($response->getBody());
 
$this->cache->set("user:$userId", $userData); // Looks sync, is async!
 
return "User $userId cached";
}

Wait, what? No await() calls? How does this work?

The magic is that get() and set() internally use Fibers. Here's a simplified example:

class HttpClient {
public function get(string $url): Response {
// Create the async operation
$promise = $this->performAsyncRequest('GET', $url);
 
// Suspend the current fiber and pass the promise to the event loop
$response = \Fiber::suspend([
'type' => 'await',
'promise' => $promise
]);
 
if ($response instanceof \Throwable) {
throw $response;
}
 
return $response;
}
}

From the user's perspective, they just called get() and got a response. They have no idea it was async.

This is the epitome of Fibers: making async operations completely transparent. The user writes what looks like blocking, synchronous PHP code. The library handles all the async complexity using Fibers behind the scenes.

Comparing the Approaches

Let's see the evolution:

// 1. Traditional async with promises (no Fibers)
$promise = $this->httpClient->getAsync("/users/$userId")
->then(fn($response) => json_decode($response->getBody()))
->then(fn($userData) => $this->cache->setAsync("user:$userId", $userData))
->then(fn() => "User $userId cached");
 
// 2. Async with await() helper (using Fibers)
$response = await($this->httpClient->getAsync("/users/$userId"));
$userData = json_decode($response->getBody());
await($this->cache->setAsync("user:$userId", $userData));
return "User $userId cached";
 
// 3. Fully transparent async (Fibers hidden in library)
$response = $this->httpClient->get("/users/$userId");
$userData = json_decode($response->getBody());
$this->cache->set("user:$userId", $userData);
return "User $userId cached";

Notice how approach #3 looks exactly like synchronous code? That's the power of Fibers when used correctly. The library developer handles the complexity once. Every user benefits from a clean, synchronous-looking API that's actually asynchronous under the hood.

Why This Led to the Misconception

Because the most visible use of Fibers was making async code look sync, developers assumed Fibers were the async mechanism. But Fibers themselves don't do anything asynchronous. They just provide the suspension/resumption mechanism that makes sync-looking async code possible.

The event loop is still doing the actual async work. Fibers just make the API nicer.

This distinction is crucial: Fibers are a tool for managing execution flow, not for achieving parallelism or asynchrony.

The Real Problem: Client Communication in the MCP SDK

Now let's get to the problem that inspired this article. I was working on the PHP implementation of the Model Context Protocol (MCP), and we hit a design challenge that seemed impossible to solve elegantly.

What is MCP?

The Model Context Protocol is a standard for connecting AI assistants (like Claude) with external tools and data sources.

An MCP server exposes:

  • Tools: Functions the AI can call (e.g., "search database", "send email")
  • Resources: Data the AI can read (e.g., "project files", "API documentation")
  • Prompts: Templates the AI can use

The protocol is bi-directional JSON-RPC over different transports (STDIO, HTTP + SSE, Custom).

The Challenge

The MCP specification includes features for servers to communicate back to clients during request handling:

  1. Logging: Send log messages to the client
  2. Progress: Update the client on long-running operations
  3. Sampling: Ask the client to generate text with its LLM

These aren't just response types. Nope, thing is, they need to happen during the execution of a tool. For example:

Client: "Hey server, run the 'analyze_dataset' tool"
Server: "Starting..." [sends log]
Server: "25% complete" [sends progress]
Server: "50% complete" [sends progress]
Server: "Generating summary, need your LLM" [sends sampling request]
Client: "Here's the generated summary" [responds to sampling]
Server: "Done! Here's the full result" [sends final response]

The server needs to:

  • Send messages mid-execution
  • Wait for responses from the client
  • Continue execution after receiving responses
  • Make all this feel natural to write

The API Requirement

One of our priorities when it comes to the MCP SDK is for it to be extremely easy to use. We wanted developers to write tools like this:

$server->addTool(
function (string $dataset, ClientGateway $client): array {
$client->log(LoggingLevel::Info, "Starting analysis");
 
foreach ($steps as $step) {
$client->progress($progress, 1, $step);
doWork($step);
}
 
$summary = $client->sample("Summarize this data: ...");
 
return ['status' => 'complete', 'summary' => $summary];
},
name: 'analyze_dataset'
);

Look at that code. It's beautiful. It's simple. It looks completely synchronous. There's no callbacks, no promises, no async/await syntax, no yield generators. Just regular PHP.

But under the hood, this needs to:

  • Send JSON-RPC notifications to the client (log, progress)
  • Send JSON-RPC requests and wait for responses (sampling)
  • Work with both with any transport, blocking or not!
  • Work whether you're using vanilla PHP, ReactPHP, Swoole, or RoadRunner

How do you make that work?

Why Traditional Approaches Wouldn't Work

I spent hours considering different solutions:

Option 1: Make Everything Async

// Promise-based approach - nested and messy
$server->addTool(function (string $dataset, $client) {
return $client->logAsync(LoggingLevel::Info, "Starting analysis")
->then(function() use ($client) {
return $client->progressAsync(0.33, 1, "Step 1");
})
->then(function() use ($client) {
return $client->progressAsync(0.66, 1, "Step 2");
})
->then(function() use ($client) {
return $client->sampleAsync("Summarize...");
})
->then(function($summary) {
return ['status' => 'complete', 'summary' => $summary];
});
});

The callback nesting gets unwieldy fast. Even if we used await() helpers to simplify it:

$server->addTool(function (string $dataset, $client) {
await($client->logAsync(LoggingLevel::Info, "Starting"));
await($client->progressAsync(0.33, 1, "Step 1"));
await($client->progressAsync(0.66, 1, "Step 2"));
$summary = await($client->sampleAsync("Summarize..."));
return ['status' => 'complete', 'summary' => $summary];
});

This forces everyone to learn async PHP. It makes async libraries a core dependency and limits the SDK to the async runtime we chose to go with. Unlike PSR-7 for server requests and responses, there's no standard for event loops or async runtime in PHP so that vendor lock in was not an option. Also it's an overkill for simple tools. Rejected.

Option 2: Callbacks

// Callback hell alert!
$server->addTool(function (string $dataset, $client) {
$client->log(..., function() use ($client) {
$client->progress(..., function() use ($client) {
$client->sample(..., function($summary) {
return ['summary' => $summary];
});
});
});
});

Nobody wants this. No further explanation needed. Rejected.

Option 3: State Machines and Serialization

What if we track execution state and re-execute the handler from checkpoints?

// Pseudo-code
if ($state->step === 0) {
$client->log(...);
$state->step = 1;
return $state->serialize();
}
if ($state->step === 1) {
$client->progress(...);
$state->step = 2;
return $state->serialize();
}
// ... and so on

This is insanely complex. Even if we abstract part of it and allow the user write synchronous code, there's a lot of work to do to track the state. How do you serialize closures? How do you restore local variables? How do you handle loops? This would require completely changing how users write tools. Rejected.

Option 4: Generators with yield

$server->addTool(function (string $dataset, $client) {
yield $client->log(...);
yield $client->progress(...);
$summary = yield $client->sample(...);
return ['summary' => $summary];
});

This is closer, but generators are limited. You can't yield from nested function calls easily. The syntax is awkward. And users need to understand generators. Not ideal, but possible.

Option 5: PHP Fibers

What if the suspension/resumption happens invisibly? What if $client->log() looks like a normal method call, but behind the scenes it suspends the fiber, sends the message, and resumes?

// What users write (looks synchronous!)
$server->addTool(function (string $dataset, $client) {
$client->log(...); // Suspends internally
$client->progress(...); // Suspends internally
$summary = $client->sample(...); // Suspends and waits
return ['summary' => $summary];
});

This is it. This is the solution. Users write normal PHP. The SDK handles all the complexity.

The "Aha!" Moment

The moment I realized Fibers were the answer, everything clicked. Here's why they're perfect:

  1. Transparent: User code doesn't need to know about fibers
  2. Flexible: Works with any transport (blocking or non-blocking)
  3. Simple: The API is just regular method calls
  4. Powerful: Full control over execution flow
  5. Universal: Works with sync PHP, async PHP, any runtime

Fibers let us hide the complexity of bidirectional communication behind a clean, synchronous-looking API. The user writes simple functions. The SDK manages the fiber lifecycle. The transports handle the actual I/O.

It's the perfect separation of concerns.

The Thought Process: Why Fibers Work Here

Let me walk you through why Fibers are uniquely suited to this problem.

The Core Challenge

When a tool handler calls $client->log(), we need to:

  1. Pause the handler's execution
  2. Send a JSON-RPC notification to the client (the mechanism depends on the transport)
  3. Resume the handler immediately (logging doesn't wait)

When a tool handler calls $client->sample(), we need to:

  1. Pause the handler's execution
  2. Send a JSON-RPC request to the client (the mechanism depends on the transport)
  3. Wait for the client's response (how we receive the response is also transport-dependent)
  4. Resume the handler with the response

The key insight: we need to leave the handler's execution context, do something else, then return to it at a particular point. And we need to be able to do that as many times as we want. That's exactly what Fibers provide.

The Architecture

The solution has three layers:

  1. ClientGateway (user-facing API)

    • Methods like log(), progress(), sample()
    • Internally calls Fiber::suspend() with message data
    • Returns the response when resumed
  2. Protocol (orchestration layer)

    • Wraps handler execution in a Fiber
    • Detects when the Fiber suspends
    • Extracts the suspended value (notification or request)
    • Hands off the Fiber to the Transport
  3. Transport (I/O layer)

    • Takes ownership of suspended Fibers
    • Sends messages to the client (responses, requests & notifications)
    • Waits for responses (if needed)
    • Resumes Fibers when ready

Each layer has a clear responsibility. The magic is in how they coordinate.

Why It Works with Both Sync and Async

The beauty of this approach is that Fibers are transport-agnostic. The suspension/resumption mechanism is the same whether you're using:

  • Stdio (blocking, single process) - Part of the official SDK
  • HTTP with PHP-FPM (stateless, multi-process) - Part of the official SDK
  • ReactPHP (non-blocking, event-driven) - External example showing async compatibility
  • Swoole (coroutine-based) - Possible with the same architecture

The transport decides when to resume the fiber based on its execution model. The fiber itself doesn't care. It just suspends and waits.

For blocking transports, resumption happens in a loop in the same process. For non-blocking transports, resumption happens via event loop callbacks. For multi-process transports, resumption happens when responses are pulled from shared sessions. The Fiber cares less.

The Implementation: Architecture Overview

Now let's look into the actual implementation. I'll show you some real code from the PHP MCP SDK and explain how everything fits together.

The Three Key Components

The system has three main parts:

User Code (Handler)
↓
ClientGateway (API)
↓
Protocol (Orchestrator)
↓
Transport (I/O)

The Handoff: Protocol to Transport

This is the crucial moment. Here's the simplified flow in the Protocol:

src/Server/Protocol.php

// Protocol::handleRequest()
public function handleRequest(Request $request, SessionInterface $session): void {
$handler = $this->findHandler($request);
 
// Execute handler inside a fiber!
$fiber = new \Fiber(fn() => $handler->handle($request, $session));
 
$result = $fiber->start();
 
if ($fiber->isSuspended()) {
// Fiber yielded something! Extract it.
if ($result['type'] === 'notification') {
$this->sendNotification($result['notification'], $session);
} elseif ($result['type'] === 'request') {
$this->sendRequest($result['request'], $result['timeout'], $session);
}
 
// Give the fiber to the transport
$this->transport->attachFiberToSession($fiber, $session->getId());
} else {
// Fiber completed without suspending
$finalResult = $fiber->getReturn();
$this->sendResponse($finalResult, $session);
}
}

The protocol starts the fiber and checks if it suspended. If it did, the protocol extracts what was suspended (notification or request), queues it for sending, and hands the fiber to the transport. We'll discuss this in detail in a sec. Let's move on for now.

From this point, the transport owns the fiber's lifecycle. Further suspensions and resumptions are handled by the Transport.

Transport Responsibilities

Each transport must:

  1. Accept fibers from the protocol
  2. Send queued messages to clients
  3. Receive client responses
  4. Resume fibers at the appropriate time
  5. Handle fiber termination

Different transports do this differently based on their execution model. Let's look at each one.

The User Experience: What It Looks Like

Before we dive deep into transport implementations, let's see what the end result looks like from a user's perspective. This is important because it shows why the complexity is worth it.

Example 1: Simple Progress Updates

Here's a real example from the MCP SDK docs:

server.php

$server->addTool(
function (string $dataset, ClientGateway $client): array {
$client->log(LoggingLevel::Info, "Running quality checks on dataset: $dataset");
 
$tasks = [
'Validating schema',
'Scanning for anomalies',
'Reviewing statistical summary',
];
 
foreach ($tasks as $index => $task) {
$progress = ($index + 1) / count($tasks);
$client->progress($progress, 1, $task);
 
usleep(140_000); // Simulate work
}
 
$client->log(LoggingLevel::Info, "Dataset $dataset passed automated checks");
 
return [
'dataset' => $dataset,
'status' => 'passed',
'notes' => 'No significant issues detected',
];
},
name: 'run_dataset_quality_checks',
description: 'Perform dataset quality checks with progress updates'
);

Look at this tool code. It's just a regular function. It loops over tasks. It calls $client->progress() like it's a normal method. There's no indication that this is doing complex bidirectional communication.

But here's what's actually happening:

  1. The handler starts (inside a fiber)
  2. $client->log() suspends the fiber
  3. The transport sends the log notification
  4. The fiber resumes
  5. The loop starts
  6. First $client->progress() suspends the fiber
  7. The transport sends the progress notification
  8. The fiber resumes
  9. usleep() runs (still in the fiber)
  10. Second $client->progress() suspends again
  11. ... and so on

Each suspension and resumption is invisible to the user. The code looks and behaves like synchronous PHP. The execution keeps jumping back and forth betweeen the transport's loop (who is now the owner) and the tool's handler,and each time it comes back to the handler, it comes back at the perfect spot and resumes (even within the foreach loop). Define beauty please!!

Example 2: Requesting LLM Sampling

Here's a more complex example that actually waits for a response:

app/Tools/IncidentCordinator.php

class IncidentCoordinator implements ClientAwareInterface {
use ClientAwareTrait; // Provides $this->log and $this->progress
 
#[McpTool('coordinate_incident_response', 'Coordinate incident response')]
public function coordinateIncident(string $incidentTitle): array {
$this->log(LoggingLevel::Warning, "Incident triage started: $incidentTitle");
 
$steps = [
'Collecting telemetry',
'Assessing scope',
'Coordinating responders',
];
 
foreach ($steps as $index => $step) {
$progress = ($index + 1) / count($steps);
$this->progress($progress, 1, $step);
usleep(180_000);
}
 
// Ask the client's LLM to generate a response strategy
$prompt = "Provide a concise response strategy for incident \"$incidentTitle\"
based on: " . implode(', ', $steps);
 
$result = $this->sample($prompt, 350, 90, ['temperature' => 0.5]);
 
$recommendation = $result->content instanceof TextContent
? trim($result->content->text)
: '';
 
$this->log(LoggingLevel::Info, "Incident triage completed");
 
return [
'incident' => $incidentTitle,
'recommended_actions' => $recommendation,
'model' => $result->model,
];
}
}

This is even more magical. The sample() call:

  1. Suspends the fiber with a sampling request
  2. The transport sends the request to the client
  3. The transport waits for the client to respond (how the response comes is up to the transport, and this could take seconds!)
  4. When the response arrives, the transport resumes the fiber with it
  5. $result contains the response, and execution continues

From the method's perspective, it made a synchronous call and got a result. Behind the scenes:

  • The fiber was suspended
  • Control went back to the transport's event loop
  • The transport handled other things (maybe other requests)
  • When the response came in (possibly from another HTTP request/process), the fiber resumed
  • The method continued as if nothing happened

This is cooperative multitasking in action. And the developer writing this tool has no idea it's happening.

The ClientAwareTrait Pattern

Notice the ClientAwareTrait in the example above. This is one of two ways to access the ClientGateway:

Method 1: Type-hint in the handler

#[McpTool('my_tool')]
public function myTool(string $input, ClientGateway $client): string {
$client->log(...);
return $result;
}

The SDK detects the ClientGateway parameter and injects it automatically.

Method 2: Implement ClientAwareInterface

class MyService implements ClientAwareInterface {
use ClientAwareTrait; // Provides setClient() and helper methods
 
#[McpTool('my_tool')]
public function myTool(string $input): string {
$this->log(...); // ClientAwareTrait provides this
$this->progress(...);
$result = $this->sample(...);
return $result;
}
}

The SDK calls setClient() before invoking the handler, and the trait provides convenient methods like log(), progress(), sample() that internally use the client.

Both approaches give you the same power. The user chooses based on preference.

Under the Hood: The ClientGateway

Now let's peel back the first layer and see how the ClientGateway works. This is the API users interact with. It's surprisingly (not so surprisingly) simple, but super powerful.

Here's the actual implementation (simplified for clarity):

src/Server/ClientGateway.php

final class ClientGateway {
public function __construct(
private readonly SessionInterface $session,
) {}
 
/**
* Send a notification to the client (fire and forget).
*/
public function notify(Notification $notification): void {
\Fiber::suspend([
'type' => 'notification',
'notification' => $notification,
'session_id' => $this->session->getId()->toRfc4122(),
]);
}
 
/**
* Send a log message to the client.
*/
public function log(LoggingLevel $level, mixed $data, ?string $logger = null): void {
$this->notify(new LoggingMessageNotification($level, $data, $logger));
}
 
/**
* Send a progress update to the client.
*/
public function progress(float $progress, ?float $total = null, ?string $message = null): void {
$meta = $this->session->get(Protocol::SESSION_ACTIVE_REQUEST_META, []);
$progressToken = $meta['progressToken'] ?? null;
 
if (null === $progressToken) {
// Client didn't ask for progress, skip it
return;
}
 
$this->notify(new ProgressNotification($progressToken, $progress, $total, $message));
}
 
/**
* Request LLM sampling from the client.
*/
public function sample(
array|Content|string $message,
int $maxTokens = 1000,
int $timeout = 120,
array $options = []
): CreateSamplingMessageResult {
// Prepare the message
if (is_string($message)) {
$message = new TextContent($message);
}
if ($message instanceof Content) {
$message = [new SamplingMessage(Role::User, $message)];
}
 
$request = new CreateSamplingMessageRequest(
messages: $message,
maxTokens: $maxTokens,
preferences: $options['preferences'] ?? null,
systemPrompt: $options['systemPrompt'] ?? null,
temperature: $options['temperature'] ?? null,
// ... other options
);
 
// Send request and wait for response
$response = $this->request($request, $timeout);
 
if ($response instanceof Error) {
throw new ClientException($response);
}
 
return CreateSamplingMessageResult::fromArray($response->result);
}
 
/**
* Send a request to the client and wait for response.
*/
private function request(Request $request, int $timeout = 120): Response|Error {
$response = \Fiber::suspend([
'type' => 'request',
'request' => $request,
'session_id' => $this->session->getId()->toRfc4122(),
'timeout' => $timeout,
]);
 
if (!$response instanceof Response && !$response instanceof Error) {
throw new RuntimeException('Transport returned unexpected payload');
}
 
return $response;
}
}

The Key Methods

notify() - The simplest case:

public function notify(Notification $notification): void {
\Fiber::suspend([
'type' => 'notification',
'notification' => $notification,
'session_id' => $this->session->getId()->toRfc4122(),
]);
}

This suspends the current fiber with a data structure indicating:

  • It's a notification (no response needed)
  • What notification to send
  • Which session it belongs to

The fiber will be resumed immediately after the notification is queued.

request() - The complex case:

private function request(Request $request, int $timeout = 120): Response|Error {
$response = \Fiber::suspend([
'type' => 'request',
'request' => $request,
'session_id' => $this->session->getId()->toRfc4122(),
'timeout' => $timeout,
]);
 
return $response;
}

This suspends the fiber with a data structure indicating:

  • It's a request (response expected)
  • What request to send
  • How long to wait for a response

The fiber will NOT be resumed until:

  • The client sends a response, OR
  • The timeout expires

When resumed, the value passed to $fiber->resume($value) becomes the return value of Fiber::suspend(). So $response will be either a Response object (success) or an Error object (failure/timeout).

The Protocol: Orchestrating the Dance

The Protocol is the orchestration layer that sits between the user-facing API (ClientGateway) and the transport layer. We've already seen a simplified view of the handoff in the Architecture Overview section. Now let's dig deeper into how the Protocol actually orchestrates the entire flow.

The Flow in Detail

Building on the simplified version we saw earlier, here's the complete flow when a request comes in:

  1. Find the handler: The Protocol iterates through registered handlers to find one that supports this specific request type. Each handler declares what methods it supports (e.g., tools/call, prompts/get, etc.). If no handler is found, return a "method not found" error immediately.

  2. Wrap in fiber and start execution:

    $fiber = new \Fiber(fn() => $handler->handle($request, $session));
    $result = $fiber->start();

    The magic starts here. The handler executes inside a brand new fiber. If the handler calls $client->log() or $client->sample(), those methods will call Fiber::suspend(), which bubbles up to this point. The $result contains whatever was passed to suspend().

  3. Check suspension status: After starting the fiber, the Protocol checks $fiber->isSuspended(). This tells us if the handler is paused (waiting for something) or completed (returned a value).

  4. If suspended - Extract the yielded value: When suspended, $result contains an array like ['type' => 'notification', 'notification' => $obj] or ['type' => 'request', 'request' => $obj, 'timeout' => 120]. The Protocol examines this structure to understand what the handler wants to do.

  5. Queue the message: For notifications, the Protocol calls sendNotification() which queues the message in the session. For requests, it calls sendRequest() which queues the message in the session too, but with extra infomation to track it as "pending" with a timestamp and timeout.

  6. Hand off to transport: This is the crucial moment:

    $this->transport->attachFiberToSession($fiber, $session->getId());

    The Protocol gives the suspended fiber to the transport and returns. From this point forward, the transport owns the fiber. The Protocol's job is done until the next request comes in.

  7. If not suspended - Send final response: If the handler never called any client communication methods, the fiber completes immediately. The Protocol retrieves the return value with $fiber->getReturn() and sends it as the final response.

Why the Protocol Doesn't Resume Fibers

Notice that the protocol never calls $fiber->resume(). Could it? Technically, yes. The protocol could send a notification, then immediately resume the fiber. Or wait for a response, then resume. The main reason it doesn't is because different transports have wildly different I/O models.

  • The StdioTransport runs a blocking loop that continuously reads stdin, processes fibers, and writes to stdout
  • The StreamableHttpTransport opens an SSE stream that blocks the entire process, creating its own event loop that manages everything
  • The ReactPHP transport uses a non-blocking event loop with timers and callbacks

The key insight: sending messages and waiting for responses are transport-specific operations that might block.

For example, when the HTTP transport opens an SSE stream, that stream blocks the process. The only thing that can run is the SSE loop. Control oscillates between the transport's loop and the handler execution (via fiber suspend/resume). If the Protocol tried to manage resumption, it would need to understand each transport's execution model.

By giving the transport ownership of the fiber after the first suspension, the transport can:

  • Send messages according to its I/O model (blocking, non-blocking, async)
  • Wait for responses using its own mechanisms (polling sessions, event callbacks, timers)
  • Resume the fiber when it's ready, not when the protocol thinks it should
  • Handle subsequent suspensions that might occur after resumption

This isn't a fiber limitation, just an architectural decision. The protocol handles request/response semantics. The transport handles I/O and fiber lifecycle. Clean separation.

Handling Responses from the Client

When the client sends a response back (to a sampling request, for example), it comes in as a new message. The protocol receives it and stores it in the session:

src/Server/Protocol.php

private function handleResponse(Response|Error $response, SessionInterface $session): void {
$this->logger->info('Handling response from client.', ['response' => $response]);
 
$messageId = $response->getId();
 
// Store the response in the session
$session->set(self::SESSION_RESPONSES . ".{$messageId}", $response->jsonSerialize());
$session->forget(self::SESSION_ACTIVE_REQUEST_META);
 
$this->logger->info('Client response stored in session', [
'message_id' => $messageId,
]);
}

You'll notice that the protocol doesn't resume the fiber directly. Why? Because the protocol doesn't own the fiber anymore β€” the transport does. So when a client sends a response, the transport retrieves it from the session and calls $fiber->resume($response).

The transport is responsible for:

  1. Checking the session for responses
  2. Matching responses to pending requests
  3. Resuming the fiber with the response
  4. Handling the fiber until it terminates

Different transports do this differently based on their I/O model. Let's see how.

Transport #1: StdioTransport (Single Process, Blocking)

The StdioTransport is the simplest case: single process, blocking I/O, stdin/stdout.

The Challenge

With stdio:

  • We have one process
  • We need to read from stdin (blocking operation)
  • We need to manage suspended fibers
  • We need to send messages to stdout
  • We need to wait for client responses

How do you do all this without freezing?

The Solution: Non-Blocking Input + Event Loop

The trick is to make stdin non-blocking and run a main loop:

src/Server/Transport/StdioTransport.php

public function listen(): int {
$this->logger->info('StdioTransport is listening for messages on STDIN...');
stream_set_blocking($this->input, false); // Non-blocking!
 
while (!feof($this->input)) {
$this->processInput(); // Read from stdin
$this->processFiber(); // Manage fiber lifecycle
$this->flushOutgoingMessages(); // Send to stdout
}
 
$this->logger->info('StdioTransport finished listening.');
$this->handleSessionEnd($this->sessionId);
 
return 0;
}

This is a classic event loop pattern. Let's break down each step:

Step 1: Process Input

src/Server/Transport/StdioTransport.php

protected function processInput(): void {
$line = fgets($this->input);
if (false === $line) {
usleep(50000); // 50ms - no input available, sleep
return;
}
 
$trimmedLine = trim($line);
if (!empty($trimmedLine)) {
$this->handleMessage($trimmedLine, $this->sessionId);
}
}

Since stdin is non-blocking, fgets() returns immediately even if there's no input. If there's no input, we sleep briefly (50ms) to avoid spinning the CPU. If there is input, we pass it to handleMessage(), which calls the protocol.

This is where client requests and responses come in. When a client responds to a sampling request, it arrives here.

Step 2: Process Fiber

src/Server/Transport/StdioTransport.php

private function processFiber(): void {
if (null === $this->sessionFiber) {
return; // No fiber to manage
}
 
if ($this->sessionFiber->isTerminated()) {
$this->handleFiberTermination();
return;
}
 
if (!$this->sessionFiber->isSuspended()) {
return; // Fiber is running (shouldn't happen)
}
 
// Fiber is suspended. Check if we're waiting for a response.
$pendingRequests = $this->getPendingRequests($this->sessionId);
 
if (empty($pendingRequests)) {
// No pending requests means this was a notification.
// Resume immediately.
$yielded = $this->sessionFiber->resume();
$this->handleFiberYield($yielded, $this->sessionId);
return;
}
 
// We have pending requests. Check if responses arrived.
foreach ($pendingRequests as $pending) {
$requestId = $pending['request_id'];
$timestamp = $pending['timestamp'];
$timeout = $pending['timeout'] ?? 120;
 
$response = $this->checkForResponse($requestId, $this->sessionId);
 
if (null !== $response) {
// Response arrived! Resume fiber with it.
$yielded = $this->sessionFiber->resume($response);
$this->handleFiberYield($yielded, $this->sessionId);
return;
}
 
if (time() - $timestamp >= $timeout) {
// Timeout! Resume with error.
$error = Error::forInternalError('Request timed out', $requestId);
$yielded = $this->sessionFiber->resume($error);
$this->handleFiberYield($yielded, $this->sessionId);
return;
}
}
}

This method manages the fiber's lifecycle:

  1. No fiber? Nothing to do.
  2. Fiber terminated? Handle cleanup.
  3. Fiber not suspended? Shouldn't happen, but skip it.
  4. No pending requests? This was a notification. Resume immediately.
  5. Pending requests? Check if responses arrived in the session.
    • If response found: Resume with it
    • If timeout exceeded: Resume with error
    • Otherwise: Keep waiting (next loop iteration will check again)

Step 3: Flush Outgoing Messages

src/Server/Transport/StdioTransport.php

private function flushOutgoingMessages(): void {
$messages = $this->getOutgoingMessages($this->sessionId);
 
foreach ($messages as $message) {
$this->writeLine($message['message']);
}
}
 
private function writeLine(string $payload): void {
fwrite($this->output, $payload . PHP_EOL);
}

Any messages queued by the protocol are sent to stdout here.

Fiber Termination

When the fiber completes:

src/Server/Transport/StdioTransport.php

private function handleFiberTermination(): void {
$finalResult = $this->sessionFiber->getReturn();
 
if (null !== $finalResult) {
try {
$encoded = json_encode($finalResult, JSON_THROW_ON_ERROR);
$this->writeLine($encoded);
} catch (\JsonException $e) {
$this->logger->error('STDIO: Failed to encode final Fiber result.', ['exception' => $e]);
}
}
 
$this->sessionFiber = null;
}

We get the return value and send it as the final response. The fiber is cleared.

Transport #2: StreamableHttpTransport (Multi-Process, HTTP)

The HTTP transport is more complex because it needs to work in a multi-process environment. This is designed for PHP-FPM, traditional web servers, or frameworks like Laravel and Symfony.

The Challenge

With HTTP/PHP-FPM:

  • Each request is a separate process
  • Processes are stateless
  • We need persistent sessions across processes
  • We need streaming for real-time updates (SSE)

The Solution: SSE Streams + Persistent Sessions

The key insight: use Server-Sent Events (SSE) to keep a connection open, and use persistent sessions to coordinate across processes.

Here's the flow:

src/Server/Transport/StreamableHttpTransport.php

public function listen(): ResponseInterface {
return match ($this->request->getMethod()) {
'OPTIONS' => $this->handleOptionsRequest(),
'POST' => $this->handlePostRequest(),
'DELETE' => $this->handleDeleteRequest(),
default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405),
};
}

Handling POST Requests

src/Server/Transport/StreamableHttpTransport.php

protected function handlePostRequest(): ResponseInterface {
$body = $this->request->getBody()->getContents();
$this->handleMessage($body, $this->sessionId); // Process with protocol
 
// Check if there was an immediate response (error, initialize, etc.)
if (null !== $this->immediateResponse) {
$response = $this->responseFactory->createResponse($this->immediateStatusCode ?? 200)
->withHeader('Content-Type', 'application/json')
->withBody($this->streamFactory->createStream($this->immediateResponse));
 
return $this->withCorsHeaders($response);
}
 
// Check if there's a suspended fiber
if (null !== $this->sessionFiber) {
$this->logger->info('Fiber suspended, handling via SSE.');
return $this->createStreamedResponse(); // Open SSE stream!
}
 
// No fiber, just return queued messages
return $this->createJsonResponse();
}

The decision tree:

  1. Immediate response? (errors, initialize response) β†’ Send JSON response
  2. Suspended fiber? β†’ Open SSE stream and block this process
  3. Just messages? β†’ Send JSON response with queued messages

The Streamed Response (SSE)

This is where it gets interesting:

src/Server/Transport/StreamableHttpTransport.php

protected function createStreamedResponse(): ResponseInterface {
$callback = function (): void {
try {
$this->logger->info('SSE: Starting request processing loop');
 
// Block this process in a loop!
while ($this->sessionFiber->isSuspended()) {
$this->flushOutgoingMessages($this->sessionId);
 
$pendingRequests = $this->getPendingRequests($this->sessionId);
 
if (empty($pendingRequests)) {
// No pending requests, resume immediately
$yielded = $this->sessionFiber->resume();
$this->handleFiberYield($yielded, $this->sessionId);
continue;
}
 
// Check for responses (from another process!)
$resumed = false;
foreach ($pendingRequests as $pending) {
$requestId = $pending['request_id'];
$timestamp = $pending['timestamp'];
$timeout = $pending['timeout'] ?? 120;
 
// Check session for response
$response = $this->checkForResponse($requestId, $this->sessionId);
 
if (null !== $response) {
// Resume with response
$yielded = $this->sessionFiber->resume($response);
$this->handleFiberYield($yielded, $this->sessionId);
$resumed = true;
break;
}
 
if (time() - $timestamp >= $timeout) {
// Timeout - resume with error
$error = Error::forInternalError('Request timed out', $requestId);
$yielded = $this->sessionFiber->resume($error);
$this->handleFiberYield($yielded, $this->sessionId);
$resumed = true;
break;
}
}
 
if (!$resumed) {
usleep(100000); // 100ms - prevent tight loop
}
}
 
$this->handleFiberTermination();
} finally {
$this->sessionFiber = null;
}
};
 
$stream = new CallbackStream($callback, $this->logger);
$response = $this->responseFactory->createResponse(200)
->withHeader('Content-Type', 'text/event-stream')
->withHeader('Cache-Control', 'no-cache')
->withHeader('Connection', 'keep-alive')
->withHeader('X-Accel-Buffering', 'no')
->withBody($stream);
 
if ($this->sessionId) {
$response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122());
}
 
return $this->withCorsHeaders($response);
}

What's Happening Here?

  1. SSE Stream Opens: The response is streaming (text/event-stream), so the connection stays open.

  2. Blocking Loop Starts: This process now blocks in a while loop, managing the fiber.

  3. Flush Messages: Send any queued messages as SSE events:

    protected function flushOutgoingMessages(?Uuid $sessionId): void {
    $messages = $this->getOutgoingMessages($sessionId);
     
    foreach ($messages as $message) {
    echo "event: message\n";
    echo "data: {$message['message']}\n\n";
    @ob_flush();
    flush();
    }
    }
  4. Check for Responses: checkForResponse() reads from the persistent session:

    public function checkForResponse(int $requestId, Uuid $sessionId): Response|Error|null {
    $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore);
    $responseData = $session->get(self::SESSION_RESPONSES . ".{$requestId}");
     
    if (null === $responseData) {
    return null;
    }
     
    // Found it! Clear it and return.
    $session->set(self::SESSION_RESPONSES . ".{$requestId}", null);
    // ... reconstruct Response object
    return $response;
    }
  5. Another Process Writes the Response: When the client sends a response, it comes in as a new HTTP POST request in a different PHP-FPM process. That process:

    • Receives the response
    • Calls the protocol
    • Protocol stores it in the session (via handleResponse())
    • That process returns 202 Accepted (no fiber to manage)
  6. This Process Resumes: The blocking loop in the SSE process sees the response appear in the session and resumes the fiber with it!

The Multi-Process Dance

Here's the complete multi-process flow:

Process 1 (SSE stream):

1. Receive tool call request
2. Start handler in fiber
3. Handler calls $client->sample()
4. Fiber suspends
5. Open SSE stream
6. Send sampling request as SSE event
7. Enter blocking loop
8. Check session for response... (waiting)
9. Check session for response... (waiting)
10. Check session for response... (found it!)
11. Resume fiber with response
12. Handler completes
13. Send final result as SSE event
14. Close stream

Process 2 (receives client response):

1. Receive client's response to sampling request
2. Call protocol to handle response
3. Protocol stores response in persistent session
4. Return 202 Accepted
5. Process ends

Process 1 never talks to Process 2 directly. They communicate through the persistent session (file-based, Redis, Memcached, etc.).

This is cooperative multitasking across processes. The fiber in Process 1 yields control, and Process 2 provides what it needs to continue. The session is the coordination mechanism.

Transport #3: ReactPhpHttpTransport (Event-Driven, Async)

Note: This transport is not part of the official PHP MCP SDK. It's a separate package that demonstrates how to build a transport using ReactPHP's event loop and how Fibers work beautifully with asynchronous libraries. This example shows the versatility of the Fiber-based architectureβ€”the same client communication code works seamlessly with both synchronous and asynchronous transports.

The ReactPHP transport is the most sophisticated. It's a fully asynchronous, event-driven HTTP server that manages multiple sessions simultaneously in a single process.

Here's the key difference: this transport runs its own HTTP server using ReactPHP's event loop.

The Challenge

With ReactPHP:

  • Single process, non-blocking I/O
  • Multiple concurrent connections/sessions
  • Event-driven architecture (no blocking allowed)
  • Need to manage multiple fibers simultaneously

The Solution: Event Loop + Fiber State Machine

class ReactPhpHttpTransport extends BaseTransport implements TransportInterface {
private const FIBER_STATUS_AWAITING_RESPONSE = 'awaiting_response';
private const FIBER_STATUS_AWAITING_NOTIFICATION_RESUME = 'awaiting_notification_resume';
 
private LoopInterface $loop;
private ?SocketServer $socket = null;
 
/** @var array<string, array{fiber: \Fiber, status: string}> */
private array $managedFibers = [];
 
/** @var array<string, ThroughStream> */
private array $activeSseStreams = [];
 
private ?TimerInterface $tickTimer = null;
 
public function __construct(
private readonly string $host = '127.0.0.1',
private readonly int $port = 8080,
LoggerInterface $logger = new NullLogger(),
?LoopInterface $loop = null,
) {
parent::__construct($logger);
$this->loop = $loop ?? Loop::get();
}
}

Starting the Server

public function listen(): int {
$status = 0;
$this->logger->info("Starting ReactPHP server on {$this->host}:{$this->port}...");
 
$http = new HttpServer($this->loop, $this->handleHttpRequest(...));
$this->socket = new SocketServer("{$this->host}:{$this->port}", [], $this->loop);
$http->listen($this->socket);
 
$this->loop->run(); // Blocks here until loop stops
 
return $status;
}

This starts a non-blocking HTTP server. All I/O is async. The event loop handles everything.

Attaching Fibers

When the protocol hands off a suspended fiber:

public function attachFiberToSession(\Fiber $fiber, Uuid $sessionId): void {
$sessionIdStr = $sessionId->toRfc4122();
 
$this->managedFibers[$sessionIdStr] = [
'fiber' => $fiber,
'status' => null
];
 
if (null === $this->tickTimer) {
$this->logger->info('First managed fiber detected. Starting master tick timer.');
$this->tickTimer = $this->loop->addPeriodicTimer(0.1, $this->tick(...));
}
}

The fiber is stored in an array keyed by session ID. When the first fiber is attached, a master tick timer starts. This timer fires every 100ms and processes all managed fibers.

The Master Tick

private function tick(): void {
// Process SSE streams (send outgoing messages)
foreach ($this->activeSseStreams as $sessionIdStr => $stream) {
if (!$stream->isWritable()) {
continue;
}
$sessionId = Uuid::fromString($sessionIdStr);
$messages = $this->getOutgoingMessages($sessionId);
$this->processOutgoingMessages($sessionIdStr, $stream, $messages);
}
 
// Process managed fibers
foreach ($this->managedFibers as $sessionIdStr => $state) {
$sessionId = Uuid::fromString($sessionIdStr);
$this->processManagedFiber($state['fiber'], $state['status'], $sessionId);
}
 
// Stop timer if no fibers left
if (empty($this->managedFibers) && $this->tickTimer) {
$this->logger->info('No active managed fibers. Stopping master tick timer.');
$this->loop->cancelTimer($this->tickTimer);
$this->tickTimer = null;
}
}

Every 100ms:

  1. Send any outgoing messages through SSE streams
  2. Process each managed fiber (check for responses, timeouts, etc.)
  3. Stop the timer if all fibers are done

This is non-blocking. The event loop continues processing HTTP requests, reading responses, etc., while the tick timer periodically advances fiber execution.

Processing Managed Fibers

private function processManagedFiber(\Fiber $fiber, ?string $status, Uuid $sessionId): void {
$sessionIdStr = $sessionId->toRfc4122();
 
if ($fiber->isTerminated()) {
$this->handleFiberTermination($fiber, $sessionId);
return;
}
 
if (!$fiber->isSuspended()) {
return;
}
 
// State machine based on status
if (self::FIBER_STATUS_AWAITING_NOTIFICATION_RESUME === $status) {
// Notification sent, resume immediately
$yielded = $fiber->resume();
$this->managedFibers[$sessionIdStr]['status'] = null;
$this->handleFiberYield($yielded, $sessionId);
return;
}
 
if (self::FIBER_STATUS_AWAITING_RESPONSE === $status) {
// Check for client response
foreach ($this->getPendingRequests($sessionId) as $pending) {
$response = $this->checkForResponse($pending['request_id'], $sessionId);
 
if (null !== $response) {
// Got response! Resume with it.
$yielded = $fiber->resume($response);
$this->managedFibers[$sessionIdStr]['status'] = null;
$this->handleFiberYield($yielded, $sessionId);
break;
}
 
$timeout = $pending['timeout'] ?? 120;
if (time() - $pending['timestamp'] >= $timeout) {
// Timeout! Resume with error.
$error = Error::forInternalError("Request timed out", $pending['request_id']);
$yielded = $fiber->resume($error);
$this->managedFibers[$sessionIdStr]['status'] = null;
$this->handleFiberYield($yielded, $sessionId);
break;
}
}
}
}

The fiber has a status that tracks what it's waiting for:

  • null: Just suspended, hasn't been categorized yet
  • awaiting_notification_resume: Notification was sent, resume immediately on next tick
  • awaiting_response: Request was sent, wait for client response

When messages are sent, the status is set:

private function processOutgoingMessages(string $sessionIdStr, ThroughStream $stream, array $messages): void {
foreach ($messages as $message) {
if (isset($this->managedFibers[$sessionIdStr])) {
$messageType = $message['context']['type'] ?? null;
if ('request' === $messageType) {
$this->managedFibers[$sessionIdStr]['status'] = self::FIBER_STATUS_AWAITING_RESPONSE;
} elseif ('notification' === $messageType) {
$this->managedFibers[$sessionIdStr]['status'] = self::FIBER_STATUS_AWAITING_NOTIFICATION_RESUME;
}
}
$this->writeSseEvent($stream, $message['message']);
}
}

Handling Multiple Sessions

The beauty of this design: multiple sessions can have suspended fibers simultaneously. The event loop handles HTTP requests from different clients, and the tick timer processes all fibers fairly.

Tick 1: Process fiber for session A (awaiting response)
Process fiber for session B (resume after notification)
Process fiber for session C (awaiting response)
 
Tick 2: Process fiber for session A (still waiting)
Session B fiber terminated
Process fiber for session C (got response! resume)
 
Tick 3: Process fiber for session A (got response! resume)
Session A fiber terminated
Session C fiber suspended again...

This is true cooperative multitaskingβ€”multiple tasks (fibers) interleaved on a single thread, coordinated by the event loop. (Confused? Read through this section again, cos it can be a lot)

The Feedback Loop: Handling Subsequent Suspensions

Now that we've seen how all three transports work, there's a crucial pattern you might have noticed: every time a transport resumes a fiber, it calls handleFiberYield() with the result. Why?

Because when a transport resumes a fiber, the fiber might suspend AGAIN, and we want to support that.

Remember, the handler might call $client->log() multiple times, or call $client->progress() in a loop, or call $client->sample() multiple times for all we care. Each call suspends the fiber. The first suspension is handled by the Protocol directly. But what about the second, third, or tenth suspension?

This is where handleFiberYield() comes in. When a transport resumes a fiber:

// Inside transport
$yielded = $fiber->resume($response); // Resume with response
 
// What comes back?
// - null if fiber terminated (handler returned)
// - Another suspension payload if handler called $client->log() again!
 
$this->handleFiberYield($yielded, $sessionId);

If $yielded is null, the fiber is doneβ€”we can get the final result with $fiber->getReturn() and send it as the response.

But if $yielded contains a value, it means the fiber suspended again. The transport doesn't know what to do with it (is it a notification? a request?), so it calls handleFiberYield(), which proxies back to the Protocol (the Protocol registered this callback):

public function handleFiberYield(mixed $yieldedValue, ?Uuid $sessionId): void {
if (!is_array($yieldedValue) || !isset($yieldedValue['type'])) {
return; // Invalid, ignore
}
 
$session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore);
 
if ('notification' === $yieldedValue['type']) {
$notification = $yieldedValue['notification'];
$this->sendNotification($notification, $session);
} elseif ('request' === $yieldedValue['type']) {
$request = $yieldedValue['request'];
$timeout = $yieldedValue['timeout'] ?? 120;
$this->sendRequest($request, $timeout, $session);
}
 
$session->save();
}

This does exactly what the Protocol did on the first suspension: examines the type, queues the message, tracks pending requests. The difference is that the fiber is still owned by the transport.

The Complete Cycle

The cycle becomes:

  1. Protocol creates fiber and detects first suspension β†’ hands to Transport
  2. Transport resumes fiber (with response or immediately) β†’ fiber suspends again
  3. Transport calls handleFiberYield() β†’ Protocol queues the message
  4. Transport sends the queued message and waits for response (if needed)
  5. Transport resumes fiber with response β†’ fiber suspends again (maybe)
  6. Repeat steps 3-5 until fiber terminates

The Protocol handles what to do with suspensions (interpret and queue messages). The Transport handles when to resume and manages the fiber's lifecycle. This separation allows the same Protocol logic to work with any transport, regardless of its I/O modelβ€”whether it's a blocking loop (Stdio), an SSE stream (HTTP), or an event loop (ReactPHP).

This feedback loop is what makes the system so flexible. Handlers can call client communication methods as many times as they need, in any order, even in loops or conditionally. Each suspension is handled the same way, and execution always returns to the exact point where it left off.

The Execution Flow: A Complete Journey

Let's trace a complete request from start to finish, showing exactly where execution is at each moment. We'll use the StdioTransport for simplicity, but the concepts apply to all transports.

The Scenario

A tool that logs progress and requests LLM sampling:

$server->addTool(
function (string $prompt, ClientGateway $client): array {
$client->log(LoggingLevel::Info, "Starting generation");
$client->progress(0.5, 1, "Requesting LLM");
$result = $client->sample($prompt, 100);
$client->log(LoggingLevel::Info, "Generation complete");
return ['text' => $result->content->text];
},
name: 'generate_text'
);

The Journey

1. Client sends request

{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "generate_text", "arguments": {"prompt": "Hello"}}}
Execution: Main loop (StdioTransport::listen)
↓ StdioTransport::processInput() reads from stdin
↓ StdioTransport::handleMessage() called
↓ Protocol::processInput() decodes JSON
↓ Protocol::handleRequest() called

2. Protocol wraps handler in fiber

$fiber = new \Fiber(fn() => $handler->handle($request, $session));
$result = $fiber->start();
Execution: Inside fiber
↓ Handler (our closure) starts
↓ $client->log(...) called
↓ ClientGateway::log() β†’ ClientGateway::notify()
↓ \Fiber::suspend(['type' => 'notification', ...])
Execution: Back in Protocol (fiber suspended!)
↓ $result contains ['type' => 'notification', ...]
↓ Protocol::sendNotification() queues message
↓ Protocol hands fiber to transport
↓ Protocol::handleRequest() returns

3. Control back to main loop

Execution: Main loop (StdioTransport::listen)
↓ StdioTransport::processFiber() called
↓ Sees suspended fiber
↓ Checks pending requests (none - it was a notification)
↓ Resumes fiber immediately: $fiber->resume()

4. Back in handler

Execution: Inside fiber (resumed)
↓ ClientGateway::notify() returns (suspend finished)
↓ Handler continues
↓ $client->progress(...) called
↓ ClientGateway::progress() β†’ ClientGateway::notify()
↓ \Fiber::suspend(['type' => 'notification', ...])
Execution: Back in StdioTransport::processFiber()
↓ $yielded contains notification
↓ Protocol::handleFiberYield() queues it

5. Loop continues

Execution: Main loop
↓ StdioTransport::flushOutgoingMessages() called
↓ Sends log and progress notifications to stdout

6. Next tick - resume from progress

Execution: Main loop
↓ StdioTransport::processFiber() called
↓ No pending requests, resume immediately
↓ $fiber->resume()
Execution: Inside fiber
↓ Handler continues
↓ $client->sample($prompt, 100) called
↓ ClientGateway::sample() β†’ ClientGateway::request()
↓ \Fiber::suspend(['type' => 'request', 'request' => ..., 'timeout' => 100])
Execution: Back in StdioTransport::processFiber()
↓ $yielded contains request
↓ Protocol::sendRequest() queues it and tracks as pending

7. Loop flushes sampling request

Execution: Main loop
↓ StdioTransport::flushOutgoingMessages()
↓ Sends sampling request to stdout
{"jsonrpc": "2.0", "id": 1000, "method": "sampling/createMessage", "params": {...}}

8. Loop waits for response

Execution: Main loop (keeps running)
↓ StdioTransport::processInput() (no input yet)
↓ StdioTransport::processFiber()
↓ Checks pending requests (has one: request 1000)
↓ Checks for response in session (not yet)
↓ Doesn't resume
↓ Loop continues...

9. Client sends response (seconds later)

{"jsonrpc": "2.0", "id": 1000, "result": {"content": {"type": "text", "text": "Hello, world!"}, ...}}
Execution: Main loop
↓ StdioTransport::processInput() reads response
↓ Protocol::processInput() decodes it
↓ Protocol::handleResponse() stores in session

10. Loop resumes fiber with response

Execution: Main loop
↓ StdioTransport::processFiber()
↓ Checks for response (found it!)
↓ Calls $fiber->resume($response)
Execution: Inside fiber (resumed with response)
↓ ClientGateway::request() returns (suspend finished with $response)
↓ ClientGateway::sample() extracts result
↓ $result = CreateSamplingMessageResult::fromArray(...)
↓ Handler continues
↓ $client->log(..., "Generation complete")
↓ \Fiber::suspend(['type' => 'notification', ...])

11. Resume from final log

Execution: Back in StdioTransport::processFiber()
↓ Queues final log
↓ Resumes fiber
Execution: Inside fiber
↓ Handler continues
↓ return ['text' => $result->content->text];
↓ Fiber terminates!

12. Handle termination

Execution: Main loop
↓ StdioTransport::processFiber()
↓ Detects fiber terminated
↓ Calls handleFiberTermination()
↓ Gets return value: ['text' => 'Hello, world!']
↓ Encodes as JSON response
↓ Writes to stdout
{"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", "text": "Hello, world!"}]}}

13. Done!

Execution: Main loop
↓ Fiber cleared
↓ Continues listening for next request...

Visualizing Execution Context

Here's a visual representation:

Time β”‚ Execution Location β”‚ Fiber State
──────┼─────────────────────────────┼──────────────
T0 β”‚ Main Loop β”‚ None
T1 β”‚ Protocol::handleRequest β”‚ Starting...
T2 β”‚ Handler (in fiber) β”‚ Running
T3 β”‚ ClientGateway::log() β”‚ Running
T4 β”‚ Main Loop β”‚ Suspended (after log)
T5 β”‚ Handler (resumed) β”‚ Running
T6 β”‚ ClientGateway::progress() β”‚ Running
T7 β”‚ Main Loop β”‚ Suspended (after progress)
T8 β”‚ Handler (resumed) β”‚ Running
T9 β”‚ ClientGateway::sample() β”‚ Running
T10 β”‚ Main Loop β”‚ Suspended (awaiting response)
T11 β”‚ Main Loop (waiting...) β”‚ Suspended (awaiting response)
T12 β”‚ Main Loop (waiting...) β”‚ Suspended (awaiting response)
T13 β”‚ Main Loop (response!) β”‚ Suspended (awaiting response)
T14 β”‚ Handler (resumed) β”‚ Running
T15 β”‚ ClientGateway::log() β”‚ Running
T16 β”‚ Main Loop β”‚ Suspended (after log)
T17 β”‚ Handler (resumed) β”‚ Running
T18 β”‚ Main Loop β”‚ Terminated

Notice how execution bounces between the handler (inside the fiber) and the main loop (outside the fiber). The fiber preserves all context across these switches.

A Note on Exception Handling and Flexibility

There is one critical aspect of the Fiber API that we haven't discussed yet. We've talked extensively about Fiber::suspend() and Fiber::resume(), which handle the "happy path" where data flows in and out smoothly.

But what happens when things go wrong? What if an exception occurs while a Fiber is suspended?

Let's back up and look at this from the Orchestrator's perspective. Once the Fiber suspends, the Orchestrator (person that started or resumed the fiber, our Transport for the SDK) regains control. At this point, it is just running normal PHP code. It might be doing other things like reading input streams, flushing messages to the network, or checking a database.

If an exception happens during this work, you have a decision to make: Is this exception relevant to the suspended fiber?

Scenario 1: Unrelated Failures

If your Orchestrator throws an exception that has absolutely nothing to do with the suspended fiber, say, a RedisConnectionException occurs while the Transport is trying to cache a totally unrelated session, you don't need to wake the fiber up.

You simply let the exception bubble up in the main process just like any standard PHP script. The fiber remains suspended in memory. If the process crashes or is done, the fiber dies with it. That's fine. The suspended fiber doesn't need to know that the server is on fire; it was just waiting for a response that will now never come.

Scenario 2: Relevant Failures

However, if the failure is related to what the fiber is waiting for, you have a bridge to cross.

For example, imagine the Fiber is waiting for a response to a request, but the Transport realizes that the request has timed out. Or perhaps the Transport tried to send the request but the socket closed unexpectedly. The Fiber is still frozen, waiting for an answer. We need to tell it that the answer isn't coming.

Fibers give you two architectural choices for this:

Option A: Resume with an Error Object (Treating Error as Data)

You can choose to handle the failure gracefully. You don't necessarily have to throw an exception. Instead, you can create a value that represents the failure (like an Error object) and resume() the fiber with it.

// Orchestrator (Transport Loop)
if (time() > $timeout) {
// We create an error object representing what went wrong
$error = new ErrorResult("Request timed out");
 
// We hand it to the fiber nicely
$fiber->resume($error);
}
 
// Inside User's Fiber
$result = Fiber::suspend();
if ($result instanceof ErrorResult) {
// User manually checks for errors
$this->log("Something went wrong: " . $result->message);
}

This is the approach I largely took in the PHP MCP SDK. Since JSON-RPC treats errors as valid response payloads, we wrap them in objects and pass them in. The fiber wakes up, checks the type, and handles it logic.

Option B: Inject the Exception (Fiber::throw)

Alternatively, you can choose to force the fiber to fail from the inside using Fiber::throw(). It takes an exception (whether one you just caught because a transport operation failed, or one you instantiated because the system reached an invalid state) and throws it into the suspended fiber.

To the code running inside the Fiber, it looks exactly like the synchronous function call failed.

Imagine your Transport tries to write to a socket on behalf of the suspended fiber, but the write fails. You don't want the Transport loop to crash, but you definitely want the Fiber to know that its request never made it out.

// Orchestrator (Transport Loop)
try {
$this->writeToSocket($payload);
} catch (ConnectionException $e) {
// The transport failed to send the request.
// Instead of crashing the whole server, we catch the exception
// and throw it specifically into the fiber that was waiting.
$fiber->throw($e);
}

You can also throw exceptions based on logic checks, like timeouts or protocol violations.

// Orchestrator (Transport Loop)
if (time() > $timeout) {
// We didn't catch an exception, but we create one because
// the system reached a state (timeout) that invalidates the wait.
$e = new TimeoutException("Server took too long to respond");
 
// We force the fiber to throw this exception at the suspension point
$fiber->throw($e);
}

In both cases, the user's code inside the Fiber looks the same. They just handle the error as if it happened synchronously:

// Inside User's Fiber
try {
// Whether the socket failed (Case 1) or it timed out (Case 2),
// it looks like this method call just threw the exception!
$client->sample("...");
} catch (ConnectionException|TimeoutException $e) {
// The exception was injected here!
$this->log(LoggingLevel::Error, "Communication failed: " . $e->getMessage());
}

Depending on your architecture, this is often the better approach for infrastructure failures. It forces the user's code to stop execution immediately unless they explicitly wrap their calls in try/catch blocks, providing a predictable, "synchronous" error handling experience even for asynchronous events.

The beauty of Fibers is that you get to choose. You define the contract. You decide where the boundary lies between the "System" (Transport) and the "Logic" (Fiber), and how failures cross that boundary.

Building Your Own: A Practical Example

Now that you've seen how I utitlized Fibers in the PHP MCP SDK, let's try to build something practical to really help us understand and drive the point home. We'll be building a task management system for a restaurant kitchen. Multiple orders come in, each with sub-tasks (prep, cook, plate), and we want them all to run concurrently in a single process.

What We're Building

Before we start building, let's take a page from Aaron Francis' book and work backwards. Instead of diving into implementation details, let's define the final API we want and what we want the system to look like. Then we'll walk backwards and build a system that allows us to do exactly that.

Here's what we're aiming for:

$manager = new TaskManager();
 
// Add restaurant orders
$manager->add(new ProcessPizzaTask());
$manager->add(new ProcessSaladTask());
$manager->add(new ProcessPastaTask());
$manager->add(new CleanupTask());
 
// Add kitchen monitoring
$manager->addInterval(2.0, fn() => echo "[Monitor] Kitchen status check\n");
 
$manager->run(); // Runs until all tasks complete

That's it. That's the API. Clean, simple, no ceremony. But what do we want from this? Let's think about what's happening in a restaurant kitchen. When you're processing a pizza order, you're not busy 100% of the time. You prep ingredients for a second, then throw it in the oven for 2.5 seconds. During that baking time, you're just... waiting. Your hands are free. In a real kitchen, you'd start working on the salad order or the pasta order. You wouldn't just stand there staring at the oven.

That's exactly what we want our system to do. When a task is waiting (for time to pass, or for another task to complete), we want the TaskManager to switch to other tasks that can make progress. No blocking, no wasted CPU time. Most importantly, we want the code inside each task to look completely synchronous. Linear, top-to-bottom, easy to read and understand.

So inside a task to process pizza for example, we want to write code like:

echo "Starting pizza...\n";
wait(seconds: 1.0); // Prep ingredients
echo "Baking...\n";
wait(seconds: 2.5); // Bake
echo "Done!\n";

No callbacks. No promises. No ->then() chains. Just normal procedural code that happens to cooperatively yield control when it's waiting.

We also want sub-tasks - finer-grained control over what runs in parallel and what runs sequentially. Maybe for pasta, we want to start boiling water AND preparing sauce at the same time, then wait for the water before cooking pasta, then wait for the sauce before combining. We should be able to start() both tasks, wait for the one we need first, do some work, then wait for the second one. If it's already done, great - we don't suspend. If not, we yield control until it finishes.

And we want helper functions like timeout() and interval() that can schedule callbacks without blocking the current task. Fire-and-forget style - schedule a reminder to check the pizza temperature in 3 seconds, but keep processing the current task without pausing.

That's our vision. Now let's build it.

Building It: Step 1 - The Task Class

First, we need a simple Task abstraction:

class Task {
private \Fiber $fiber;
private bool $started = false;
private bool $complete = false;
private mixed $result = null;
 
protected function execute(): mixed {
return null;
}
 
public function start(): void {
if ($this->started) {
return;
}
 
$this->started = true;
 
$this->fiber = new \Fiber(fn() => $this->execute());
 
$manager = TaskManager::current();
 
if (!$manager) {
throw new \RuntimeException('No TaskManager available');
}
 
$manager->add($this);
}
 
public function getFiber(): \Fiber {
return $this->fiber;
}
 
public function isStarted(): bool {
return $this->started;
}
 
public function isComplete(): bool {
return $this->complete || ($this->fiber && $this->fiber->isTerminated());
}
 
public function markComplete(mixed $result): void {
$this->complete = true;
$this->result = $result;
}
 
public function getResult(): mixed {
return $this->result;
}
}

The Task class is very straightforward and is our basic unit of work. It's designed to be extended so child classes can override the execute() method to define what the task actually does. Notice execute() is protected, not public. This is intentional since tasks should be started via start(), not by calling execute() directly.

When you call start(), it first checks if the task has already started (we don't want to start the same task twice). Then it creates a new Fiber, wrapping the execute() method. It then gets the current TaskManager and automatically registers itself to it. As you may have noticed, it doesn't start the fiber, and that's because we want the TaskManager to be in complete control of when fibers start and when they resume.

The task also tracks its own state. The isStarted() and isComplete() methods let other tasks check if this task is done. The getResult() method returns whatever value the task's execute() method returned. This makes it easy to have tasks that depend on other tasks - just start() a task, then later wait(task: $task) for it and grab its result.

Now, creating a class for every little task can be tedious. Sometimes you just want a quick one-off task without the ceremony of a full class. For example, maybe you want to add a simple callback that runs concurrently with your main tasks. That's where CallableTask comes in. It's a specialized Task that accepts a callable in its constructor:

class CallableTask extends Task {
private $callback;
 
public function __construct(callable $callback) {
$this->callback = $callback;
}
 
protected function execute(): mixed {
return ($this->callback)();
}
}

Simple, right? It just takes your callable and executes it in the execute() method. This means you can do $manager->add(function() { echo "Quick task!"; wait(seconds: 1.0); }) and the TaskManager will automatically wrap it in a CallableTask. Clean and convenient for quick tasks that don't warrant a full class.

Step 2: The TaskManager

Now we need someone to orchestrate all these fibers. That's the TaskManager's job. It should decide which fiber runs, tracks all active tasks, manages timers and intervals, and keeps everything moving forward.

Here's the TaskManager:

class TaskManager {
private array $tasks = [];
private array $timers = [];
private array $timeouts = [];
private array $intervals = [];
private float $startTime;
 
private static ?TaskManager $current = null;
 
public function __construct() {
$this->startTime = microtime(true);
self::$current = $this;
}
 
public static function current(): self {
return self::$current;
}
 
public function add(Task|callable $task): Task {
if (!$task instanceof Task) {
$task = new CallableTask($task);
}
 
foreach ($this->tasks as $item) {
if ($item['task'] === $task) {
return $task;
}
}
 
$this->tasks[] = [
'task' => $task,
'id' => uniqid(),
'waiting_for' => null,
];
return $task;
}
 
public function addTimeout(float $seconds, callable $callback): void {
$this->timeouts[] = [
'fire_at' => $this->now() + $seconds,
'callback' => $callback,
'executed' => false,
];
}
 
public function addInterval(float $seconds, callable $callback): int {
static $nextId = 1;
$id = $nextId++;
 
$this->intervals[$id] = [
'seconds' => $seconds,
'callback' => $callback,
'next_run' => $this->now() + $seconds,
];
 
return $id;
}
 
public function clearInterval(int $id): void {
unset($this->intervals[$id]);
}
 
public function run(): void {
// Start all tasks added before run()
foreach ($this->tasks as $item) {
$item['task']->start();
}
 
// Main loop - runs until all TASKS complete
while (!empty($this->getActiveTasks())) {
$this->processTasks();
$this->processTimers();
$this->processTimeouts();
$this->processIntervals();
usleep(10000); // 10ms tick
}
 
echo "\nβœ“ All tasks completed!\n";
}
 
private function processTimers(): void {
$now = $this->now();
 
foreach ($this->timers as $key => $timer) {
if ($now >= $timer['resume_at'] && !$timer['executed']) {
$this->timers[$key]['executed'] = true;
$fiber = $timer['fiber'];
 
if ($fiber->isSuspended()) {
$result = $fiber->resume();
$this->handleSuspension($result, $timer['task_key']);
}
 
unset($this->timers[$key]);
}
}
}
 
private function processTimeouts(): void {
$now = $this->now();
 
foreach ($this->timeouts as $key => $timeout) {
if ($now >= $timeout['fire_at'] && !$timeout['executed']) {
$this->timeouts[$key]['executed'] = true;
$callback = $timeout['callback'];
$callback();
unset($this->timeouts[$key]);
}
}
}
 
private function processIntervals(): void {
$now = $this->now();
 
foreach ($this->intervals as $id => $interval) {
if ($now >= $interval['next_run']) {
$this->intervals[$id]['next_run'] = $now + $interval['seconds'];
$callback = $interval['callback'];
$callback();
}
}
}
 
private function processTasks(): void {
foreach ($this->tasks as $key => $item) {
$fiber = $item['task']->getFiber();
 
// Start fibers that haven't been started yet
if (!$fiber->isStarted()) {
$result = $fiber->start();
$this->handleSuspension($result, $key);
continue;
}
 
// Mark terminated tasks as complete
if ($fiber->isTerminated()) {
if (!$item['task']->isComplete()) {
$this->tasks[$key]['task']->markComplete($fiber->getReturn());
}
continue;
}
 
// Resume tasks waiting for other tasks
if ($fiber->isSuspended() && $item['waiting_for'] !== null) {
$waitedTask = $item['waiting_for'];
if ($waitedTask->isComplete()) {
$this->tasks[$key]['waiting_for'] = null;
$result = $fiber->resume($waitedTask->getResult());
$this->handleSuspension($result, $key);
}
}
}
}
 
private function handleSuspension($result, int $taskKey): void {
$taskItem = $this->tasks[$taskKey];
$fiber = $taskItem['task']->getFiber();
 
if ($fiber->isTerminated()) {
$this->tasks[$taskKey]['task']->markComplete($fiber->getReturn());
return;
}
 
if (is_array($result) && isset($result['type']) && $result['type'] === 'wait') {
if (isset($result['seconds'])) {
// Wait for time duration
$this->timers[] = [
'resume_at' => $this->now() + $result['seconds'],
'fiber' => $fiber,
'task_key' => $taskKey,
'executed' => false,
];
} elseif (isset($result['task'])) {
// Wait for another task
$this->tasks[$taskKey]['waiting_for'] = $result['task'];
}
}
}
 
private function getActiveTasks(): array {
return array_filter($this->tasks, function($item) {
return !$item['task']->getFiber()->isTerminated();
});
}
 
private function now(): float {
return microtime(true) - $this->startTime;
}
}

Let's break down what's happening here:

The add() method accepts either a Task or a callable. If it's a callable, it wraps it in a CallableTask automatically. But importantly, add() doesn't start the task - it just registers it in the internal array with an additional waiting_for field (initially null). This field will track if the task is waiting for another task to complete.

When you call run(), it first loops through all registered tasks and calls start() on them. Remember, start() doesn't actually start the fiber, it just marks the task as started. The actual fiber starting happens in the processing loop.

run() then enters the main loop: while (!empty($this->getActiveTasks())). This is the orchestrator we talked about earlier - the entity that owns the fibers and decides when to start them and when to resume them. Every 10ms, it calls four process methods:

  1. processTasks() - This is where fibers actually start. It checks each task: if the fiber hasn't been started yet, it starts it and handles the suspension. If the fiber is already running and waiting for another task (via the waiting_for field), and that task is complete, it resumes the waiting fiber with the result.
  2. processTimers() - Handles time-based waits only. If a fiber called wait(seconds: 2.0) and 2 seconds have passed, resume it.
  3. processTimeouts() - Fires one-time timeout callbacks that are due. These come from timeout() calls.
  4. processIntervals() - Fires recurring interval callbacks that are due. These come from interval() calls.

The loop continues as long as there are active tasks. Intervals and timeouts don't keep the loop alive - only actual tasks do. Once all tasks terminate, the manager stops, and any pending intervals stop firing.

The handleSuspension() method is where we process what a fiber suspended with. When a fiber calls wait(seconds: 2.0), it suspends with ['type' => 'wait', 'seconds' => 2.0]. The manager adds it to the timers array with when to resume it. When a fiber calls wait(task: $someTask), it suspends with ['type' => 'wait', 'task' => $someTask], and the manager sets that task item's waiting_for field to $someTask.

This is cooperative multitasking in action. Fibers explicitly yield control back to the manager, and the manager decides when to give control back based on conditions. The manager is in complete control of fiber lifecycle - starting them, resuming them, tracking their suspensions.

Step 3: The Helper Functions

Now we need a way for tasks to actually suspend themselves. We can't use sleep() because that blocks the entire process - all fibers would freeze. Instead, we need helpers that suspend just the current fiber and tell the TaskManager when to resume it:

function wait(?Task $task = null, ?float $seconds = null): mixed {
if ($task !== null && $seconds !== null) {
throw new \InvalidArgumentException('Cannot wait for both task and seconds');
}
 
if ($task === null && $seconds === null) {
throw new \InvalidArgumentException('Must provide either task or seconds');
}
 
if ($task !== null) {
if (!$task->isStarted()) {
throw new \RuntimeException('Task must be started before waiting');
}
 
if ($task->isComplete()) {
return $task->getResult();
}
 
return \Fiber::suspend(['type' => 'wait', 'task' => $task]);
}
 
if ($seconds !== null) {
\Fiber::suspend(['type' => 'wait', 'seconds' => $seconds]);
}
 
return null;
}

The wait() function is the heart of our system. It uses named parameters so you can call either wait(seconds: 2.0) to pause for a duration, or wait(task: $someTask) to wait for another task to complete.

When you call wait(seconds: 2.0), it suspends the current fiber with ['type' => 'wait', 'seconds' => 2.0]. The TaskManager sees this, stores it in its timers array with a resume time, and switches to other fibers. After 2 seconds elapse, the manager resumes your fiber right where it left off.

When you call wait(task: $someTask), it first checks if the task is started - if not, it throws an exception because you need to explicitly start() tasks before waiting for them. If the task is already complete, wait() returns immediately without suspending (optimization!). If it's still running, the fiber suspends with ['type' => 'wait', 'task' => $someTask], and the manager will resume it once that task it's waiting for completes.

While we're at it, let's add some helper functions for tasks to schedule callbacks without blocking. Say you're processing an order and want to schedule a reminder to check on it in 3 seconds, but you don't want to pause your current task. Or maybe you want a recurring callback that fires every 2 seconds to monitor system status. That's what timeout() and interval() are for:

function timeout(float $seconds, callable $callback): void {
$manager = TaskManager::current();
 
if (!$manager) {
throw new \RuntimeException('No TaskManager available');
}
 
$manager->addTimeout($seconds, $callback);
}
 
function interval(float $seconds, callable $callback): int {
$manager = TaskManager::current();
 
if (!$manager) {
throw new \RuntimeException('No TaskManager available');
}
 
return $manager->addInterval($seconds, $callback);
}

These functions are fundamentally different from wait(). They don't suspend the current fiber at all (I'm just adding them for dramatic flair). When you call timeout(3.0, fn() => echo "Reminder!"), execution continues immediately. The function just registers the callback with the TaskManager, which will fire it 3 seconds later during its processing loop. Same with interval() - it schedules a recurring callback and returns an ID you can use to cancel it later if needed.

The timeout() and interval() technically don't have anything to do with Fibers per se, but I included them to show that our main Task manager can do other stuffs to while all fibers are suspended and its free.

Step 4: Defining Task Classes

Now let's define the actual restaurant tasks. We'll create three main orders - pizza, salad, and pasta - each with their own sub-tasks. The beauty of this design is how natural the code looks despite all the concurrent execution happening underneath.

Pizza Order (Sequential sub-tasks):

class PrepareIngredientsTask extends Task {
protected function execute(): mixed {
echo " Preparing ingredients...\n";
wait(seconds: 1.0);
return "ingredients_ready";
}
}
 
class BakePizzaTask extends Task {
protected function execute(): mixed {
echo " Baking pizza...\n";
wait(seconds: 2.5);
return "pizza_baked";
}
}
 
class ProcessPizzaTask extends Task {
protected PrepareIngredientsTask $prepTask;
protected BakePizzaTask $bakeTask;
 
public function __construct() {
$this->prepTask = new PrepareIngredientsTask();
$this->bakeTask = new BakePizzaTask();
}
 
protected function execute(): mixed {
echo "πŸ“‹ Pizza order received\n";
 
timeout(3.0, fn() => echo "⏰ Check pizza temperature!\n");
timeout(4.5, fn() => echo "⏰ Don't forget extra cheese!\n");
 
$this->prepTask->start();
wait(task: $this->prepTask); // Suspends until prep done
 
$this->bakeTask->start();
wait(task: $this->bakeTask); // Suspends until baking done
 
echo " Adding toppings...\n";
wait(seconds: 0.5);
echo "βœ… Pizza complete!\n\n";
 
return "pizza_complete";
}
}

The pizza order is straightforward. We have two sub-tasks: preparing ingredients and baking. In the execute() method, we start the prep task explicitly with $this->prepTask->start(), then immediately call wait(task: $this->prepTask).

Here's what happens: when we start the prep task, its fiber begins executing. It prints "Preparing ingredients..." then calls wait(seconds: 1.0) and suspends. Control returns to ProcessPizzaTask. We then call wait(task: $this->prepTask), which checks if prep is complete. It's not (it's only been a fraction of a second), so ProcessPizzaTask also suspends. Now both fibers are suspended, and the TaskManager switches to process other orders.

After 1 second, the manager resumes PrepareIngredientsTask. It completes and the fiber terminates. The manager detects this and resumes ProcessPizzaTask, which was waiting for prep to complete. The wait() returns, and we continue to the next step - baking. Same pattern: start baking, wait for it, then continue.

We also schedule two timeouts - reminders to check temperature and add extra cheese. These don't block execution; they're just scheduled and will fire at the specified times regardless of what else is happening.

This is sequential execution of sub-tasks, but it doesn't block other orders from processing.

Salad Order (Parallel sub-tasks):

class WashVegetablesTask extends Task {
protected function execute(): mixed {
echo " Washing vegetables...\n";
wait(seconds: 1.0);
return "vegetables";
}
}
 
class PrepareBowlTask extends Task {
protected function execute(): mixed {
echo " Preparing bowl...\n";
wait(seconds: 0.8);
return "bowl";
}
}
class ProcessSaladTask extends Task {
protected WashVegetablesTask $washTask;
protected PrepareBowlTask $bowlTask;
 
public function __construct() {
$this->washTask = new WashVegetablesTask();
$this->bowlTask = new PrepareBowlTask();
}
 
protected function execute(): mixed {
echo "πŸ“‹ Salad order received\n";
 
timeout(2.5, fn() => echo "⏰ Time to add dressing!\n");
 
// Start BOTH tasks (they run in parallel!)
$this->washTask->start();
$this->bowlTask->start();
 
// Wait for both
wait(task: $this->washTask); // Might already be done!
wait(task: $this->bowlTask); // Might already be done!
 
echo " Chopping...\n";
wait(seconds: 1.5);
echo "βœ… Salad complete!\n\n";
 
return "salad_complete";
}
}

The salad order shows parallel execution. Notice that we start BOTH tasks before waiting for either.

When we call $this->washTask->start(), the wash task begins, prints "Washing vegetables...", hits wait(seconds: 1.0), and suspends. Control returns to ProcessSaladTask. We then call $this->bowlTask->start(), and the same thing happens - it starts, prints, waits 0.8s, and suspends. Now we have THREE suspended fibers: ProcessSaladTask, WashVegetablesTask, and PrepareBowlTask.

The TaskManager switches between them as their delays expire. After 0.8 seconds, PrepareBowlTask resumes and completes. After 1.0 seconds, WashVegetablesTask resumes and completes. ProcessSaladTask is still waiting.

So, when we call wait(task: $this->washTask), it checks if wash is complete. It is (1.0s already elapsed), so it returns immediately WITHOUT suspending! Same with wait(task: $this->bowlTask) - the bowl was done even earlier, so it also returns immediately.

This is the power of explicit task starting as both sub-tasks ran in parallel, and by the time we check if they're done, they are. No suspension needed.

Pasta Order (Mixed: parallel then sequential):

class BoilWaterTask extends Task {
protected function execute(): mixed {
echo " Boiling water...\n";
wait(seconds: 2.0);
return "water_ready";
}
}
 
class PrepareSauceTask extends Task {
protected function execute(): mixed {
echo " Preparing sauce...\n";
wait(seconds: 1.5);
return "sauce_ready";
}
}
class ProcessPastaTask extends Task {
protected BoilWaterTask $boilTask;
protected PrepareSauceTask $sauceTask;
 
public function __construct() {
$this->boilTask = new BoilWaterTask();
$this->sauceTask = new PrepareSauceTask();
}
 
protected function execute(): mixed {
echo "πŸ“‹ Pasta order received\n";
 
timeout(1.0, fn() => echo "⏰ Water boiling soon...\n");
timeout(2.5, fn() => echo "⏰ Stir pasta!\n");
timeout(4.0, fn() => echo "⏰ Check if al dente!\n");
 
// Start both in parallel
$this->boilTask->start();
$this->sauceTask->start();
 
// Wait for water (sauce keeps cooking in background!)
wait(task: $this->boilTask);
 
// Cook pasta (sauce STILL cooking!)
echo " Cooking pasta...\n";
wait(seconds: 3.0);
 
// Now wait for sauce (might be done already!)
wait(task: $this->sauceTask);
 
echo " Combining...\n";
wait(seconds: 0.5);
echo "βœ… Pasta complete!\n\n";
 
return "pasta_complete";
}
}

The pasta order demonstrates a mix of parallel and sequential execution. We start both BoilWaterTask (2.0s) and PrepareSauceTask (1.5s) in parallel, just like salad. But then we wait for water first with wait(task: $this->boilTask). Why? Because we need boiling water to cook pasta dhurr!

When we call that wait, the boil task is still running (hasn't finished its 2.0s yet), so ProcessPastaTask suspends. Meanwhile, the sauce task is also cooking. After 1.5 seconds, the sauce completes. After 2.0 seconds, the water completes, and ProcessPastaTask resumes.

Now we cook the pasta for 3 seconds with wait(seconds: 3.0). During this time, ProcessPastaTask is suspended again. Other orders can process. After pasta is done cooking, we call wait(task: $this->sauceTask). Remember, the sauce finished way back at 1.5s, so this wait returns immediately - no suspension needed!

This pattern of starting multiple things in parallel and waiting for them in the order YOU need them is incredibly powerful. The sauce was done long before we needed it, but we didn't have to block waiting for it. We just checked when we were ready, and it was there.

We also schedule three different timeout reminders at various points. Cleanup Task:

class CleanupTask extends Task {
protected function execute(): mixed {
echo "πŸ“‹ Cleanup scheduled for 6s\n\n";
wait(seconds: 6.0);
echo "🧹 Starting cleanup...\n";
wait(seconds: 0.5);
echo "🧹 Wiping counters...\n";
wait(seconds: 0.5);
echo "🧹 Done!\n\n";
}
}

Step 5: Running It

$manager = new TaskManager();
 
$manager->add(new ProcessPizzaTask());
$manager->add(new ProcessSaladTask());
$manager->add(new ProcessPastaTask());
$manager->add(new CleanupTask());
 
$manager->addInterval(2.0, fn() => echo "[Monitor] Kitchen status check\n");
 
echo "🍽️ Restaurant Kitchen\n==================\n\n";
$manager->run();

Output:

🍽️ Restaurant Kitchen Management System
=====================================
 
πŸ“‹ [Order #101] Pizza order received
πŸ“‹ [Order #102] Salad order received
πŸ“‹ [Order #103] Pasta order received
πŸ“‹ [Cleanup] Scheduled for 6 seconds
[Order #101] Preparing ingredients...
[Order #102] Washing vegetables...
[Order #102] Preparing bowl and utensils...
[Order #103] Boiling water...
[Order #103] Preparing sauce...
[Order #102] βœ“ Bowl ready
⏰ [Order #103] Water should be boiling soon...
[Order #101] βœ“ Ingredients ready
[Order #102] βœ“ Vegetables washed
[Order #102] Chopping ingredients...
[Order #101] Baking pizza...
[Order #103] βœ“ Sauce ready
[Monitor] Checking kitchen status...
[Order #103] βœ“ Water boiling
[Order #103] Cooking pasta...
⏰ [Order #102] Time to add dressing!
⏰ [Order #103] Remember to stir the pasta!
[Order #102] βœ“ Ingredients chopped
[Order #102] Mixing salad...
⏰ [Order #101] Reminder: Check pizza temperature!
[Order #102] βœ“ Salad mixed
[Order #102] Packaging...
βœ… [Order #102] Salad complete!
 
[Order #101] βœ“ Pizza baked
[Order #101] Adding toppings...
⏰ [Order #103] Check if pasta is al dente!
[Monitor] Checking kitchen status...
[Order #101] βœ“ Toppings added
[Order #101] Packaging...
⏰ [Order #101] Don't forget extra cheese!
βœ… [Order #101] Pizza complete!
 
[Order #103] βœ“ Pasta cooked
[Order #103] Combining pasta and sauce...
[Order #103] βœ“ Combined
[Order #103] Placing...
🧹 [Cleanup] Starting kitchen cleanup...
[Monitor] Checking kitchen status...
βœ… [Order #103] Pasta complete!
 
🧹 [Cleanup] Wiping counters...
🧹 [Cleanup] Done!
 
 
βœ“ All tasks completed!

Notice how output interleaves - that's concurrent execution!

If you want to clone the exact code, poke around the scheduler, or tweak the tasks for your own experiments, I've pushed everything to GitHub as CodeWithKyrian/php-fiber-kitchen-scheduler: https://github.com/CodeWithKyrian/php-fiber-kitchen-scheduler. The repo includes the TaskManager, helper functions, task definitions, and a runnable CLI entry point (php kitchen.php) so you can watch the cooperative multitasking trace in real time.

What Just Happened?

Here's what's happening:

  1. Manager starts all tasks - Pizza, Salad, Pasta, Cleanup all begin executing as fibers
  2. Each task starts sub-tasks - PrepTask, BakeTask, WashTask, etc. start and immediately wait() for time
  3. Parent tasks call wait(task: ...) - They suspend, TaskManager switches to other fibers
  4. Every 10ms, manager checks - Which delays expired? Which tasks completed? Resume those fibers
  5. Fibers resume, execute, suspend again - ProcessPizzaTask resumes when prep done, starts baking, waits again
  6. Timeouts fire independently - "Check temperature!" prints at 3s, doesn't block anything
  7. Intervals fire every 2s - Kitchen monitor runs regardless of task state
  8. Manager stops when all tasks done - Intervals stop too

Total time: ~8s. If run sequentially: ~18s. One process, one thread, cooperative multitasking via fibers.

A Note on Architecture

The approach we took here uses a central orchestrator - the TaskManager - which owns all fibers and decides when to start and resume them. But there's another route you could take: let tasks be orchestrators themselves.

In that model, the main TaskManager would only manage top-level tasks added directly to it. Each task could then manage its own child fibers without involving the manager. The parent task would start its child fiber, and if the child suspends, the parent would also suspend (yielding control back to the TaskManager). When the TaskManager sends a "tick" to resume the parent, the parent checks if its child's conditions are met, and if so, resumes the child.

It's more complex and you'd need careful coordination to prevent parents from blocking indefinitely while waiting for children. The parent needs to suspend cooperatively, check child fiber states on each tick, and resume children at the right moment. But it's a valid approach that gives individual tasks more autonomy.

We won't explore this architecture in detail here as it deserves its own dedicated article. But if you're interested, you could try implementing it yourself. It's a great way to deeply understand fiber orchestration and cooperative multitasking. Or let me know if you'd like to see it covered in a future article!

When Should You Use Fibers?

Now that you've seen Fibers in action, when should you actually use them in your own code?

The Fiber Use Case Checklist

Consider Fibers when:

βœ… You need to pause and resume execution - The core use case. If you need to leave a function, do something else, and come back.

βœ… You want to hide complexity from users - If you're building a library and want a clean API that hides asynchronous or stateful behavior.

βœ… You need cooperative multitasking - When you want multiple "tasks" to make progress without threads or processes.

βœ… You're bridging sync and async code - When you want to make async operations look synchronous (like ReactPHP's await).

βœ… You need to maintain execution context - When pausing and resuming with generators would be too limiting (can't yield from nested calls easily).

βœ… You're building infrastructure code - Libraries, frameworks, and SDKs benefit most from Fibers.

When NOT to Use Fibers

Don't use Fibers when:

❌ Simple callbacks would suffice - Don't overcomplicate things. If a callback works, use a callback.

❌ You need true parallelism - Fibers are cooperative, not parallel. Use processes, threads, or async I/O for parallelism.

❌ The code is simple and linear - If there's no interruption or resumption needed, Fibers add unnecessary complexity.

❌ You're not controlling the execution flow - Fibers shine in libraries and frameworks, less so in application code.

❌ Generators work fine - If generators (yield) solve your problem cleanly, stick with them. Fibers are more powerful but also more complex.

Common Pitfalls and Gotchas

1. Understanding Who Controls the Fiber

The most fundamental thing to understand about fibers: when a fiber suspends, it yields control back to someone. That "someone" is the orchestrator, and you need to know who it is.

A fiber represents a unit of work. When it calls Fiber::suspend(), execution jumps out of the fiber and returns to whoever called $fiber->start() or $fiber->resume(). That entity becomes responsible for deciding when (and if) to resume the fiber.

In our MCP transports:

  • StdioTransport: The main loop (while (!feof($input))) is the orchestrator. It continuously processes input, manages fibers, and flushes output.
  • StreamableHttpTransport: The SSE stream's blocking loop BECOMES the orchestrator for that request's lifetime. It blocks the entire process and manages the fiber until completion.
  • ReactPHP: The event loop is the orchestrator. We don't block it; instead, we register timers that the loop manages.

Key principle: The orchestrator must not be permanently blocked, or your fibers will never resume. If you're writing code that suspends fibers, make sure whoever receives control has a mechanism to resume them.

Also note: Fibers can be nested. You can create fibers inside fibers. The orchestrator can be a central manager (as seen in our TaskManager example), or parent fibers can themselves act as orchestrators for their child fibers. Just be clear about who's managing whom and ensure the orchestrator isn't blocked indefinitely.

2. Forgetting You're in a Fiber

function myHandler() {
$client->sample("Generate text"); // This suspends!
// Any code here runs AFTER the suspension and resumption
}

Remember that suspension can happen deep in the call stack. Always think about what state might change during suspension.

3. Resource Lifetime

$lock = $mutex->acquire();
$client->sample("..."); // Fiber suspends here
$lock->release(); // This runs much later!

Be careful with resources (locks, database transactions, file handles) that span suspension points. The fiber might be suspended for seconds or minutes.

4. Exception Handling Across Suspension

try {
$result = $client->sample("..."); // Suspends
} catch (\Throwable $e) {
// This catches exceptions from WITHIN the fiber
// Not exceptions during the suspension period
// Unless the fiber is resumed with a `throw()`
}

Exceptions work normally within a fiber, but the suspension/resumption mechanism itself has separate error handling. So its vital to understand that exceptions do not automatically cross the boundary between the Fiber and the Orchestrator.

  • If the Fiber throws an exception, it bubbles out to the Orchestrator (via $fiber->start() or $fiber->resume()).
  • If the Orchestrator throws an exception, it does not enter the Fiber automatically (since the exception may or may not be related to the suspended fiber)

You must explicitly decide how to bridge that gap. Do you want the Orchestrator to crash independently? Do you want to catch the error and resume() with a failure object? Or do you want to throw() it into the fiber? These are architectural decisions, not defaults.

5. Global State

global $counter;
$counter++;
$client->log("Count: $counter"); // Suspends
$counter++; // What if another fiber modified $counter during suspension?

Be careful with global state. Other code (or other fibers) might modify it while you're suspended.

6. Fiber Creation Overhead

Creating fibers has a small overhead. Don't create millions of them. They're lightweight compared to threads, but not free.

Conclusion: The Power of Understanding Your Tools

When you learn data structures and algorithms, you don't just memorize definitions and syntax, you also learn when to use them. For example, a doubly linked list isn't useful if you don't recognize when you need fast insertion/deletion at both ends.

The same applies to language features. PHP has had Fibers since 8.1, but most developers don't use them because they don't recognize the problems Fibers solve. So, the next time you face a problem that involves:

  • Pausing and resuming execution
  • Hiding complexity behind a clean API
  • Making async code feel synchronous
  • Cooperative multitasking

Ask yourself: "Could Fibers solve this elegantly?"

You might be surprised at how often the answer is yes.

Final Thoughts

I promised on Twitter that I'd write this article, and I'm glad I did. Not just to fulfill a promise, but because explaining something deeply is the best way to understand it yourself.

The PHP MCP SDK's client communication feature (PR #109) is now one of my favorite pieces of code I've ever written. Not because it's complex (though it is), but because it's elegant. It solves a hard problem in a way that makes the problem disappear for users.

That's the power of choosing the right tool. That's the power of understanding Fibers.

Now go forth and use them wisely. πŸš€

References and Further Reading

Official Documentation

Libraries Using Fibers

The MCP SDK

Related Articles

Advanced Topics


This article was written with ❀️ by Kyrian Obikwelu. If you found it helpful, share it with other PHP developers who might benefit from understanding Fibers better.

Have questions or want to discuss Fibers? Find me on X/Twitter @CodeWithKyrian

0 Comments

No comments yet. Be the first to comment!

Would you like to say something? Please log in to join the discussion.

Login with GitHub