Skip to content

Deferred Tasks

defer() runs a piece of work after the response has been delivered. The visitor gets their page as soon as your controller returns; sending the email, calling the slow API or rebuilding the cache happens afterwards, without making anyone wait.

public function register(Request $request): Response
{
    $user = $this->users->create($request);

    // Runs after the response is sent. The visitor does not wait for it.
    defer(function () use ($user): void {
        $this->welcomeEmail->sendTo($user);
    });

    return $this->view('auth/registered.twig');
}

Using It

There are two equivalent ways in. Use whichever fits the code you are writing.

use Rhapsody\Core\Contracts\DeferrerInterface;

// 1. The global helper, available everywhere once the app has booted
defer(fn () => $this->rebuildSitemap());

// 2. Inject the service (easier to test and to swap)
public function __construct(private DeferrerInterface $deferrer) {}

$this->deferrer->defer(fn () => $this->rebuildSitemap());

Methods

Method What it does
defer(callable $task)Queues a task. It is called with no arguments, so capture what it needs with use (...).
pending()How many tasks are waiting to run.
discard()Drops every waiting task without running it.
flush()Finishes the response and runs the waiting tasks. The framework calls this for you; you should not need to.

How It Runs

The framework detects the environment and picks the best behaviour automatically.

Environment What happens
PHP-FPM or LiteSpeedThe response is flushed and the connection is closed, then tasks run. The visitor never waits.
Apache mod_php, the built-in serverThe response is fully flushed, then tasks run. The page is complete in the browser, but the connection stays open until the tasks finish.
RoadRunner workerTasks run right after the response is handed to RoadRunner. That worker is busy until they finish.
php rhapsody commands and other CLI scriptsThere is no response to wait for, so the task runs immediately when you call defer().

Good to Know

  • Tasks cannot change the response. By the time they run, the headers and body are gone. Anything a task prints is discarded.
  • The session is already closed. The session lock is released before tasks run so the visitor's next request is not blocked. Writing to the session inside a task will not persist.
  • Failures are logged, never shown. A task that throws is written to storage/logs/defer.log and the remaining tasks still run.
  • A failed request runs nothing. If the request ends in an exception (including a 404 or 500 page), its queued tasks are discarded.
  • Tasks run in the order you queued them, and a task may call defer() to queue more. A single request runs at most 1000 tasks.
  • PHP time limits still apply. Under PHP-FPM, max_execution_time and request_terminate_timeout apply to deferred work too. Closing the connection does not remove them.
  • Nothing is saved. If the server stops while a task is waiting, the task is lost. For work that must survive that (payments, important emails), use a persistent queue instead.
  • Resolve it by its interface. Ask the container for DeferrerInterface::class, not the concrete class.
  • Boot-time code: the defer() helper is available once the application has finished booting. A module's boot() runs earlier, so resolve DeferrerInterface from the container there instead.
Tip: keep deferred tasks small and self-contained. Capture plain values (an id, an email address) rather than whole request objects, so the task does not depend on request state that no longer matters.

Upgrading an Existing App

New apps from the skeleton have this wired up. If you are updating an existing project, add one line after the response is sent in each entry point:

// index.php
$response->send();
$kernel->afterResponse();
// worker.php, inside the while() loop, after the try/catch that responds
$kernel->afterResponse();

Apps that do not call defer() pay essentially nothing for this: afterResponse() returns immediately when nothing was queued.


Related Documentation