
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.

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 opcovnão funcionam; oimagickconflita 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_threadspassou 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
- frankenphp.dev · documentação · github.com/php/frankenphp
- Worker mode · Configuração · Laravel · Produção
- Aqui no blog: Caddy no Linux · A história da linguagem Go
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.