You get a bonus - 1 coin for daily activity. Now you have 1 coin

Ways to create asynchronous responses (deferred processing) in plain PHP or Laravel (backend)

Lecture



Asynchrony on the backend — isn't just “trendy and fast”, it's a way to separate the time it takes to respond to the user from the time it takes to complete the task. In the classic synchronous scenario, the server processes the request completely and only then sends a response. This means the user waits for the entire logic to finish, even if most of it has no effect on the response itself.

Modern backend applications, including Laravel projects, use asynchronous approaches to:

  • speed up API response times;
  • offload the web server;
  • improve scalability;
  • process heavy tasks in the background.

Asynchrony — isn't a single tool, but a set of architectural decisions: queues, background processes, response streaming, events, and deferred actions after the response.

Ways to create asynchronous responses (deferred processing) in plain PHP or Laravel (backend)

What to understand by asynchrony on the backend

It's important to clear up a common misconception right away:
asynchrony on the backend ≠ necessarily parallelism or an event loop like in Node.js.

In practice, this means:

1. Separating the “response” from the “work”

The server can:

  • quickly return an HTTP response (200, 202 Accepted);
  • continue executing the task later or in another process.

Example:

  • the user clicked “create report”;
  • the server immediately responds: “task accepted”;
  • the report is generated in the background.

2. Deferred execution

The task is executed:

  • after the response is sent (for example, terminate() or afterResponse);
  • or in a queue (job/worker).

This is the most common type of asynchrony in PHP/Laravel.

3. Background processing

Tasks are executed outside the HTTP request:

  • separate workers;
  • queues (Redis, DB, etc.).

This is already a genuine asynchronous architecture, because:

  • execution isn't tied to the request lifecycle;
  • there's retry logic, resilience, and scalability.

4. Streaming responses

The response doesn't wait for the entire logic to complete:

  • data is sent in chunks;
  • the user gets progress in real time.

Examples:

  • execution log;
  • file generation;
  • streaming an AI response.

5. Event-driven model

The system reacts to events:

  • “user registered” → send an email;
  • “payment completed” → update the status.

This is already a step toward a more complex architecture (event-driven systems).

Key idea

Asynchrony — isn't about “executing faster”, it's about:

the user doesn't wait for what they don't have to wait for

6 . the queue system

Comparing strategies

Classic queue Priority queue Quorum queue A system with consumer autoscaling Streams
Example - Laravel Queue Example - extra priority column Example – fault-tolerant queues Example – Kafka Consumer Groups, Cloudflare Queues Example – RabbitMQ Streams
Leader on a single node Leader on a single node Leader + replicas No leader, distributed across partitions/workers Replication with its own model
Doesn't scale automatically

Messages are sorted by priority

or importance

Scales via replicas Scales automatically by adding consumers Suitable for large data streams
Fast, but a bottleneck Useful for critical tasks More reliable, but more expensive Flexible and elastic, load is distributed High throughput
FIFO order FIFO with priorities (highest priority processed first) FIFO with replication FIFO or distributed order (depends on the model) Streaming model
No automatic scaling Useful for critical tasks No automatic scaling Automatic consumer scaling Suitable for high loads

In the context of RabbitMQ, the terms «leader» and «node» refer to the cluster architecture and queue organization:

Node
  • A RabbitMQ node — is a separate instance of RabbitMQ, running on a server or container.

  • A cluster can have several nodes, which jointly store metadata (users, virtual hosts, policies).

  • Each node can serve its own queues, but a queue is always «bound» to a specific node.

Leader

  • For each queue a queue leader is elected — this is the node on which the queue is physically stored and which is responsible for processing it.

  • All operations on that queue (writing messages, reads by consumers) go through the leader.

  • If the queue is replicated (for example, a quorum queue), meaning there are several copies on different nodes, there's still a single leader that makes decisions and serves clients.

  • If the leader fails, the cluster can elect a new leader from the replicas so the queue keeps working.

Example

  • You have a cluster of 3 nodes: node1, node2, node3.

  • The orders queue was created on node2. → Queue leader = node2.

  • If the queue is a quorum queue, copies may exist on node1 and node3, but there's still only one leader (e.g., node2).

  • When the queue is overloaded, adding new nodes doesn't help directly — the queue stays on its leader. You need to either increase the number of consumers or use an architecture with multiple queues.

When asynchrony is needed

