FrankenPHP: PHP com Caddy embutido e worker mode no 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

Por muito tempo, servir PHP em produção quis dizer duas peças: um servidor web na frente (Apache ou nginx) e o PHP-FPM atrás, conversando por FastCGI, cada um com sua configuração, seu serviço e seus logs. O FrankenPHP junta tudo num binário: o servidor web Caddy com o interpretador PHP embutido. Você ganha HTTPS automático, HTTP/3 e, de quebra, um worker mode que mantém a aplicação carregada na memória entre requisições. Este guia explica o que é, instala no Debian e no Ubuntu pelos pacotes oficiais e mostra os dois modos funcionando.

O que é o FrankenPHP

O FrankenPHP é um servidor de aplicações PHP escrito em Go, criado por Kévin Dunglas — o autor do API Platform — com patrocínio da cooperativa Les-Tilleuls.coop. A versão 1.0 saiu em dezembro de 2023. Em maio de 2025, a PHP Foundation passou a apoiar oficialmente o projeto e o código foi para a organização oficial do PHP no GitHub (php/frankenphp), com a governança mantida pelos mantenedores originais. A licença é MIT.

A versão atual é a 1.13.0, de 4 de outubro de 2026, que embute o Caddy 2.11.7 e o Mercure 1.0 e corrige cinco falhas de segurança (duas classificadas como altas). O nome é literal: é um PHP costurado dentro do Caddy, daí o elefante com parafusos no logo.

O que ele traz:

  • Um processo só. O PHP roda em threads dentro do processo do Caddy, sem FastCGI e sem um pool separado para administrar. Por isso o FrankenPHP usa o PHP compilado em modo ZTS (thread safe).
  • Tudo o que o Caddy faz. Certificado automático, HTTP/2, HTTP/3, compressão zstd/brotli/gzip, proxy reverso e o mesmo Caddyfile.
  • Worker mode. A aplicação inicializa uma vez e atende as requisições seguintes já carregada. O Laravel (via Octane) e o Symfony (via Runtime) têm integração oficial.
  • Extras de aplicação web. Early Hints (status 103), tempo real com Mercure, hot reload e X-Sendfile para arquivos 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
À esquerda, dois serviços e um socket FastCGI. À direita, um binário, um Caddyfile e um serviço — e a opção de manter a aplicação na memória.

Instalando no Debian e no Ubuntu

Há três caminhos: pacotes .deb/.rpm, binário estático e Docker. Para servidor, o pacote é o mais prático, porque traz serviço systemd, php.ini de produção e extensões instaláveis pelo apt. Os pacotes são mantidos pela equipe do projeto num repositório próprio, com uma variante por versão do 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

Testei esses comandos num Debian 13 limpo. O 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)

O pacote cria o usuário frankenphp (membro do grupo www-data), instala o serviço frankenphp.service e o CLI php-zts. Use esse CLI para Composer e scripts: na 1.13, o subcomando frankenphp php-cli passou a usar a SAPI de linha de comando nativa do PHP e, com PHP abaixo de 8.6, recusa rodar com a mensagem this functionality is not available.

Os arquivos ficam em:

Item Caminho (pacote deb/rpm)
Configuração do servidor /etc/frankenphp/Caddyfile
Configurações extras (carregadas sozinhas) /etc/frankenphp/Caddyfile.d/*.caddyfile
php.ini (já com preset de produção) /etc/php-zts/php.ini
ini adicionais /etc/php-zts/conf.d/*.ini
Site de exemplo /usr/share/frankenphp/

Extensões entram pelo apt com o prefixo php-zts-. A instalação base já traz OPcache, mbstring, curl, sodium, dom e outras; o resto se instala assim:

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

Prefere não adicionar repositório? O script oficial baixa o binário estático, que roda em qualquer distribuição sem dependências: curl https://frankenphp.dev/install.sh | sh. A troca é que extensões passam a ter de ser compiladas dentro do binário.

Modo clássico: substituindo nginx + PHP-FPM

No modo clássico, o FrankenPHP se comporta como a dupla tradicional: cada requisição carrega o script do zero. É o modo para qualquer aplicação PHP existente — WordPress, Nextcloud, um sistema legado. O Caddyfile de um site em produção:

{
	frankenphp
}

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

A diretiva php_server resume o que no nginx exigiria try_files, location ~ \.php$ e fastcgi_pass: serve arquivos estáticos, manda .php para o interpretador e redireciona o resto para o index.php. Como no Caddy, o domínio no endereço do bloco basta para o certificado ser emitido e renovado sozinho, com DNS apontado e portas 80 e 443 abertas.

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 testar sem tocar no serviço, rode na pasta do projeto frankenphp php-server: ele serve o diretório atual na hora. Para WordPress, a documentação oficial recomenda um bloco equivalente: domínio, php_server, compressão e log.

Worker mode: a aplicação fica na memória

Num framework moderno, boa parte do tempo de cada requisição vai para a inicialização: autoload, leitura de configuração, montagem do container de dependências. No worker mode, o script de entrada faz isso uma vez e entra num laço que atende requisição após requisição com frankenphp_handle_request(). As superglobais ($_GET, $_POST, $_SERVER) são renovadas a cada volta; o resto continua carregado.

Um worker mínimo, para ver o efeito:

<?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
}

No teste, três requisições seguidas responderam requisição 1, 2 e 3, todas com o mesmo horário de inicialização: o estado sobreviveu entre requisições. No modo clássico, o mesmo contador voltaria sempre a 1.

Isso é poderoso e cobra disciplina. Variáveis estáticas, singletons e conexões persistem entre usuários; vazamento de memória se acumula; uma exceção não tratada derruba o worker. Por isso a documentação recomenda capturar exceções dentro do handler, e o FrankenPHP reinicia workers que falham (o limite é configurável com max_consecutive_failures). Para mudanças de código em desenvolvimento, a opção watch reinicia o worker quando arquivos mudam.

Laravel e Symfony

Você não precisa escrever o laço à mão. No Laravel, o Octane cuida disso:

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

No Symfony, o componente Runtime tem suporte nativo; a configuração está na página do Symfony da documentação. Em ambos, rode os testes da aplicação em worker mode antes de ir para produção — código que assume “processo novo a cada requisição” aparece rápido.

Docker

A imagem oficial é dunglas/frankenphp, com variantes por versão do PHP e base Debian ou Alpine. Para subir o diretório atual:

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

Abra https://localhost (não https://127.0.0.1, para o qual não há certificado por padrão) e aceite o certificado local. Para uma imagem de produção, as extensões entram com o install-php-extensions que já vem na base, e o worker com a variável 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"

Prefira as imagens baseadas em Debian. As Alpine e o binário estático usam a musl libc, e a documentação lista incompatibilidades, como a falta da flag GLOB_BRACE do glob(). Para o básico de containers, veja o curso de Docker.

Cuidados antes de migrar

  • Extensões não thread safe. imap, o agente do New Relic e o pcov não funcionam; o imagick conflita com as threads do ImageMagick, e a documentação manda desligar essas threads; os perfiladores do Datadog e do Blackfire ainda têm limitações. A lista completa está em Known issues.
  • Threads, não processos. O padrão é o dobro do número de CPUs. Na 1.13, num_threads passou a contar só as threads para requisições fora dos workers, que entram por cima; quem fixava esse valor junto com workers deve revisar a configuração antes de atualizar.
  • Worker mode é opcional. Comece no modo clássico, que não muda nada no comportamento da aplicação, e só ative o worker depois de testar.
  • Mantenha-se atualizado. A 1.13 corrigiu, entre outras, uma falsificação de cabeçalho por nomes com ponto e um putenv() que vazava valores entre requisições. Atualizar é sudo apt upgrade.

Se você mantém várias versões de PHP lado a lado com nginx, o post Nginx e várias versões de PHP mostra a abordagem tradicional — útil para comparar e para as aplicações que ainda dependem de uma extensão incompatível.

Links

O FrankenPHP não pede que você reescreva nada para começar: no modo clássico, ele simplesmente troca dois serviços por um e traz o HTTPS automático do Caddy junto. O ganho grande vem depois, com o worker mode — e aí vale tratar a migração como mudança de arquitetura, com testes, e não como troca de servidor.