FrankenPHP: PHP con Caddy integrado y worker mode en Linux

Mascote do LinuxPro apertando com uma chave inglesa o parafuso de um servidor com o logo do FrankenPHP, enquanto o elefante verde do FrankenPHP segura o outro lado e o cachorro caramelo observa

Durante mucho tiempo, servir PHP en producción significó dos piezas: un servidor web delante (Apache o nginx) y PHP-FPM detrás, hablando a través de FastCGI, cada uno con su configuración, su servicio y sus registros. El FrankenPHP lo reúne todo en un único binario: el servidor web Caddy con el intérprete PHP integrado. Se obtiene HTTPS automático, HTTP/3 y, de paso, un worker mode que mantiene la aplicación cargada en memoria entre peticiones. Esta guía explica qué es, lo instala en Debian y Ubuntu mediante los paquetes oficiales y muestra ambos modos funcionando.

Qué es FrankenPHP

FrankenPHP es un servidor de aplicaciones PHP escrito en Go, creado por Kévin Dunglas — el autor de API Platform — con el patrocinio de la cooperativa Les-Tilleuls.coop. La versión 1.0 salió en diciembre de 2023. En mayo de 2025, la PHP Foundation pasó a apoyar oficialmente el proyecto y el código pasó a la organización oficial de PHP en GitHub (php/frankenphp), con la gobernanza mantenida por los mantenedores originales. La licencia es MIT.

La versión actual es la 1.13.0, de 4 de octubre de 2026, que incorpora Caddy 2.11.7 y Mercure 1.0 y corrige cinco fallos de seguridad (dos clasificados como altos). El nombre es literal: es un PHP cosido dentro de Caddy, de ahí el elefante con tornillos en el logo.

Qué aporta:

  • Un único proceso. El PHP se ejecuta en hilos dentro del proceso de Caddy, sin FastCGI y sin un pool separado que administrar. Por eso FrankenPHP utiliza PHP compilado en modo ZTS (thread safe).
  • Todo lo que hace Caddy. Certificado automático, HTTP/2, HTTP/3, compresión zstd/brotli/gzip, proxy inverso y el mismo Caddyfile.
  • Worker mode. La aplicación se inicializa una vez y atiende las siguientes peticiones ya cargada. Laravel (vía Octane) y Symfony (vía Runtime) tienen integración oficial.
  • Extras de aplicación web. Early Hints (estado 103), tiempo real con Mercure, hot reload y X-Sendfile para archivos grandes.
Diagrama comparando a pilha clássica, com nginx na frente e um pool de php-fpm separado ligado por FastCGI, onde cada requisição recarrega a aplicação, com o FrankenPHP, um único processo com Caddy e PHP ZTS, que oferece o modo clássico e o modo worker, no qual a aplicação inicializa uma vez e fica na memória
A la izquierda, dos servicios y un socket FastCGI. A la derecha, un binario, un Caddyfile y un servicio — y la opción de mantener la aplicación en memoria.

Instalación en Debian y Ubuntu

Hay tres caminos: paquetes .deb/.rpm, binario estático y Docker. Para servidor, el paquete es el más práctico, porque trae servicio systemd, php.ini de producción y extensiones instalables vía apt. Los paquetes los mantiene el equipo del proyecto en un repositorio propio, con una variante por versión de PHP (8.2 a 8.5):

sudo apt install -y curl ca-certificates
sudo mkdir -p /etc/apt/keyrings

VERSION=85   # 82, 83, 84 ou 85
sudo curl -fsS https://pkg.henderkes.com/api/packages/${VERSION}/debian/repository.key \
  -o /etc/apt/keyrings/static-php${VERSION}.asc
echo "deb [signed-by=/etc/apt/keyrings/static-php${VERSION}.asc] https://pkg.henderkes.com/api/packages/${VERSION}/debian php-zts main" \
  | sudo tee /etc/apt/sources.list.d/static-php${VERSION}.list