Use it if you have:

  • long-running operations (imports, reports, API integrations);
  • high load;
  • UX requirements for a “fast response”;
  • tasks that don't affect the immediate response.

When it's not needed

It's not worth adding complexity if:

  • the operation takes milliseconds;
  • there's no load;
  • there's no point in separating the process.

Asynchrony adds:

  • complexity;
  • the need for monitoring;
  • debugging of background processes.

what can actually be done in plain PHP, where the response becomes “asynchronous” for the user, and what the limitations of CGI/FPM are.

In plain PHP, an “asynchronous response” is usually done not with a single method, but with one of several patterns — depending on exactly what's needed: quickly return an HTTP response, continue heavy work after the response, or actually run tasks in parallel.

Main options:

  1. Return the response immediately, then continue the work after it
    This is the most practical option for the web on PHP-FPM: give the client an "accepted" response or a JSON status, then continue executing code on the server. For FastCGI/FPM there's fastcgi_finish_request() for this, after which the response to the user is already complete, while the PHP script can continue executing logic further.

    Example:

    header('Content-Type: application/json; charset=utf-8');
    
    echo json_encode([
    'status' => 'accepted',
    'message' => 'Task started'
    ]);
    
    if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
    }
    
    // After this the user has already received the response,
    // and the server can continue working
    file_put_contents(__DIR__ . '/log.txt', date('c') . " heavy work started\n", FILE_APPEND);
    
    sleep(5); // simulating heavy work
    
    file_put_contents(__DIR__ . '/log.txt', date('c') . " heavy work finished\n", FILE_APPEND);
  2. Send the response and start a background task as a separate process
    This is also a very popular approach. The PHP script quickly responds, and then launches a separate CLI script in the background via exec() / shell_exec() / proc_open(). This is no longer “true asynchrony” within a single process, but delegating the work to another process.

    Example:

    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['status' => 'accepted']);
    
    if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
    }
    
    $php = PHP_BINARY;
    $script = __DIR__ . '/worker.php';
    
    // Linux/macOS
    exec("$php " . escapeshellarg($script) . " > /dev/null 2>&1 &");

    worker.php:

    file_put_contents(__DIR__ . '/log.txt', date('c') . " worker start\n", FILE_APPEND);
    sleep(10);
    file_put_contents(__DIR__ . '/log.txt', date('c') . " worker done\n", FILE_APPEND);
  3. Long polling
    PHP can keep a request open for a while and wait for an event, then return the response. This isn't really asynchrony on the server, but to the client it looks like “the response arrived on its own, later”. Suitable for chats, notifications, and simple live updates.

    Scheme:

    • the client makes a request;
    • PHP checks in a loop whether an event has occurred;
    • if it has — it responds immediately;
    • if not — it returns an empty response on timeout.

    Downsides:

    • keeps workers busy;
    • scales poorly with a large number of clients.
  4. Streaming / chunked response
    PHP can send data in chunks via echo + flush(). This is handy when you don't want to wait for the work to finish, but instead want to gradually send progress. But keep in mind that the actual delivery depends on buffering in PHP, the web server, and FastCGI. The PHP documentation notes that handling a broken connection and sending data depends on the actual write to the connection.

    Simplified example:

    header('Content-Type: text/plain; charset=utf-8');
    header('Cache-Control: no-cache');
    
    echo "Start\n";
    flush();
    
    for ($i = 1; $i <= 5; $i++) {
    sleep(1);
    echo "Step $i\n";
    flush();
    }
    
    echo "Done\n";
    flush();
  5. Ignore the client disconnecting and finish the work within the same request
    If it's important that the task runs to completion even if the user closed the tab, you can use ignore_user_abort(true). By default, PHP usually terminates the script when the client disconnects, but this behavior can be changed. You can also track the connection state via connection_aborted() and connection_status().

    Example:

    ignore_user_abort(true);
    set_time_limit(0);
    
    echo "Accepted";
    flush();
    
    // Continue the task even if the client has left
    sleep(15);
    file_put_contents(__DIR__ . '/log.txt', "finished\n", FILE_APPEND);
  6. Forking a process with pcntl_fork()
    This is already closer to real parallelism: the parent PHP process can spin off a child process. But this works in Unix/Linux CLI scenarios and is usually not a good idea in a regular web request under FPM/Apache. The pcntl_fork() function officially exists in the Process Control extension.

    Idea:

    $pid = pcntl_fork();
    
    if ($pid === -1) {
    die('fork failed');
    }
    
    if ($pid) {
    echo "Parent: response to user";
    } else {
    sleep(10);
    file_put_contents(__DIR__ . '/log.txt', "child finished\n", FILE_APPEND);
    exit;
    }
  7. A file/DB queue + a periodic PHP CLI worker
    If you need “plain PHP”, but already at a mature level, this is how it's done:

    • the HTTP request puts a task into the DB/file;
    • it immediately returns a task_id to the user;
    • a separate PHP CLI worker periodically picks up tasks and executes them;
    • the client asks /status?id=....

    This is the most reliable option without third-party brokers.

