PHP Fibers & Async Programming: Beyond the Request-Response Cycle

· 8 min read

PHP has been synchronous since birth. One request, one thread, top to bottom. PHP 8.1 introduced Fibers — cooperative multitasking primitives that let you pause and resume execution. This isn't threads. It's not parallelism. It's structured concurrency within a single thread.

What Are Fibers?

A Fiber is a block of code that can suspend itself and be resumed later by the caller. Think of it as a pausable function.

$fiber = new Fiber(function (): void {
    $value = Fiber::suspend('Hello');
    echo "Fiber received: $value\n";
});

$result = $fiber->start();      // Starts fiber, runs until suspend
echo "Main got: $result\n";     // "Main got: Hello"
$fiber->resume('World');         // Resumes fiber with value
                                 // Prints: "Fiber received: World"

Execution flow:

Main: start() ──→ Fiber runs ──→ Fiber::suspend('Hello') ──→ returns to Main
Main: echo "Hello"
Main: resume('World') ──→ Fiber continues ──→ $value = 'World' ──→ echo

Why Fibers Matter

The Problem: Blocking I/O

// Traditional PHP — sequential, blocking
$user = Http::get('https://api.example.com/users/1');       // Wait 200ms
$orders = Http::get('https://api.example.com/orders/1');    // Wait 300ms
$recommendations = Http::get('https://api.example.com/rec/1'); // Wait 150ms
// Total: ~650ms (sequential)

The Solution: Concurrent I/O

With Fibers (or libraries built on them), these calls can run concurrently:

// Concurrent — all requests start immediately
// Total: ~300ms (slowest request)

Raw Fiber API

Basic Example: Lazy Generator

function lazyRange(int $start, int $end): Fiber
{
    return new Fiber(function () use ($start, $end): void {
        for ($i = $start; $i <= $end; $i++) {
            Fiber::suspend($i);
        }
    });
}

$fiber = lazyRange(1, 5);

$value = $fiber->start();
while (!$fiber->isTerminated()) {
    echo "$value\n";
    $value = $fiber->resume();
}

Fiber States

$fiber = new Fiber(function (): string {
    Fiber::suspend();
    return 'done';
});

$fiber->isStarted();     // false
$fiber->isRunning();     // false
$fiber->isSuspended();   // false
$fiber->isTerminated();  // false

$fiber->start();
$fiber->isSuspended();   // true

$fiber->resume();
$fiber->isTerminated();  // true
$fiber->getReturn();     // 'done'

Practical Use: Concurrent HTTP Requests

With Laravel's HTTP Client + Pool

Laravel already provides concurrent requests without raw Fibers:

use Illuminate\Http\Client\Pool;
use Illuminate\Support\Facades\Http;

$responses = Http::pool(fn (Pool $pool) => [
    $pool->as('user')->get('https://api.example.com/users/1'),
    $pool->as('orders')->get('https://api.example.com/orders/1'),
    $pool->as('recommendations')->get('https://api.example.com/rec/1'),
]);

$user = $responses['user']->json();
$orders = $responses['orders']->json();
$recommendations = $responses['recommendations']->json();

Under the hood, this uses Guzzle's async/promises (cURL multi). But Fibers enable a cleaner model for libraries to build async APIs.

With AMPHP (Fiber-Based)

AMPHP v3 is built entirely on Fibers:

composer require amphp/http-client amphp/amp
use Amp\Http\Client\HttpClientBuilder;
use Amp\Http\Client\Request;
use function Amp\async;
use function Amp\await;

$client = HttpClientBuilder::buildDefault();

// These run concurrently!
$futures = [
    'user' => async(fn () => $client->request(new Request('https://api.example.com/users/1'))),
    'orders' => async(fn () => $client->request(new Request('https://api.example.com/orders/1'))),
];

// Await all results
$userResponse = $futures['user']->await();
$ordersResponse = $futures['orders']->await();

echo $userResponse->getBody()->buffer();

