Yii3
Yii3 está diseñado para ejecutarse en un proceso que no muere: su contenedor de DI trae un StateResetter, el runner expone su contenedor como API pública, y construir la aplicación una vez y reiniciar el estado de la petición después de cada respuesta es la forma que el framework ya tiene. El runner oficial para RoadRunner, yiisoft/yii-runner-roadrunner, está construido igual. Esta página cubre el script de worker residente, la alternativa que reconstruye el runner en cada petición y qué se comprobó sobre enrutado, sesiones, subidas de archivos y gestión de errores.
Verificado con
- PHP 8.5.8 — NTS, SAPI embed
- Rapira 0.6.0
- Plantilla yiisoft/app 1.4, con yii-runner-http 3.2.1 (router-fastroute 4.x)
Los dos scripts de worker de esta página se ejecutaron contra ese stack y pasaron la batería completa: enrutado, URL generadas, envíos de formulario y de JSON, sesiones, subidas de archivos, gestión de errores y 200 peticiones seguidas.
Yii3 y el modo worker
Un worker residente necesita dos piezas de API pública.
ApplicationRunner::getContainer() devuelve el contenedor sobre el que corre la aplicación, así que no hay que heredar de nada ni hurgar en estado privado. Yiisoft\Di\StateResetter es un servicio más de ese contenedor: los componentes registran en él sus propios callbacks de reinicio y una sola llamada a reset() los deja como estaban al principio, que es la respuesta del propio framework a un servicio que guarda estado de la petición.
Un servicio tuyo que guarde estado de la petición también tiene que registrar su callback: añade una clave 'reset' => function (): void { … } a la definición de DI de ese servicio, igual que declaran las suyas yiisoft/session y yiisoft/router. El closure se enlaza a la instancia, así que puede restaurar estado privado sin reconstruir el objeto. Qué reinicia Rapira entre peticiones y qué deja sin tocar está documentado en la guía general de frameworks y en Modo worker.
El patrón residente son entonces tres pasos: construir el runner una vez, ejecutarlo en cada petición y reiniciar después el estado del contenedor.
Requisitos previos
- Rapira instalado: ver Instalación.
- Una aplicación Yii3, ya sea un proyecto recién creado con
yiisoft/appo una que ya tengas.
En el lado de PHP no hay que instalar nada: el script de worker de abajo es el único archivo nuevo del proyecto, y va en la raíz, junto a composer.json, porque el rootPath del runner es precisamente la raíz del proyecto. También necesitas un PHP CLI normal en la máquina para Composer: Rapira trae PHP como biblioteca (libphp), no como comando php, así que esos pasos se ejecutan con el PHP de tu sistema, que Rapira ni usa ni toca.
El worker residente
Esta es la forma recomendada. Guárdalo como worker.php en la raíz del proyecto:
<?php
declare(strict_types=1);
use App\Environment;
use Rapira\Plugin\Http\HttpHandlerConfig;
use Yiisoft\Di\StateResetter;
use Yiisoft\Yii\Runner\Http\HttpApplicationRunner;
use function Rapira\create_plugin_handler;
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/src/bootstrap.php';
$runner = new HttpApplicationRunner(
rootPath: __DIR__,
debug: Environment::appDebug(),
checkEvents: Environment::appDebug(),
environment: Environment::appEnv(),
);
$container = $runner->getContainer();
$http = create_plugin_handler(new HttpHandlerConfig());
$handler = static function () use ($runner, $container): void {
try {
$runner->run();
} finally {
// The worker keeps serving after an escaped error; the reset has to
// run on that path too, or state leaks into the next request.
$container->get(StateResetter::class)->reset();
}
};
while ($http->handleRequest($handler)) {
gc_collect_cycles();
}Vamos por partes:
src/bootstrap.php es el arranque que trae la propia plantilla. Carga el autoloader de Composer, lee el .env si está y llama a Environment::prepare(): exactamente lo que hace public/index.php antes de tocar el runner. La línea explícita de vendor/autoload.php que va justo encima es redundante —require_once convierte la segunda llamada en algo que no hace nada— y deja el worker legible como punto de entrada independiente.
El runner se construye una sola vez, con los argumentos de public/index.php. rootPath, debug, checkEvents y environment salen de App\Environment tal cual los pasa el front controller, así que el worker arranca la misma aplicación que el punto de entrada web. El public/index.php de la plantilla pasa un argumento más —un temporaryErrorHandler conectado a un logger con StreamTarget— y hace require de c3.php cuando APP_C3 está activo. El worker verificado se salta las dos cosas. Ese manejador temporal solo cubre los errores que se producen mientras se construyen la configuración y el contenedor; si no le pasas ninguno, el runner recurre a un ErrorHandler con un NullLogger (HttpApplicationRunner::createTemporaryErrorHandler()), así que pásaselo aquí también si quieres que queden registrados los fallos al construir el contenedor.
getContainer() es API pública, así que el contenedor que capturas es el de la aplicación: el mismo que usará el runner en cada petición. El StateResetter se resuelve desde ahí dentro del handler.
En cada petición: run() y después reset(). run() es la misma llamada que hace el front controller; reset() recorre los callbacks de reinicio registrados en el contenedor y devuelve los servicios con estado a su punto de partida antes de que llegue la petición siguiente.
run() vuelve a ejecutar toda su secuencia en cada llamada. Cada llamada registra el manejador de errores, ejecuta runBootstrap(), ejecuta checkEvents() y después atiende la petición; el runner es reentrante por diseño y se comprobó que esa repetición es inofensiva durante 200 llamadas seguidas. La comprobación de eventos solo hace algo cuando su flag está activo, y la plantilla ata ese flag a Environment::appDebug(), así que con el modo debug apagado no hace nada en ninguna llamada.
Un runner residente lee cada petición desde cero. run() no captura la petición al construirse. En cada llamada resuelve RequestFactory desde el contenedor y construye un ServerRequest PSR-7 nuevo a partir de $_SERVER, $_GET, $_POST, $_COOKIE, $_FILES y php://input, y Rapira vuelve a rellenar esas superglobales antes de cada iteración del bucle (ese contrato lo cubre Modo worker).
La memoria se mantiene plana. A lo largo de 200 peticiones seguidas, el conjunto residente del worker no creció de forma apreciable, porque la aplicación se construye una vez y el reinicio es barato, así que no hay ningún arranque por petición que después haya que recoger.
La alternativa sencilla: un runner nuevo en cada petición
Para evitar por completo el estado residente, construye el runner dentro del handler. Así, todo lo que cree la aplicación pertenece a una sola petición:
<?php
declare(strict_types=1);
use App\Environment;
use Rapira\Plugin\Http\HttpHandlerConfig;
use Yiisoft\Yii\Runner\Http\HttpApplicationRunner;
use function Rapira\create_plugin_handler;
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/src/bootstrap.php';
$http = create_plugin_handler(new HttpHandlerConfig());
$handler = static function (): void {
// A fresh runner per request; constructor arguments mirror public/index.php.
$runner = new HttpApplicationRunner(
rootPath: __DIR__,
debug: Environment::appDebug(),
checkEvents: Environment::appDebug(),
environment: Environment::appEnv(),
);
$runner->run();
};
while ($http->handleRequest($handler)) {
gc_collect_cycles();
}El contenedor se reconstruye cada vez, así que hay menos piezas móviles, ningún reinicio que puedas hacer mal y ningún estado del contenedor que pase de una petición a la siguiente; las propiedades static, las variables globales y todo lo que dejara montado el arranque siguen residentes bajo cualquier worker y tiene que reiniciarlos tu propio código. Esta variante también pasó la batería completa.
El contenedor arranca en cada petición, de modo que ese arranque se paga cada vez y se genera la basura de un contenedor entero. La memoria del worker va creciendo según se acumulan esos contenedores hasta que PHP los libera de golpe, el perfil normal de un arranque por petición y no una fuga. Combina este patrón con pool.max_requests para que cada worker termine y sea reemplazado cada cierto tiempo; los perfiles de memoria están en la guía general de frameworks y la clave está documentada en Configuración.
El autoloader y el arranque de la plantilla siguen siendo residentes y el bucle de peticiones sigue viviendo en el script de worker, así que esto sigue siendo un worker, uno que descarta su aplicación entre peticiones, no modo clásico.
Usa el runner residente salvo que tengas un motivo para no hacerlo: es el diseño de larga vida que propone el propio framework, la memoria se mantiene plana y el reinicio es una sola llamada. Usa el runner por petición si tu arranque tiene restricciones de orden sobre las que prefieres no pensar: código que debe ejecutarse antes de que se construya el contenedor, o trabajo de arranque por petición que un callback de StateResetter no puede deshacer. Cambiar de uno a otro más adelante solo afecta al script de worker.
Cómo ejecutarlo
rapira serve worker.phpEl modo worker es el de por defecto, así que no hace falta ninguna opción. Las demás están en CLI.
Para producción, pásalo a un rapira.toml:
[http]
listen = "127.0.0.1:8000"
[pool]
entrypoint = "/srv/app/worker.php"
processes = 8
max_requests = 500
request_terminate_timeout_secs = 30
[log]
level = "info"
format = "json"Cada clave, con su valor por defecto y sus límites, está en la página de Configuración; la unidad de systemd y el proxy inverso que va delante están en En producción.
Qué se verificó
Los dos patrones pasaron la misma batería de pruebas contra la plantilla yiisoft/app. Los resultados:
El enrutado funciona sin sobrescribir nada de $_SERVER. Rapira pone en SCRIPT_NAME el nombre del script de entrada —/worker.php, no /index.php— y aun así FastRoute siguió emparejando rutas anidadas con query string. La raíz / renderizó la página de inicio de la plantilla y una ruta desconocida devolvió el 404 del propio framework. No hizo falta sobrescribir SCRIPT_NAME, REQUEST_URI ni DOCUMENT_ROOT en ningún sitio.
Las URL generadas salen limpias. UrlGeneratorInterface::generate() produjo rutas normales de la aplicación: el nombre del script de worker no se cuela en ellas.
Las sesiones son de cada petición y están bien aisladas. Un cliente que guardaba sus cookies vio su contador pasar de 1 a 2 entre peticiones; otro cliente que llegó justo después al mismo endpoint obtuvo una sesión nueva que volvía a empezar en 1. Eso se cumple también en el patrón residente, donde el contenedor sobrevive.
Llegan los envíos de formulario, los cuerpos JSON y las subidas de archivos. Campos en $_POST, un payload JSON leído de php://input y una subida multipart con su archivo temporal legible durante la petición: el ServerRequest PSR-7 que yii-runner-http construye a partir de las superglobales lo lleva todo.
Una excepción lanzada es un 500, y el worker sigue sirviendo. A una acción que lanza la recoge ErrorCatcher, que renderiza la respuesta de error igual que lo haría en cualquier otro sitio; la excepción queda registrada y la petición siguiente la atiende con normalidad ese mismo proceso worker. En Rapira una excepción sin capturar es un fallo de la petición, no del worker: en Modo worker tienes qué provoca la caída de un worker y qué no.
CSRF
La plantilla de la aplicación mete CsrfTokenMiddleware en su cadena de middleware por defecto, y el token vive en la sesión, que es justo el estado que sí ejercitó la batería: por petición y aislado por cliente. Nada del bucle del worker toca el flujo del token, así que aquí un POST necesita el suyo igual que en cualquier otro sitio. Si los POST empiezan a ser rechazados después de pasarte a un worker, comprueba primero el token; el arreglo es el de siempre (renderizar el token en el formulario y devolverlo), no un cambio en el script de worker.
El modo clásico como alternativa
Yii3 también funciona como front controller de toda la vida:
rapira serve --classic public/index.phpEl mismo código, sin script de worker y con estado limpio en cada petición. Consulta Modo clásico para más información.
El script de worker es un punto de entrada más y no un sustituto del front controller, así que conserva public/index.php: es el script de entrada que ejecuta el modo clásico y sigue siendo útil para trabajar en local con el servidor que trae PHP.
El public/index.php de la plantilla tiene una rama PHP_SAPI === 'cli-server' que sirve archivos estáticos y reescribe SCRIPT_NAME. Está ahí por el servidor de desarrollo que trae PHP y bajo Rapira no se activa nunca, porque PHP_SAPI vale rapira (fastcgi en PHP 8.4 — ver Instalación), así que puede quedarse como está.