8. WebSocket — is another important way of achieving “asynchrony” on the backend; WebSocket — is a persistent two-way connection between client and server.

Unlike HTTP:

  • HTTP → request → response → connection closed
  • WebSocket → the connection stays open continuously → the server and client can send data at any moment
$host = '0.0.0.0';
$port = 8080;

$server = stream_socket_server("tcp://{$host}:{$port}", $errno, $errstr);

stream_set_blocking($server, false);

$clients = [];
$handshaked = [];
while (true) {
  $read = $clients;
  $read[] = $server;
  // accept new clients
  // read incoming messages
  // send messages to the right clients
}

What to choose in practice

If you need a simple, working scheme:

  • respond quickly and finish up later → fastcgi_finish_request()
  • a long background task → a separate CLI process via exec()
  • checking execution status → a task in the DB + polling /status
  • relaying progress in real time → streaming via flush()
  • real parallelism → pcntl_fork() only for CLI/Linux scenarios

Comparison with other approaches when using plain PHP

Approach Type of asynchrony When to use
Queue (Laravel) background heavy tasks
terminate() after the response logging, metrics
afterResponse() micro-tasks short actions
Streaming gradual response progress
WebSocket real-time push chat, live data

An important limitation

In regular PHP under a web server, “asynchrony” is usually not the same as in the Node.js event loop. Usually it's:

  • either an early response + continued work,
  • or launching a separate process,
  • or a queue + worker.

In other words, for “plain PHP” the most sensible path is usually this:
HTTP → save the task → respond immediately → the worker does the work → the client asks for status.

What this looks like in Laravel (in this context)

  • Queue / Job → true asynchrony
  • dispatchAfterResponse() → short actions after the response
  • middleware terminate() → infrastructure-level post-processing
  • stream() / eventStream() → gradual delivery of the response
In the Laravel framework, an “asynchronous response” is usually done not with a single method, but through several built-in approaches — depending on the goal: quickly return an HTTP response, run code right after the response is sent, offload work to a queue, or stream data to the user as it becomes ready. Laravel officially supports all these scenarios.

Main approaches.

1. Queues (Queue / Job) — the main and correct approach
Laravel recommends offloading heavy tasks to queues: sending email, processing CSVs, generating reports, imports, integrations, and other long-running operations. The idea is simple: the HTTP request quickly returns a response, while the job is processed in the background by a worker process. Laravel supports several queue drivers, including Redis, SQS, and database.

Example:

// Controller
public function store(Request $request)
{
ProcessOrderJob::dispatch($request->all());

return response()->json([
'status' => 'accepted',
'message' => 'Order queued',
], 202);
}
use Illuminate\Contracts\Queue\ShouldQueue;

class ProcessOrderJob implements ShouldQueue
{
public function __construct(public array $payload) {}

public function handle(): void
{
// heavy logic
}
}

This is the best option when the task can run independently of the request.

2. dispatchAfterResponse() — run a job after the response has been sent to the browser
Laravel can defer dispatching a job until after the response has already been sent to the user. The documentation describes this as an approach for short tasks, usually around a second, such as sending a letter. It's not a full-fledged queue in the architectural sense, but rather “do it right after the response, in the current process”.

Example:

public function register()
{
SendWelcomeEmail::dispatchAfterResponse();

return response()->json([
'status' => 'ok',
'message' => 'User created',
]);
}

Or a closure:

dispatch(function () {
\Log::info('Executed after response');
})->afterResponse();

return response()->json(['status' => 'ok']);

Suitable for very short post-actions. For heavy processing, a regular queue is better.