The magic: async() wraps each call in a Fiber. While one request waits for I/O, the event loop switches to another Fiber. No threads, no processes — just cooperative scheduling.

Building a Simple Async Runner

class AsyncRunner
{
    /** @var Fiber[] */
    private array $fibers = [];

    public function add(callable $task): self
    {
        $this->fibers[] = new Fiber($task);
        return $this;
    }

    public function run(): array
    {
        $results = [];
        $pending = $this->fibers;

        // Start all fibers
        foreach ($pending as $i => $fiber) {
            $fiber->start();
        }

        // Resume suspended fibers until all complete
        while (!empty($pending)) {
            foreach ($pending as $i => $fiber) {
                if ($fiber->isTerminated()) {
                    $results[$i] = $fiber->getReturn();
                    unset($pending[$i]);
                } elseif ($fiber->isSuspended()) {
                    $fiber->resume();
                }
            }
            // Prevent busy-waiting
            if (!empty($pending)) {
                usleep(1000);
            }
        }

        return $results;
    }
}
$runner = new AsyncRunner();

$runner->add(function (): string {
    // Simulate async work
    Fiber::suspend(); // Yield control
    return 'Task 1 complete';
});

$runner->add(function (): string {
    Fiber::suspend();
    return 'Task 2 complete';
});

$results = $runner->run();
// ['Task 1 complete', 'Task 2 complete']

AMPHP — Fiber-Based Framework

AMPHP v3 is an async PHP framework built entirely on Fibers. It provides an event loop that drives Fiber scheduling.

composer require amphp/http-client amphp/amp

Concurrent HTTP Requests

use Amp\Http\Client\HttpClientBuilder;
use Amp\Http\Client\Request;
use function Amp\async;
use function Amp\Future\await;

$client = HttpClientBuilder::buildDefault();

// Each async() wraps a function in a Fiber
$futures = [
    'user' => async(fn () => $client->request(new Request('https://api.example.com/users/1'))),
    'orders' => async(fn () => $client->request(new Request('https://api.example.com/orders/1'))),
    'posts' => async(fn () => $client->request(new Request('https://api.example.com/posts'))),
];

// await() waits for all futures to complete
$responses = await($futures);

$userData = $responses['user']->getBody()->buffer();
$ordersData = $responses['orders']->getBody()->buffer();

How it works internally:

  1. async() creates a Fiber for each HTTP request
  2. When a Fiber calls network I/O, it suspend()s — yielding execution
  3. Event loop detects I/O ready → resume()s the corresponding Fiber
  4. Code inside Fibers looks synchronous but executes concurrently

ReactPHP — Event-Driven Alternative

ReactPHP predates Fibers, using event loop + promises (callback-based). Since v1.x it has a Fiber adapter.

composer require react/http react/event-loop
use React\Http\Browser;
use function React\Async\await;

$browser = new Browser();

// New style: using Fibers for synchronous-looking code
$response = await($browser->get('https://api.example.com/users/1'));
echo $response->getBody();

// Concurrent
$promises = [
    $browser->get('https://api.example.com/users/1'),
    $browser->get('https://api.example.com/orders/1'),
];

$responses = await(React\Promise\all($promises));

AMPHP vs ReactPHP:

Feature AMPHP v3 ReactPHP
Concurrency model Fibers-native Promise-based (Fiber adapter)
API style Sync-looking Promise chains or Fiber adapter
Maturity Newer Older, broader ecosystem
Learning curve Easier (code looks sync) Promise chains harder to debug

Fibers in Laravel Octane

Laravel Octane (with Swoole or FrankenPHP) keeps the application in memory, processing requests via coroutines/fibers — no re-booting per request.

composer require laravel/octane
php artisan octane:install --server=frankenphp

Concurrent Tasks in Octane

use Laravel\Octane\Facades\Octane;

