Skip to content

<div style="display: none;" hidden="true" aria-hidden="true">Are you an LLM? You can read better optimized documentation at /guides/DeferredTask.md for this page in Markdown format</div>

Deferred Task Runner

Overview

The deferred task runner executes fire-and-forget work after the HTTP response has been sent to the client. By calling fastcgi_finish_request() before running queued tasks, the runner ensures that tracking calls, analytics events, and CRM syncs never add latency to the user-facing response (TTFB).

        [Request]


    [Controller logic]
     tasks queued via defer()


  ┌─── post_system hook ───────────────┐
  │                                    │
  │   fastcgi_finish_request()         │
  │   response sent to client          │
  │         │                          │
  │         ▼                          │
  │   [Deferred tasks run]             │
  │   priority-ordered, time-budgeted  │
  │                                    │
  └── deferred execution context ──────┘

Tasks are executed in priority order within a configurable time budget. If the budget runs out, remaining tasks are skipped and logged.

Public API

php
use Advisable\DeferredTask\DeferredTaskRunner;

/** @var DeferredTaskRunner $runner */
$runner = di()->get('deferred_task');

// Queue a task
$runner->defer(
    task: fn() => $service->sendEvent($payload),
    maxCostSeconds: 3,
    priority: DeferredTaskRunner::PRIORITY_NORMAL,
    name: 'meta.view_content',
);

// Check if anything is queued
$runner->hasTasks(); // bool

run() is called automatically by the post_system hook — you never call it manually.

Priority Constants

ConstantValueUse for
PRIORITY_CRITICAL100Revenue-affecting events (purchases, checkout)
PRIORITY_NORMAL50Standard tracking (page views, add-to-cart, bookmarks)
PRIORITY_LOW10Best-effort analytics (Matomo bulk flush)

Higher-priority tasks execute first. If the budget is tight, low-priority tasks are the first to be skipped.

Parameters

ParameterTypeDescription
task\ClosureThe work to execute. Must be a zero-argument closure.
maxCostSecondsintEstimated worst-case duration. Tasks are skipped if this exceeds remaining budget.
priorityintExecution order (higher = sooner). Defaults to PRIORITY_NORMAL.
namestringIdentifier for logging. Convention: service.action (e.g., meta.purchase).

How It Works

Execution Flow

  1. During the request, controllers queue tasks via defer().
  2. The controller finishes and CodeIgniter sends the response.
  3. The post_system hook fires and calls $runner->run().
  4. The runner closes the PHP session (if active) and flushes the response to the client via the SAPI-specific finish function (see SAPI Support below).
  5. The PHP time limit is adjusted (see Time Limit Behavior below).
  6. Tasks are extracted from the priority queue in descending priority order.
  7. Before each task, the runner checks whether maxCostSeconds fits within the remaining budget. If not, the task is skipped.
  8. Each task runs inside a try/catch — a failure in one task does not prevent others from executing.
  9. Skipped task names are logged at notice level.

Budget Enforcement

Budget: 10s
┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐
│ critical │  │ normal   │  │ normal   │  │ low      │
│ cost: 3s │→ │ cost: 3s │→ │ cost: 5s │→ │ cost: 2s │
│ actual:2s│  │ actual:1s│  │ SKIPPED  │  │ actual:1s│
└──────────┘  └──────────┘  │(5 > 7rem)│  └──────────┘
                             └──────────┘
Remaining: 10 → 8 → 7 → 7 (skip) → 6

The budget is tracked by measuring actual elapsed time (not maxCostSeconds), so a task that finishes faster than declared frees up room for subsequent tasks. However, the skip decision uses maxCostSeconds to avoid starting a task that could overrun.

Time Limit Behavior

The runner treats PHP's max_execution_time (from php.ini) as a hard ceiling and uses the configured budget to impose a tighter constraint below it when possible:

max_execution_timetimeBudgetSecondsBehavior
3010set_time_limit(10) — tighten below ini limit
3040No call — ini limit (30s) is already tighter
0 (unlimited)10set_time_limit(10) — impose budget as only constraint
300 (unlimited)No call — ini limit governs, budget enforcement off
00No call — fully unconstrained

The runner uses wall-clock elapsed time (REQUEST_TIME_FLOAT) to estimate how much of the ini limit has been consumed. Wall-clock overestimates CPU time (which max_execution_time tracks on Linux), so the estimate errs on the conservative side.

Unlimited Budget

Setting APP_DEFERRED_TASK_BUDGET_SECONDS=0 disables budget enforcement — all tasks run regardless of their declared cost. The PHP ini time limit is left untouched. Tasks still execute in priority order and exceptions are still isolated.

Disabled Mode

When APP_DEFERRED_TASK_ENABLED=false, the runner executes all tasks inline (before the response is sent) with no budget enforcement. This is the legacy behavior — useful for debugging.

Tasks still execute in priority order and exceptions are still isolated.

SAPI Support

The runner detects the available SAPI at runtime and uses the appropriate function to flush the response and detach from the request lifecycle:

SAPIFunctionBehavior
PHP-FPMfastcgi_finish_request()Response sent, PHP process continues independently
LiteSpeedlitespeed_finish_request()Same semantics as PHP-FPM
Other (mod_php, CLI)No-opTasks still run but block the response

The session is always closed (session_write_close()) before flushing, regardless of SAPI.

Configuration

Environment Variables

bash
# Enable/disable post-response deferral (default: true)
APP_DEFERRED_TASK_ENABLED=true

# Maximum seconds for all deferred tasks combined (default: 10, 0 = unlimited)
APP_DEFERRED_TASK_BUDGET_SECONDS=10

# Logging
DEFERRED_TASK_LOG_THRESHOLD=WARNING       # Monolog threshold
#DEFERRED_TASK_LOG_MAX_FILES=15           # Log rotation (default: 15)

Config file: application/config/deferred_tasks.php. All values have sensible defaults — no env vars are required.

Container Registration

application/config/container/deferred_task.php registers the runner as a public DI service:

php
di()->get('deferred_task');  // DeferredTaskRunner instance

The runner is wired with the configured budget, enabled flag, and an optional Monolog logger (channel: DEFERRED_TASK, log file: application/logs/deferred_task_log.php).

Hook Registration

application/config/hooks.php:

php
$hook['post_system'] = function () {
    if (function_exists('di') && di()->has('deferred_task')) {
        di()->get('deferred_task')->run();
    }
};

The guard clause ensures the hook is safe during CLI execution or if the DI container is not initialized.

Protected Services

All integrations follow the same defer pattern — queue the call in the controller, let the runner execute it after the response:

ServiceTask NamesTypical PriorityWhat it defers
Meta Conversions APImeta.add_to_cart, meta.purchase, meta.initiate_checkout, meta.view_content, meta.search, meta.complete_registrationCRITICAL (purchase, checkout) / NORMAL (others)Facebook server-side pixel events
Advisable AIadvisable_ai.bookmark, advisable_ai.remove_bookmark, advisable_ai.rating, advisable_ai.purchase, advisable_ai.view, advisable_ai.add_to_cart, advisable_ai.remove_from_cart, advisable_ai.session_switchCRITICAL (purchase) / NORMAL (others)Recommendation engine user signals
Manago CRMmanago.purchaseCRITICALCRM purchase event sync
Matomo Analyticsmatomo.flushLOWBulk analytics tracking flush

Integration Pattern

php
// In a controller action
$fb = $this->facebookConversionService;

di()->get('deferred_task')->defer(
    task: fn() => $fb->dispatchEvent(EventType::ADD_TO_CART, $customData, $userData),
    maxCostSeconds: $fb->getMaxCostSeconds(),
    priority: DeferredTaskRunner::PRIORITY_NORMAL,
    name: 'meta.add_to_cart',
);

Key locations where tasks are deferred:

ControllerModuleEvents deferred
Adv_front_controllercoreAI signals (bookmark, rating, view, purchase, session), Matomo flush
AdvApiCartControllerapiMeta add-to-cart, AI add/remove cart
Adv_checkoutcheckoutMeta purchase, Manago purchase, Matomo
Adv_ordereshopMeta initiate-checkout
Adv_productseshopMeta view-content
Adv_vendorseshopMeta view-content
Adv_searchsearchMeta search
Adv_customereshopMeta complete-registration, Matomo

Relationship with Circuit Breaker

The deferred task runner and the circuit breaker solve complementary problems:

ConcernSolution
Latency isolation — tracking calls must not slow down TTFBDeferred Task Runner (post-response execution)
Failure isolation — a down service must not waste time or flood logsCircuit Breaker (fail fast when service is known-down)

Inside each deferred task, the service checks $circuitBreaker->isOpen() before making the HTTP call. If the circuit is open, the task returns immediately without making a network request — saving time within the deferred budget for other tasks.

[Controller] → defer(task) → [Response sent] → [Runner executes task]

                                                 CB open? → return (fail fast)

                                                 CB closed → HTTP call

                                              success → recordSuccess()
                                              failure → recordFailure() → may trip CB

Testing

Unit tests: tests/Unit/DeferredTask/DeferredTaskRunnerTest.php

bash
vendor/bin/phpunit tests/Unit/DeferredTask

The test suite covers priority ordering, budget enforcement (skip over-budget tasks, drain remaining when exhausted), exception isolation, disabled/inline mode, and hasTasks() state tracking.

File Locations

FilePurpose
src/DeferredTask/DeferredTask.phpTask value object (closure, cost, priority, name)
src/DeferredTask/DeferredTaskRunner.phpCore runner — queue, budget, execution
application/config/deferred_tasks.phpEnabled flag and budget config
application/config/container/deferred_task.phpDI service registration
application/config/hooks.phppost_system hook that triggers run()
application/config/monolog.phpLogger channel config (deferred-task)
tests/Unit/DeferredTask/DeferredTaskRunnerTest.phpUnit tests