sudo apt update
sudo apt install frankenphp

Probé estos comandos en un Debian 13 limpio. El resultado:

$ frankenphp version
FrankenPHP v1.13.0 PHP 8.5 Caddy v2.11.7

$ php-zts -v
PHP 8.5.11 (cli) (ZTS gcc 16.2.0 x86_64)

El paquete crea el usuario frankenphp (miembro del grupo www-data), instala el servicio frankenphp.service y el CLI php-zts. Use este CLI para Composer y scripts: en 1.13, el subcomando frankenphp php-cli pasa a usar la SAPI de línea de comandos nativa de PHP y, con PHP por debajo de 8.6, rechaza ejecutarse con el mensaje esta funcionalidad no está disponible.

Los archivos se encuentran en:

Ítem Ruta (paquete deb/rpm)
Configuración del servidor /etc/frankenphp/Caddyfile
Configuraciones extra (cargadas automáticamente) /etc/frankenphp/Caddyfile.d/*.caddyfile
php.ini (ya con preajuste de producción) /etc/php-zts/php.ini
adicionales /etc/php-zts/conf.d/*.ini
Sitio de ejemplo /usr/share/frankenphp/

Las extensiones entran a través de apt con el prefijo php-zts-. La instalación base ya trae OPcache, mbstring, curl, sodium, dom y otras; el resto se instala así:

apt-cache search php-zts- | grep -v debuginfo
sudo apt install php-zts-pdo-mysql php-zts-gd php-zts-intl php-zts-redis
php-zts -m

¿Prefieres no añadir un repositorio? El script oficial descarga el binario estático, que se ejecuta en cualquier distribución sin dependencias: curl https://frankenphp.dev/install.sh | sh. El cambio es que las extensiones tienen que ser compiladas dentro del binario.

Modo clásico: sustituyendo nginx + PHP-FPM

En el modo clásico, FrankenPHP se comporta como el dúo tradicional: cada solicitud carga el script desde cero. Es el modo para cualquier aplicación PHP existente — WordPress, Nextcloud, un sistema legado. El Caddyfile de un sitio en producción:

{
	frankenphp
}

app.exemplo.com.br {
	root /var/www/app/public
	encode zstd br gzip
	php_server
	log
}

La directiva php_server resume lo que en nginx requeriría try_files, location ~ \.php$ e fastcgi_pass: sirve archivos estáticos, envía .php al intérprete y redirige el resto al index.php. Como en Caddy, el dominio en la dirección del bloque basta para que el certificado sea emitido y renovado automáticamente, con el DNS apuntando y los puertos 80 y 443 abiertos.

sudo frankenphp fmt --overwrite /etc/frankenphp/Caddyfile
sudo frankenphp validate --config /etc/frankenphp/Caddyfile
sudo systemctl enable --now frankenphp
sudo systemctl reload frankenphp
journalctl -u frankenphp -f

Para probar sin tocar el servicio, ejecuta en la carpeta del proyecto frankenphp php-server: sirve el directorio actual al instante. Para WordPress, la documentación oficial recomienda un bloque equivalente: dominio, php_server, compresión y registro.

Worker mode: la aplicación queda en memoria

En un framework moderno, gran parte del tiempo de cada petición se va en la inicialización: autoload, lectura de configuración, montaje del contenedor de dependencias. En el modo worker, el script de entrada hace esto una vez y entra en un bucle que atiende petición tras petición con frankenphp_handle_request(). Las superglobales ($_GET, $_POST, $_SERVER) se renuevan en cada vuelta; lo demás sigue cargado.

Un worker mínimo, para ver el efecto:

<?php
// /srv/app/worker.php
$boot = microtime(true);   // executa uma única vez
$count = 0;

$handler = function () use (&$count, $boot) {
    $count++;
    echo "requisição $count, app iniciada em " . round($boot) . "\n";
};

while (frankenphp_handle_request($handler)) {
    gc_collect_cycles();
}
{
	frankenphp {
		worker {
			file /srv/app/worker.php
			num 4
		}
	}
}

app.exemplo.com.br {
	root /srv/app
	php_server
}

En la prueba, tres peticiones consecutivas respondieron requisição 1, 2 e 3, todas con la misma hora de inicialización: el estado sobrevivió entre peticiones. En el modo clásico, el mismo contador volvería siempre a 1.

Esto es poderoso y cobra disciplina. Las variables estáticas, singletons y conexiones persisten entre usuarios; las fugas de memoria se acumulan; una excepción no tratada derriba al worker. Por eso la documentación recomienda capturar excepciones dentro del handler, y FrankenPHP reinicia los workers que fallan (el límite es configurable con max_consecutive_failures). Para cambios de código en desarrollo, la opción watch reinicia el worker cuando cambian los archivos.

Laravel y Symfony

No necesitas escribir el bucle a mano. En Laravel, Octane se encarga de esto:

composer require laravel/octane
php-zts artisan octane:install --server=frankenphp
php-zts artisan octane:frankenphp

En Symfony, el componente Runtime tiene soporte nativo; la configuración está en la página de Symfony de la documentación. En ambos, ejecuta las pruebas de la aplicación en worker mode antes de pasar a producción: el código que asume “proceso nuevo en cada petición” aparece rápido.

Docker

La imagen oficial es dunglas/frankenphp, con variantes por versión de PHP y base Debian o Alpine. Para levantar el directorio actual:

docker run -v $PWD:/app/public \
  -p 80:80 -p 443:443 -p 443:443/udp \
  dunglas/frankenphp:1.13-php8.5

Abre https://localhost (no https://127.0.0.1, para el cual no hay certificado por defecto) y acepta el certificado local. Para una imagen de producción, las extensiones entran con el install-php-extensions que ya viene en la base, y el worker con la variable FRANKENPHP_CONFIG:

FROM dunglas/frankenphp:1.13-php8.5
RUN install-php-extensions pdo_mysql gd intl zip opcache
RUN cp $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini
COPY . /app
ENV FRANKENPHP_CONFIG="worker ./public/index.php"

Prefiere las imágenes basadas en Debian. Las Alpine y el binario estático usan musl libc, y la documentación lista incompatibilidades, como la falta del flag GLOB_BRACE del glob(). Para lo básico de contenedores, consulta la curso de Docker.

Precauciones antes de migrar

  • Extensiones no thread safe. imap, el agente de New Relic y el pcov no funcionan; el imagick choca con los hilos de ImageMagick, y la documentación indica desactivar esos hilos; los perfiladores de Datadog y de Blackfire aún presentan limitaciones. La lista completa se encuentra en Known issues.
  • Hilos, no procesos. El valor predeterminado es el doble del número de CPUs. En la 1.13, num_threads se empezó a contar solo los hilos para las peticiones fuera de los workers, que entran de forma adicional; quienes fijaban ese valor junto con los workers deben revisar la configuración antes de actualizar.
  • El modo worker es opcional. Comience en el modo clásico, que no cambia el comportamiento de la aplicación, y solo active el worker después de probarlo.
  • Manténgase actualizado. La 1.13 corrigió, entre otras cosas, una falsificación de cabecera por nombres con punto y un putenv() que filtraba valores entre peticiones. Actualizar es sudo apt upgrade.

Si mantiene varias versiones de PHP lado a lado con nginx, la entrada Nginx y varias versiones de PHP muestra el enfoque tradicional — útil para comparar y para las aplicaciones que aún dependen de una extensión incompatible.

Enlaces

FrankenPHP no le pide que reescriba nada para empezar: en el modo clásico, simplemente sustituye dos servicios por uno y trae consigo el HTTPS automático de Caddy. La gran ganancia llega después, con el modo worker — y entonces conviene tratar la migración como un cambio de arquitectura, con pruebas, y no como un simple cambio de servidor.