Laravel Octane + FrankenPHP: qué se rompe de verdad (reproduje cada fuga)

Foto de Marco Orta | 15 min de lectura
Portada tipográfica: la palabra Octane sobre un diff etiquetado laravel 13, - server: php-fpm en rojo y + server: frankenphp (worker) en verde
Tabla de Contenidos

    Octane mantiene tu aplicación de Laravel en memoria entre peticiones. De ahí sale la velocidad, y de ahí sale todo lo que se rompe. Casi todos los artículos de «trampas de Octane» repiten la misma advertencia: un singleton que recibe la petición en su constructor se queda con la primera petición para siempre. Monté una app de Laravel 13 en Octane con FrankenPHP dentro de Docker y reproduje cada fallo con curl, y esa advertencia, tal como se suele contar, es falsa. Las fugas que sí ocurren vienen de otro lado.

    Tres resultados me sorprendieron. Un singleton que captura la petición y se resuelve durante una petición no filtra nada, porque Octane atiende cada petición sobre un clon de la aplicación. --max-requests=0, que en Swoole y RoadRunner significa «sin límite», hace que FrankenPHP ni siquiera arranque. Y las funciones que la documentación marca como exclusivas de Swoole no lanzan error en FrankenPHP: casi todas hacen otra cosa en silencio. Todo lo que sigue va con el comando y la salida.

    Si todavía no decides si Octane te conviene, empieza por la guía de rendimiento de Laravel; este post es para el paso siguiente, cuando ya vas a encenderlo.

    El laboratorio

    ComponenteVersión
    Laravel13.32.0 (el esqueleto pide PHP ^8.3)
    laravel/octane2.19.1
    FrankenPHP1.12.7, PHP 8.5.10 ZTS, Caddy 2.11.4
    Imagendunglas/frankenphp:latest (Debian 13) + pcntl + Composer
    Cargawrk, en CPUs separadas

    Un esqueleto limpio, nueve clases pequeñas y un archivo de rutas. Octane corre con php artisan octane:frankenphp; el modo de comparación es el php-server clásico de FrankenPHP, que arranca Laravel en cada petición igual que PHP-FPM.

    Antes de arrancar: pcntl, zip y el binario musl

    Tres cosas estorbaron antes de la primera petición:

    • La imagen oficial de FrankenPHP no trae pcntl. Sin la extensión, octane:frankenphp muere de inmediato:

      Error  Undefined constant "Laravel\Octane\Commands\Concerns\SIGINT"
      at vendor/laravel/octane/src/Commands/Concerns/InteractsWithServers.php:174
      

      El Dockerfile de la documentación de Octane ejecuta install-php-extensions pcntl justo por eso. Copia esa línea.

    • Tampoco trae zip ni unzip, así que composer require falla dentro de ella. Instala las dependencias en una etapa con la imagen de Composer y copia vendor/.

    • En Alpine, octane:install descarga el binario musl. Si no hay frankenphp en el PATH, Octane lo descarga, y elige la versión glibc solo si getconf GNU_LIBC_VERSION funciona. En Alpine no funciona, así que bajas los 173 MB del build musl. La guía de rendimiento de FrankenPHP dice «evita musl en producción» porque PHP es más lento sobre ella, sobre todo en modo ZTS. En mi ruta de prueba, que es trivial, no medí diferencia (5,900 peticiones por segundo contra 5,800-6,100 en glibc), pero la página de problemas conocidos lista huecos concretos, como que no existe GLOB_BRACE. Usa las imágenes Debian.

    1. Singletons: la fuga que todos describen no es la que muerde

    El binding que todos los artículos ponen de ejemplo:

    // AppServiceProvider::register()
    $this->app->singleton(RequestEcho::class,
        fn (Application $app) => new RequestEcho($app['request']));
    

    Resuelto dentro de una ruta, con un solo worker para que todas las peticiones caigan en el mismo:

    ?user=alice → {"query_user":"alice","singleton_sees":"alice"}
    ?user=bob   → {"query_user":"bob","singleton_sees":"bob"}
    ?user=carol → {"query_user":"carol","singleton_sees":"carol"}
    

    Nada se filtra. La razón está en Worker::handle() de Octane: cada petición corre sobre $sandbox = clone $this->app, y un singleton resuelto en ese clon muere con él al terminar la petición. La advertencia famosa solo es cierta en dos situaciones, y son las que tienes que buscar.

    El singleton se resuelve durante el arranque. La documentación de Octane lo dice con precisión: el problema es cuando la instancia «se resuelve durante el proceso de arranque de la aplicación». Resolví la misma clase en boot(), como haría un paquete que se carga de inmediato:

    ?user=alice → {"query_user":"alice","singleton_sees":null,"singleton_url":"http://localhost:8000"}
    

    No captura la primera petición. Captura la petición falsa de consola que Laravel registra mientras arranca (la URL es APP_URL y no trae datos), y se queda con ella en todas las peticiones.

    Algo lo resuelve a través de la aplicación base. Esta sí captura la primera petición. Un listener registrado en boot() que resuelve a través de $this->app, que es la aplicación original que Octane clona, no el clon:

    // AppServiceProvider::boot(). Aquí todavía no se resuelve nada.
    Event::listen('lab.who',
        fn () => $this->app->make(RequestEchoViaProvider::class)->user());
    
    ?user=alice → "alice"
    ?user=bob   → "alice"
    ?user=carol → "alice"
    

    La primera petición resolvió el singleton sobre la app base, y ahí se quedó durante toda la vida del worker. Agregar la clase a flush en config/octane.php lo arregló (alice, bob, carol).

    El mismo mecanismo convierte un singleton con estado en una fuga de datos entre usuarios. Un singleton CurrentTenant que vive en la app base (porque algo lo resolvió al arrancar), con un middleware que lo llena solo cuando la petición trae X-Tenant:

    -H 'X-Tenant: acme' → {"singleton_tenant":"acme","scoped_tenant":"acme"}
    (sin cabecera)      → {"x_tenant_header":null,"singleton_tenant":"acme","scoped_tenant":null}
    

    La petición anónima heredó acme. Con cuatro workers es peor de depurar: solo el worker que atendió a acme quedó contaminado, así que una de cada cuatro peticiones salía mal. La misma clase registrada con scoped() en vez de singleton() devolvió null en todas las peticiones sin cabecera.

    Qué hacer:

    • Usa scoped() para todo lo que guarde estado de la petición (tenant, usuario actual, idioma).
    • No resuelvas en boot() servicios que dependan de la petición, y busca listeners y macros registrados en boot() que llamen a $this->app->make().
    • Lo que no puedas cambiar (un paquete), agrégalo a flush en config/octane.php.
    • A los servicios que necesitan la petición o la configuración, inyéctales un resolvedor, fn () => Container::getInstance(), en vez del objeto.

    2. El estado estático crece hasta que el worker se reinicia

    Un arreglo estático que suma 10 KB por petición, que es lo que hace una caché en memoria ingenua o un registro de «ya procesados»:

    req#1    worker dcb87b  items 1    mem_kb 5667
    req#100                 items 100  mem_kb 7001
    req#300                 items 300  mem_kb 9410
    req#500                 items 500  mem_kb 11810
    req#501  worker c5125d  items 1    mem_kb 1253   ← reinicio en --max-requests=500
    

    Unos 12 KB por petición, que solo se liberan cuando Octane reinicia el worker a las 500 peticiones, el valor por defecto. El reinicio no aparece en los logs y cuesta poco: 1.85 ms antes, 4.40 ms en la primera petición del worker nuevo y 2.19 ms después.

    Una trampa si lo pruebas tú: mi primera versión usaba str_repeat('x', 10240) y la memoria se quedó plana durante 500 peticiones. OPcache convierte esa expresión constante en una sola cadena compartida, así que todos los elementos del arreglo apuntaban a la misma memoria. Con Str::random() sí apareció la fuga. Si tu prueba de fuga sale limpia, comprueba que los datos de verdad cambian en cada petición.

    3. --max-requests=0 no significa «sin límite» en FrankenPHP

    En OpenSwoole, max_request en 0 significa sin límite, y en RoadRunner «cero (o nada) significa sin límite» para max_jobs. En FrankenPHP:

    php artisan octane:frankenphp --max-requests=0
    
    Error: loading initial config: … frankenphp app module: start: failed to initialize workers:
    too many consecutive failures: worker public/frankenphp-worker.php has not reached frankenphp_handle_request().
    

    El contenedor sale con código 1. El script del worker de Octane hace while ($requestCount < $maxRequests …), así que con 0 nunca atiende una petición; FrankenPHP ve seis workers seguidos que no llegan a frankenphp_handle_request() y se rinde. Con octane:start --server=frankenphp --max-requests=0 el servidor sí arranca, pero el worker recibe MAX_REQUESTS=500: un ?: en el comando cambia el 0 por el valor de la configuración sin avisarte. Los dos pull requests que habrían hecho que 0 signifique «sin límite» se cerraron sin fusionarse.

    Si quieres algo casi ilimitado, pasa un número grande (--max-requests=1000000 midió el mismo rendimiento que 500). Pero los reinicios son lo que te protege de la sección 2, así que yo dejaría el valor por defecto.

    4. La configuración inyectada en un singleton se queda vieja, y puede envenenar el worker

    Un singleton que recibe el repositorio de configuración en su constructor, y una petición que cambia un valor en tiempo de ejecución (configuración por tenant, por ejemplo):

    ?set=changed-in-request → {"config_helper":"changed-in-request","injected_repo":"original",
                               "resolver_closure":"changed-in-request"}
    

    El repositorio inyectado es el de la app base, así que dentro de la petición no ve el cambio. El caso peor es el contrario: una petición que escribe a través de ese repositorio inyectado ($service->config->set(...)) cambia la configuración de la app base, y desde entonces todas las peticiones siguientes de ese worker leen mutated-via-service. En modo clásico nada de esto sobrevive a la petición. El arreglo es el que da la documentación: inyecta un closure que resuelva la configuración, o llama a config() cuando necesites el valor.

    5. Lo que añade FrankenPHP: hilos, $_ENV y exit()

    Hilos, no procesos. Los cuatro workers reportaron el mismo PID, y /proc mostró un solo proceso frankenphp con 67 hilos. La documentación de FrankenPHP lo dice («hilos en vez de procesos»), y importa por dos razones: no puedes usar extensiones que no sean thread-safe (la página de problemas conocidos lista imap, newrelic y pcov, y avisa que imagick en las imágenes Docker puede tronar por sus hilos de OpenMP), y un fallo en cualquier hilo tumba el proceso entero, con todos los workers.

    $_ENV y putenv() sobreviven entre peticiones. FrankenPHP reinicia $_GET, $_POST, $_SERVER y los demás, pero su documentación del modo worker dice que «$_ENV actualmente no se reinicia entre peticiones». Lo probé con putenv():

    ?v=secret-from-alice → {"prev_getenv":null}
    (petición siguiente) → {"prev_$_ENV":"secret-from-alice","prev_$_SERVER":null,
                            "prev_getenv":"secret-from-alice"}
    

    Con cuatro workers, solo el hilo que lo escribió vio el valor. Si algún código guarda datos de la petición en el entorno (algunos SDK ponen credenciales así), se le filtran al siguiente usuario que atienda ese hilo.

    exit() devuelve 200. exit(0) y exit(1) mandaron lo que llevaban de salida con HTTP 200 y reiniciaron el worker. Doce exit(1) seguidos: doce 200, y el servidor aguantó. dd() devolvió 500 y también reinició. Un monitoreo que solo mira el código de estado no va a ver un script que muere a la mitad.

    El límite de 30 segundos es por petición. Octane fija max_execution_time en 30 segundos. Dos peticiones de 20 segundos seguidas en el mismo worker devolvieron 200; una de 35 devolvió 500 a los 31.2 s, con «Maximum execution time of 30 seconds exceeded» en el log, y el worker se reinició.

    Issues abiertos que conviene conocer antes de desplegar (en php/frankenphp, abiertos en septiembre de 2026):

    • #2588: segfault del worker de Octane (salida 139). Quien lo reportó lo arregló cambiando opcache.jit de tracing a function, y lo atribuye a una regresión del JIT tracing de PHP 8.5.5 en adelante.
    • #1074: las subidas de archivos simultáneas se quedan colgadas por HTTPS; el mantenedor lo reprodujo en julio con 30 subidas multipart sobre una sola conexión HTTP/2.
    • #2388: la imagen Alpine truena con cientos de variables de entorno (los service links de Kubernetes son el origen típico).

    Y actualiza Octane: antes de la 2.18.0, un POST multipart/form-data sin boundary tiraba la petición. Con el script de worker viejo me devolvió un 500 silencioso, sin nada en laravel.log; con la 2.19.1 se atiende normal.

    6. Las funciones exclusivas de Swoole fallan en silencio

    La documentación marca las tareas concurrentes, los ticks, la caché de Octane y las tablas como funciones de Swoole. Esto es lo que hacen en FrankenPHP:

    FunciónEn FrankenPHP
    Octane::concurrently()Ejecuta las tareas una tras otra: dos de 300 ms tardaron 600 ms
    Cache::store('octane')Funciona, pero es una caché distinta por worker: un valor guardado en uno de cuatro workers no existía en los otros tres
    Octane::tick()Se registra sin error y nunca se ejecuta
    Octane::table()Lanza «Tables may only be accessed when using the Swoole server.»

    Solo la última te avisa. Si vienes de Swoole, busca las tres primeras con grep: el código va a seguir corriendo, solo que ya no hace lo que hacía.

    7. Paquetes: casi todos bien en 2026

    Octane trae sus propios listeners de reinicio para Livewire, Inertia, Scout y Socialite. Las versiones actuales de los sospechosos de siempre ya lo resuelven solas: Filament reinicia sus gestores de componentes en cada petición, Telescope escucha los eventos de petición de Octane, Debugbar 4 «funciona sin configuración con Octane» (si vienes de la 3.x, quita su entrada de flush en config/octane.php) e Inertia 3 arregló sus herramientas de desarrollo bajo Octane. El que hay que configurar es spatie/laravel-permission: su listener de reinicio para Octane viene apagado, y su documentación dice que lo enciendas si los permisos cacheados se ven viejos o se cruzan entre peticiones.

    ¿Vale la pena? Los números

    Una microprueba en una laptop, no una promesa de producción: una ruta que devuelve un JSON pequeño, APP_ENV=production, el servidor fijado a 4 CPUs (8 workers) y wrk en CPUs aparte, 20 segundos por corrida.

    ModoConexionesPeticiones/sLatencia mediana
    Octane (worker de FrankenPHP)16~6,0002.5 ms
    FrankenPHP clásico, con optimize16~2,9005 ms
    FrankenPHP clásico, sin caché de config y rutas161,34511 ms
    Octane64~5,15012 ms
    FrankenPHP clásico, con optimize64~2,49024 ms
    php artisan serve64~315204 ms

    Octane duplicó el rendimiento frente a un modo clásico bien cacheado, y lo multiplicó por 4.5 frente a uno sin php artisan optimize. La comparación honesta es la primera: si tu producción no cachea configuración y rutas, arregla eso antes de meter Octane. Y en una app real la ganancia depende de cuánto pesa el arranque frente a tu I/O; una ruta que espera 200 ms a la base de datos gana poco con ahorrarse 3 ms de arranque.

    La revisión antes de cambiar

    # Servicios que guardan estado o reciben request/config en el constructor
    grep -rnE "singleton\(|->instance\(" app/Providers/
    grep -rnE "__construct\([^)]*(Request|Repository|Container)" app/
    
    # Propiedades estáticas que acumulan
    grep -rnE "static (array|\\\$)" app/ | grep -v "function"
    
    # Resolución a través de la app base desde boot()
    grep -rnE "\\\$this->app->(make|get)\(" app/Providers/
    
    # Lo que FrankenPHP trata distinto
    grep -rnE "putenv\(|\\\$_ENV\[|\bexit\(|\bdie\(" app/ routes/
    
    # APIs de Swoole que se degradan en silencio
    grep -rnE "Octane::(concurrently|tick|table)|store\('octane'\)" app/
    

    Después, en orden: instala pcntl en la imagen, usa una imagen Debian, corre tu suite de pruebas, haz una prueba de carga contra el servidor de Octane con al menos cuatro workers (con uno solo no ves las fugas que solo pasan en uno de cada cuatro) y deja --max-requests en su valor por defecto. Si además vas a subir a Laravel 13, el lado del framework está en qué se rompe al actualizar a Laravel 13.

    Preguntas frecuentes

    ¿Un singleton que recibe la petición se filtra entre peticiones en Laravel Octane?

    No si se resuelve durante una petición: Octane atiende cada petición sobre un clon de la aplicación, y los singletons resueltos en el clon se descartan al terminar. Probado en Laravel 13.32 con Octane 2.19.1 y FrankenPHP 1.12.7. Sí se filtra cuando se resuelve durante el arranque (captura la petición de consola que Laravel registra al arrancar) o a través de la aplicación base, por ejemplo desde un listener registrado en boot() que llama a $this->app->make(); en ese caso se queda con la primera petición durante toda la vida del worker. Usa scoped(), agrega la clase a flush en config/octane.php o inyecta un closure que la resuelva.

    ¿Qué hace --max-requests=0 con Octane y FrankenPHP?

    Impide que el servidor arranque. Con php artisan octane:frankenphp --max-requests=0, el script del worker nunca llega a frankenphp_handle_request() y FrankenPHP sale con «too many consecutive failures». Con octane:start el servidor arranca pero usa en silencio el valor por defecto de 500. En Swoole y RoadRunner, 0 significa sin límite. Usa un número grande si quieres evitar reinicios, pero el valor de 500 es lo que limpia las fugas de memoria.

    ¿Qué funciones de Octane no sirven con FrankenPHP?

    Las tareas concurrentes, los ticks, la caché de Octane y las tablas son funciones de Swoole. En FrankenPHP, Octane::concurrently() ejecuta las tareas una tras otra, el almacén de caché octane es un arreglo distinto por worker, Octane::tick() se registra pero nunca se ejecuta y solo Octane::table() lanza una excepción. Las tres primeras fallan en silencio.

    ¿Por qué Laravel Octane falla con «Undefined constant SIGINT» en FrankenPHP?

    Porque la imagen oficial dunglas/frankenphp no incluye la extensión pcntl, y Octane usa sus constantes de señales. Instálala con install-php-extensions pcntl, como hace el Dockerfile de la documentación de Octane. La imagen tampoco trae zip ni unzip, así que instala las dependencias de Composer en una etapa aparte.

    ¿$_ENV se reinicia entre peticiones en el modo worker de FrankenPHP?

    No. FrankenPHP reinicia $_GET, $_POST, $_COOKIE, $_FILES, $_SERVER y $_REQUEST entre peticiones, pero su documentación dice que $_ENV actualmente no se reinicia. En la prueba, un valor puesto con putenv() en una petición lo vio la siguiente petición del mismo hilo. No guardes en el entorno datos de la petición ni datos sensibles.

    ¿Cuánto más rápido es Laravel con Octane y FrankenPHP?

    En una microprueba en laptop con una ruta JSON mínima, Octane atendió unas 6,000 peticiones por segundo contra unas 2,900 del modo clásico de FrankenPHP con caché de configuración y rutas, más o menos el doble, y 4.5 veces más que el modo clásico sin caché. La ganancia real depende de cuánto de cada petición es arranque del framework y cuánto es esperar a la base de datos u otro I/O.

    ¿Te sirvió? Compártelo

    ¿Te sirvió? Recibe la próxima por correo

    Una vez por semana: qué se rompe al actualizar, IA para desarrolladores y lo que estoy construyendo, con fuentes. Sin spam.

    Al suscribirte aceptas nuestro aviso de privacidad.

    Buscar

    Etiquetas

    Migración PHP IA Laravel JavaScript Tutorial Desarrollo Web Upgrade Buenas Prácticas Seguridad OpenAI SEO Backend Claude Laravel 13