// Run 3 tasks concurrently within the same request
[$users, $orders, $stats] = Octane::concurrently([
    fn () => User::active()->get(),
    fn () => Order::today()->sum('total'),
    fn () => Cache::get('dashboard_stats'),
]);

Octane uses Swoole coroutines (similar to Fibers) to run 3 closures concurrently. When one closure waits for the database, another runs.

Important Caveat with Octane

// ❌ DANGEROUS: Static/global state persists between requests
class BadService
{
    private static array $cache = []; // Keeps values between requests!
}

// ✅ SAFE: Use request-scoped state
class GoodService
{
    public function __construct(
        private array $cache = [],
    ) {}
}

Octane doesn't reboot the app per request → static properties, singletons, global state persist between requests. This is the most common source of bugs when switching to Octane.

Fibers vs Threads vs Processes

Feature Fibers Threads (pthreads) Processes (pcntl_fork)
Concurrency model Cooperative Preemptive Preemptive
Memory sharing Same memory Shared (dangerous) Separate
Overhead ~8KB per fiber ~1MB per thread ~10MB+ per process
I/O optimization Excellent Good Good
CPU parallelism No Yes Yes
Complexity Low High (locks, deadlocks) Moderate
PHP version 8.1+ Deprecated CLI only

Key insight: Fibers are for I/O concurrency — waiting on network, disk, database. For CPU-heavy work (image processing, encryption, ML), use processes/queues — Fibers don't help because they don't create true parallelism.

Error Handling in Async Code

use function Amp\async;
use function Amp\Future\await;

$futures = [
    'users' => async(function () {
        return Http::get('https://api.example.com/users')->throw()->json();
    }),
    'orders' => async(function () {
        return Http::get('https://api.example.com/orders')->throw()->json();
    }),
];

try {
    $results = await($futures);
} catch (\Exception $e) {
    // Exception from ANY future bubbles up here
    Log::error('Async request failed', ['error' => $e->getMessage()]);
}

Timeout handling:

use Amp\TimeoutCancellation;

$response = $client->request(
    new Request('https://slow-api.example.com/data'),
    new TimeoutCancellation(5), // 5 second timeout
);

When to Use Fibers

Directly: Almost Never

Fibers are a low-level primitive for library authors. You shouldn't call new Fiber() in application code — just like you don't use socket() directly when you have HTTP clients.

Indirectly (Through Libraries):

Use Case Solution
Concurrent HTTP calls Http::pool() (Laravel) or AMPHP
Concurrent DB queries Octane concurrently()
Event-driven apps ReactPHP
Real-time server Swoole/FrankenPHP + Octane
Background processing Laravel Queues (still best for most cases)
WebSocket server Ratchet (ReactPHP) or Swoole

Benchmark: Sequential vs Concurrent

// Setup: 5 API calls, each taking ~200ms

// Sequential
$start = microtime(true);
for ($i = 0; $i < 5; $i++) {
    Http::get("https://httpbin.org/delay/0.2");
}
$sequential = microtime(true) - $start; // ~1000ms

// Concurrent
$start = microtime(true);
Http::pool(fn (Pool $pool) => array_map(
    fn ($i) => $pool->get("https://httpbin.org/delay/0.2"),
    range(1, 5),
));
$concurrent = microtime(true) - $start; // ~200ms

// Result: 5x faster for I/O-bound operations

Conclusion

PHP Fibers are a foundation, not a feature you use daily. They enable libraries to provide async APIs with synchronous-looking code — no callback hell, no promise chains.

For most Laravel developers:

  1. Use Http::pool() for concurrent API calls — simplest approach
  2. Use Octane concurrently() for concurrent tasks within a request
  3. Use Laravel Queues for background processing — still the best solution
  4. Understand Fibers when debugging async libraries or reading framework source

Fibers don't replace queues. Queues handle background tasks, retries, scheduling. Fibers handle concurrent I/O within a single request. Different purposes, complementary tools.

The PHP ecosystem is moving toward async — Fibers are the engine making it happen.

Comments