> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentcat.com/llms.txt
> Use this file to discover all available pages before exploring further.

# PHP runtimes

> When the PHP SDK runs your hooks and sends its events on PHP-FPM, FrankenPHP, Apache, NGINX Unit, the CLI, containers, Swoole, RoadRunner, Workerman and hardened hosts.

AgentCat runs your hooks and sends the events after the client has its answer, wherever the runtime allows it. This page says how each PHP runtime does it, and what you must do, if anything. There are two exceptions: on Apache mod\_php, php-cgi and `php -S`, a client whose answer has no `Content-Length` waits for the delivery (see the Apache section), and a stdio server delivers the events of a call when the next call arrives, so the client of that next call waits for it (see the CLI section).

## Overview

| Runtime | When AgentCat runs the hooks and sends the events | What you do |
| - | - | - |
| PHP-FPM | At the end of the script, after `fastcgi_finish_request()` has sent the response | Nothing |
| NGINX Unit | Like PHP-FPM | Nothing |
| FrankenPHP, classic mode | At the end of the script, after `frankenphp_finish_request()` | Nothing |
| FrankenPHP, worker mode | Laravel Octane and Symfony: after each request. A raw worker loop: when you call `AgentCat::flush()` | A raw loop calls `AgentCat::flush(1.0)` after each request |
| Apache mod\_php, php-cgi, `php -S` | At the end of the script. The client has its answer first when the answer has a `Content-Length` | A plain `mcp/sdk` script sets `Content-Length`. The Laravel and Symfony bridges set it for you |
| The CLI (stdio servers) | When the next `tools/call` or `tools/list` request arrives, and when the input ends | Install ext-pcntl; in a container, use `docker run --init` |
| Swoole, OpenSwoole, RoadRunner, Workerman | Laravel Octane and Symfony Runtime: after each request. A raw loop: when you call `AgentCat::flush()` | A raw loop calls `AgentCat::flush(1.0)` after each request |

AgentCat needs ext-curl, ext-json and ext-mbstring. ext-pcntl is optional and recommended for CLI servers.

## The 5-second budget

When a script or a process ends, AgentCat runs the hooks that still wait and sends the waiting events, for at most 5 seconds in all. Events that are still waiting when the time is up are lost, and AgentCat logs `Shutting down with <n> events still in queue`.

AgentCat cannot stop a hook that runs: synchronous PHP cannot interrupt it. A slow hook, or a hook that waits for a slow service, therefore makes the end of a request or a process last longer than 5 seconds. Keep hooks fast.

## PHP-FPM

`fastcgi_finish_request()` sends the response to the client. Then, in its shutdown function, AgentCat runs the hooks and sends the events. The FPM worker stays busy during that time, up to the 5-second budget: count it when you size the pool.

* **The session lock.** On every web runtime, before it delivers, AgentCat writes and closes an active PHP session (`session_write_close()`), so the next request of the same client does not wait for the lock. A destructor that writes to `$_SESSION` after that loses its write.
* **The time limit.** On every web runtime, when `max_execution_time` is set, AgentCat gives its delivery its own limit with `set_time_limit(6)`, so a request that used most of its time does not end the delivery with a fatal error. That call can also shorten a longer limit for code that runs after AgentCat's shutdown function.
* **`request_terminate_timeout`.** This limit of the FPM pool counts wall-clock time, and PHP code cannot change it. When it is shorter than your slowest request plus the delivery, FPM kills the worker during the delivery, and the events of that request are lost. Leave at least 6 seconds plus the time your hooks take over your slowest request.
* **Diagnostics.** AgentCat sends its diagnostics after the response, within the budget. Each diagnostics request has a 250 ms connect timeout and a 1-second total timeout, so a collector that does not answer holds the worker only briefly.
* **A host that disables `fastcgi_finish_request()`** gets the behavior of Apache mod\_php (below): the response goes out at the end of the script.

## FrankenPHP

**Classic mode** works like PHP-FPM, with `frankenphp_finish_request()`.

**Worker mode** keeps the script alive between requests, so shutdown functions do not run after each request. Laravel Octane and Symfony Runtime need nothing: AgentCat delivers in their terminate hooks and waits at most 1 second after each request. A raw `frankenphp_handle_request()` loop must call `AgentCat::flush()` after each request:

```php theme={null}
<?php

declare(strict_types=1);

use AgentCat\AgentCat;
use Http\Discovery\Psr17Factory;
use Mcp\Server;
use Mcp\Server\Transport\StreamableHttpTransport;

require __DIR__ . '/vendor/autoload.php';

$handler = static function (): void {
    $builder = Server::builder()
        ->setServerInfo('worker-mcp', '1.0.0')
        ->addTool(static fn(string $msg): string => $msg, 'echo', description: 'Echo a message');
    AgentCat::track($builder, 'proj_YOUR_PROJECT_ID');

    $factory = new Psr17Factory();
    $response = $builder->build()->run(new StreamableHttpTransport($factory->createServerRequestFromGlobals(), $factory, $factory));
    http_response_code($response->getStatusCode());
    foreach ($response->getHeaders() as $name => $values) {
        header($name . ': ' . implode(', ', $values));
    }
    echo $response->getBody();
};

while (frankenphp_handle_request($handler)) {
    AgentCat::flush(1.0); // after each response: deliver this request's events, waiting at most 1 second
}
```

Copy the `while` loop exactly: the `AgentCat::flush(1.0)` call after `frankenphp_handle_request()` is what delivers each request's events. Events that are still waiting after the 1 second go out after a later request, or when the worker stops.

* **Worker detection.** AgentCat detects worker mode from the `FRANKENPHP_WORKER` environment variable that FrankenPHP sets. Inside a request, `$_SERVER` does not have it, so keep the variable in the worker's environment.
* **Errors after the response.** The official FrankenPHP Docker image has no `php.ini`, so `log_errors` is off, and a fatal error after the response (in a hook, for example) leaves no trace. Turn it on in the Caddyfile, or ship a `php.ini`:

```caddyfile theme={null}
{
    frankenphp {
        php_ini log_errors 1
    }
}
```

## Apache mod\_php, php-cgi and `php -S`

These runtimes have no function that sends the response before the script ends. So at the start of its last shutdown pass, AgentCat sends and closes the output buffers that can be closed, and flushes them. A client that knows the length of the answer (`Content-Length`) then has all of it before AgentCat runs the hooks and delivers. A client of an answer without a length waits until the script ends, up to the 5-second budget.