3. dispatchSync() / sync driver — not asynchronous, but the opposite: synchronous
It's important not to confuse this: Laravel has dispatchSync(), but it executes the task in the current process, not in the background. For queueable jobs this goes through the sync queue. So on the surface it's a job, but there's no real asynchrony here.

Example:

ProcessOrderJob::dispatchSync($data);

This is useful for a uniform API, but not for speeding up the response.

4. Batch and chain — complex background scenarios
Laravel allows you to run not just individual jobs, but also chains and batches. This is handy when the asynchronous processing consists of several stages: import → process → notify. A batch can also be dispatched after the response.

Example idea:

Bus::chain([
new PrepareImportJob($fileId),
new ParseImportJob($fileId),
new NotifyUserJob($userId),
])->dispatch();

This is already an “asynchronous pipeline”.

5. Streamed response — deliver the response in chunks
Laravel can build a streamed response via response()->stream(...). This is a different type of “asynchrony”: the user doesn't wait for the entire response to be assembled, but receives it in parts. Suitable for progress, large texts, logs, long exports, and generating large files.

Example:

public function stream()
{
return response()->stream(function () {
echo "Start\n";
ob_flush();
flush();

for ($i = 1; $i <= 5; $i++) {
sleep(1);
echo "Step {$i}\n";
ob_flush();
flush();
}

echo "Done\n";
ob_flush();
flush();
}, 200, [
'Content-Type' => 'text/plain',
'Cache-Control' => 'no-cache',
]);
}

This isn't a queue, but exactly a streamed delivery of the response.

6. SSE / eventStream() — the server sends events as they appear
Laravel supports Server-Sent Events via response()->eventStream(...). This is already a convenient built-in way to make “live” responses: progress, LLM tokens, notifications, task status. SSE works as an HTTP stream with text/event-stream.

Example:

public function events()
{
return response()->eventStream(function () {
yield "Step 1";
sleep(1);

yield "Step 2";
sleep(1);

yield "Finished";
});
}

This is a good option if you specifically need “the response arrives gradually, on its own”.

7. streamJson() — streamed JSON delivery
Laravel also supports a streamed JSON response. This is useful when you need to gradually deliver a large JSON without fully assembling it in memory.

8. streamDownload() — asynchronous for the user in the sense of a staged download
If the task involves an export, Laravel can do a streamed file download. This isn't a background queue, but it lets the user start receiving the file right away instead of waiting for it to be fully generated.

9 terminable middleware

Laravel has terminable middleware: middleware with a terminate() method that is called after the HTTP response has already been sent to the client. This is documented in Laravel, and the mechanism is based on calling Kernel::terminate($request, $response) after the response's send(); a similar mechanism exists in Symfony via the kernel.terminate event.

When to use this in the same context

terminate() is suitable when you need to:

  • not delay the response to the user;
  • perform a short post-action right after the response;
  • avoid spinning up a separate queue worker for something trivial.

Typical use cases:

  • append to a log;
  • send a metric;
  • save an audit trail;
  • finalize a session;
  • a lightweight notification or webhook, if it's genuinely fast.

Laravel directly gives an example of middleware doing work after the response is sent, and the built-in session middleware saves the session precisely after the response.

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class LogAfterResponseMiddleware
{
public function handle(Request $request, Closure $next): Response
{
// regular logic before/during the request
return $next($request);
}

public function terminate(Request $request, Response $response): void
{
\Log::info('Response already sent', [
'path' => $request->path(),
'status' => $response->getStatusCode(),
]);
}
}

The middleware is then attached as regular middleware — globally or on a route. Historically, the Laravel documentation states that terminable middleware needs to be added to the HTTP kernel, and the fact that it's “terminable” is determined simply by the presence of the terminate method.

How terminate() middleware differs from dispatchAfterResponse()

They are similar in meaning: in both cases the work happens after the response is sent. But the role is different:

  • terminate() middleware — is an infrastructure layer of the request/response; good for logging, metrics, sessions, cross-cutting logic.
  • dispatchAfterResponse() — is more of an application-level action at the job/business-operation level. Laravel separately recommends this approach for short tasks, usually on the order of a second, while for heavy operations — use a queue.

In practice:

  • need to “quietly do something after every request” → terminate();
  • need to “finish a mini-task after a specific controller action” → afterResponse;
  • need something heavy and reliable → queue/job worker. Laravel queues are made specifically for deferred processing of long tasks.

What happens under the hood of terminate() middleware

