Background work

PHP is single-threaded. Pionia does not spawn OS threads. defer() and closure async() run work after the HTTP response is sent so the client is not blocked — but the closure still executes in the same PHP worker until it finishes.

Quick choice

You want…Use
Fire-and-forget after the client gets JSONdefer(function () { … })
Same, plus promises (.then(), await())async(function () { … })
Durable email, reports, heavy jobsasync('service', 'action', $payload) + RoadRunner Jobs
HTTP 202 + job_id in the API responsemoonlight()->async(...)

Do not use async(closure) expecting a new thread. For post-response logging or webhooks, use defer(). Long sleep() or CPU-heavy closures still block that worker — use Moonlight jobs instead.

defer() — recommended for post-response work

defer(function () use ($user) {
    logger()->info('Welcome email queued', ['id' => $user->id]);
});

return response(0, 'OK', $rows);
  • Closure is queued during the action and runs after fly() sends the response (php pionia serve, FPM) or after RoadRunner respond().
  • Requires composer require react/promise in your application.
  • On the built-in dev server, Pionia sets Connection: close and flushes output so curl returns before deferred work runs.

async() — promises and Moonlight jobs

Closure form (same timing as defer)

(void) async(function () use ($user) {
    logger()->info('Post-response via promise API');
});

On PHP 8.5+, async() is #[NoDiscard] — use the return value or (void) async(...). Prefer defer() when you do not need a promise.

Moonlight job form

async('mail', 'send_welcome', ['email' => $user->email]);
// or API-style 202:
moonlight()->async('mail', 'send_welcome', ['email' => $user->email]);
ContextBehaviour
RoadRunner + [jobs] ENABLEDJob queued on worker pool
php pionia serve / FPM without jobsRuns after response, synchronously in same process
Tests (PIONIA_TESTING)Sync unless PIONIA_JOBS_QUEUE=1

Enable jobs in environment/settings.ini:

[jobs]
ENABLED = true
PIPELINE = moonlight
RPC = tcp://127.0.0.1:6001

See RoadRunner for .rr.yaml rpc + jobs sections.

Execution order (closure defer)

1. Action body runs (including defer() call — closure NOT executed yet)
2. return response(...) builds JSON
3. Response sent to client
4. Deferred closure(s) run
5. Script / worker ready for next request

Log order for:

logger()->info('A — before return');
defer(fn () => logger()->info('C — after response'));
logger()->info('B — still before return');
return response(0, 'OK', $data);

A, B, then client receives JSON, then C.

await() and promises

$result = await(async(fn () => expensive_local_work()));

await() triggers the deferred buffer when waiting on a pending closure promise.