* **The Laravel and Symfony bridges set `Content-Length`** on the plain JSON answers of a tracked server. They never set it on a streamed (SSE) answer, on an answer without a body (1xx, 204, 304) or to a `HEAD` request, on an answer that has a `Content-Length` or `Transfer-Encoding` already, or after other output went out first.
* **A plain `mcp/sdk` script sets it itself**, before it echoes the answer: `header('Content-Length: ' . strlen($body));` (see the [HTTP server](/sdk/php/mcp-sdk.md#http-servers) of the `mcp/sdk` page).
* **Headers after that point.** A destructor that sets a header after AgentCat closed the buffers gets a "headers already sent" warning.
* **`zlib.output_compression`.** AgentCat closes the compression buffer with the others. Output written after that, for example an `echo` in a destructor, is not part of the compressed body and breaks it.
* **Warnings.** Under Laravel and Symfony, the framework's error handler catches PHP's warnings, so they reach the framework's log, not Apache's error log.

## NGINX Unit

NGINX Unit is supported like PHP-FPM, and you need nothing. Its PHP module reports the SAPI of `php -S` (`cli-server`); AgentCat recognizes Unit by its `SERVER_SOFTWARE` (`Unit/<version>`) and sends the response with Unit's `fastcgi_finish_request()` before it delivers.

## The CLI and containers

A stdio server has no request end. AgentCat runs the hooks and sends the events of a call when the next `tools/call` or `tools/list` request arrives, and when the input ends (see [differences and limits](/sdk/php/limits.md) for what that means for slow hooks). AgentCat writes nothing to STDOUT or STDERR: STDOUT stays the JSON-RPC stream.

* **Signals.** With ext-pcntl, AgentCat sends the waiting events at SIGTERM and SIGINT. The [`mcp/sdk` page](/sdk/php/mcp-sdk#signals) explains the handlers, inherited ignores and `disableSignalHandlers`. The official PHP Docker images have no ext-pcntl: add `RUN docker-php-ext-install pcntl` to your image.
* **PID 1.** In a container, the process with PID 1 ignores a signal whose handler is the default one. After its delivery, AgentCat gives the signal back to that default handler, which then does not end the process. Start the server with `docker run --init` (or `init: true` in Docker Compose), as production images should, so that an init process is PID 1.
* **Thread-safe (ZTS) builds**, such as the official `php:*-zts` images: a second SIGTERM or Ctrl+C during the delivery waits until the delivery ends, up to the 5-second budget.
* **Windows** has no ext-pcntl, so the events that wait at Ctrl+C are lost. AgentCat logs no line for that.
* **Symfony's `bin/console mcp:server`** ends with exit code 0 after the delivery at SIGTERM, because Symfony Console handles the signal itself, not 143.

## Swoole, OpenSwoole, RoadRunner and Workerman

* **With Laravel Octane or Symfony Runtime**, AgentCat delivers in the framework's terminate hooks: Laravel's `terminating()` callbacks and Symfony's `kernel.terminate`. It waits at most 1 second after each request. Events that are still waiting go out at a later request of the worker, or when the worker stops. See [Laravel](/sdk/php/laravel.md) for Octane's worker stop.
* **A raw worker loop** calls `AgentCat::flush(1.0)` after each request, as in the [FrankenPHP loop](#frankenphp).
* **No signal handlers under Swoole and OpenSwoole.** When ext-swoole or ext-openswoole is loaded, AgentCat installs no SIGTERM or SIGINT handlers: those servers stop their workers with handlers that PHP cannot see. The check looks at the loaded extension, so a stdio server on a PHP with ext-swoole loaded gets no delivery at those signals either.
* **RoadRunner and Workerman** install PHP signal handlers. AgentCat keeps them: after its delivery, it calls the handler that was there before.
* **No async runtimes.** Version 1 does not support async runtimes that interleave calls in one process, such as Swoole coroutines or ReactPHP. Serve one request at a time per worker.

## Hardened hosts

Shared hosts often list functions in `disable_functions`. AgentCat keeps your server answering whatever they disable. Where it loses a feature, it logs one line. "Once per process" means once per request under PHP-FPM, mod\_php and php-cgi, because PHP keeps no state between their requests.

| Disabled | What AgentCat does | Log line (once per process) |
| - | - | - |
| Any of the ext-curl functions AgentCat calls: `curl_init`, `curl_setopt_array`, `curl_getinfo`, `curl_error`, `curl_strerror`, `curl_multi_init`, `curl_multi_add_handle`, `curl_multi_remove_handle`, `curl_multi_exec`, `curl_multi_select`, `curl_multi_info_read`, `curl_multi_strerror` | Sends nothing: no events, no exports, no diagnostics. A host that disables `curl_multi_exec` loses every event. (`curl_exec` is not needed.) | `WARN: AgentCat cannot send events, telemetry exports or diagnostics: ext-curl functions are missing or disabled (<list>)` |
| `pcntl_signal`, `pcntl_signal_get_handler` or `pcntl_async_signals` | No delivery at SIGTERM or SIGINT | `WARN: AgentCat cannot send waiting events at SIGTERM or SIGINT: ext-pcntl functions are disabled (<list>); remove them from disable_functions` |
| ext-pcntl not installed (CLI only; not on Windows, not under Swoole or OpenSwoole, not with `disableSignalHandlers`) | No delivery at SIGTERM or SIGINT | `WARN: AgentCat cannot send waiting events at SIGTERM or SIGINT: ext-pcntl is not loaded; install ext-pcntl` |
| `register_shutdown_function` | Events still go out between requests and at `AgentCat::flush()`, but those that wait when the script ends are lost | `WARN: AgentCat cannot send waiting events when the script ends: register_shutdown_function is disabled` |
| `posix_kill` or `posix_getpid` | After the delivery at a signal, ends the process with `exit(128 + <signal>)` | None |
| `getenv` | Reads the environment from `$_ENV`, then `$_SERVER` | None |
| `hrtime` | Uses `microtime()` | None |
| `usleep` | Waits for a retry with `time_nanosleep()` | None |
| `usleep` and `time_nanosleep` | A delivery ends at the first retry that is not due yet. An event whose first attempt failed goes out only if a later delivery retries it, so at the end of a request it is lost | `WARN: AgentCat cannot wait for the retry of a failed event: usleep and time_nanosleep are disabled; a delivery ends at the first retry that is not due yet; remove one of them from disable_functions` |
| `ignore_user_abort`, `set_time_limit`, `ini_set`, `ini_get` | Skips that step of the request end; exporter bodies use the `serialize_precision` of `php.ini` | None |
| `fastcgi_finish_request` | Sends the response at the end of the script, as on Apache mod\_php | None |
| `session_status`, `session_write_close` | Does not close the session before it delivers | None |
| `php_uname`, `getmypid`, `gethostname` | Leaves those values out of diagnostics | None |
| `file_put_contents` | The log file stays empty; a PSR-3 logger still gets every line | None |

A failure that repeats on every pass of one delivery is logged once, with `(<n> more times in this delivery)` after it.

What AgentCat needs:

* **ext-curl, with the `curl_multi_*` functions**, to send anything.
* **One of `hrtime()` and `microtime()`.** With both disabled, delivery fails.
* **PHP's core string, array, math, JSON, PCRE, mbstring, hash and error-handler functions**, like every Composer package. No hosting list disables them.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.