Simplified, the chain looks like this:

  1. Laravel processes the request.
  2. A Response object is formed.
  3. The response is sent to the client.
  4. After that, Kernel::terminate($request, $response) is called.
  5. The kernel iterates over the middleware and calls terminate() on those that have this method.

This is visible both in the Laravel API (Illuminate\Foundation\Http\Kernel::terminate and terminateMiddleware) and in the Symfony docs: first $response->send(), then $kernel->terminate(...), which triggers the late post-response actions.

In other words, terminate():

  • is not part of the main response;
  • doesn't change the already-sent body/status/headers;
  • runs already “after”.

An important nuance: it's not the same middleware object

Laravel separately notes that when terminate() middleware is called, it's usually resolved anew from the container, meaning it can be a new instance, not the one used in handle(). If you need the same instance for handle() and terminate(), the middleware must be registered as a singleton.

This is a very important point. For example, you shouldn't rely on this:

class BadMiddleware
{
private $startedAt;

public function handle($request, Closure $next)
{
$this->startedAt = microtime(true);
return $next($request);
}

public function terminate($request, $response)
{
// $this->startedAt may not be preserved,
// because terminate may be called on a new instance
}
}

More reliable:

  • either recompute what you need inside terminate(),
  • or store data in request->attributes,
  • or register the middleware as a singleton, if it's genuinely needed.

When terminate() is a bad idea

Don't put the following there:

  • heavy imports;
  • processing large files;
  • lengthy integration with external APIs;
  • critical tasks that need retry and reliability.

Because terminate() — is still part of the lifecycle of the same PHP request process, just after the response has been sent. For genuinely long tasks, Laravel queues are better suited, because they're specifically built for deferred processing.

A good example in this context

1. Via terminate() — logging and metrics

class ApiTimingMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$request->attributes->set('started_at', microtime(true));

return $next($request);
}

public function terminate(Request $request, Response $response): void
{
$startedAt = $request->attributes->get('started_at');
$durationMs = $startedAt
? round((microtime(true) - $startedAt) * 1000, 2)
: null;

\Log::info('api_request_finished', [
'path' => $request->path(),
'status' => $response->getStatusCode(),
'duration_ms' => $durationMs,
]);
}
}

2. Via queue — heavy work

public function store(Request $request)
{
ProcessImportJob::dispatch($request->all());

return response()->json([
'status' => 'accepted'
], 202);
}

3. Via dispatchAfterResponse() — a small post-action

public function submit()
{
dispatch(function () {
\Log::info('Light post-response action');
})->afterResponse();

return response()->json(['ok' => true]);
}

Simple classification

  • Background processing → Queue / Job / Worker.
  • After the response, but still within the same application → dispatchAfterResponse().
  • Gradual delivery of the response → stream().
  • Event stream to the client → eventStream() / SSE.
  • Large JSON in parts → streamJson().
  • Large file in parts → streamDownload().

What to choose when using Laravel

  • If the task is heavy and can live separately from the request — a regular queue + worker. This is the main production approach in Laravel.
  • If the action is very short and you just need to avoid delaying the response — dispatchAfterResponse(). Laravel itself notes that this is usually for short tasks, like sending an email.
  • If you need to show progress or deliver data gradually — stream() or eventStream(). For real-time events over HTTP, SSE via eventStream() is better.
  • terminate() → middleware-level post-logic: logging, metrics, sessions, audit.
  • afterResponse() → a short application-level action after the response.
  • Queue / Job → heavy, reliable, repeatable background processing.
  • stream() / eventStream() → when the response needs to be sent in parts, rather than just “finished up later”. This is a separate scenario of streamed responses.

Conclusion

Asynchrony on the backend — is an architectural tool that lets you separate the user experience from internal data processing. In PHP and Laravel it's implemented not through a single mechanism, but through a combination of approaches: queues, post-processing after the response, streaming, and events.

Proper use of asynchrony makes it possible to:

  • speed up the API;
  • improve the resilience of the system;
  • scale task processing.

But the main thing — is to understand that asynchrony — is not a goal, but a means.
It should be applied where it genuinely solves a problem, not just “because it's possible”.

Comments

To leave a comment

If you have any suggestion, idea, thanks or comment, feel free to write. We really value feedback and are glad to hear your opinion.
To reply

Lectures and tutorial on "Running server side scripts using PHP as an example (LAMP)"

Terms: Running server side scripts using PHP as an example (LAMP)