Por que um único plugin pode derrubar um site inteiro
Heckgalerie 1.2.5 e Leadlotse 1.4.3, dois dos nossos plugins do diretório do WordPress, juntos causavam um erro fatal em todas as páginas de um site. Nenhum plugin estava quebrado sozinho. A falha estava em uma biblioteca que cinco dos nossos plugins trazem e, em boa parte, em duas particularidades do Composer e do WordPress que tínhamos deixado passar. Isso afeta qualquer pessoa que compartilhe código entre plugins.
A mensagem de erro é curta. É assim que ela aparece, reproduzida no WordPress Playground:
PHP Fatal error: Uncaught Error: Call to undefined method
Hafenstudios\Core\Admin\DesignSystem::action_button()
in /wordpress/wp-content/plugins/heckgalerie/heckgalerie.php:163
O curioso: o método existe. O Heckgalerie o traz na própria cópia da biblioteca. Mesmo assim, o PHP carregou uma cópia mais antiga de outro plugin, o Leadlotse, que ainda não o tinha.
A falha foi nossa. Mas duas das suas três camadas estão na forma como Composer e WordPress interagem, e elas afetam qualquer pessoa que compartilhe código entre plugins. Por isso as registramos aqui.
Uma biblioteca, cinco cópias
Heckgalerie, Kurato, Leadlotse, Wellenbrecher e Wimpel compartilham uma biblioteca, o Hafenstudios Core (não confundir com nosso plugin Hafen Core): configurações, eventos, envio de e-mail, registro de consentimentos, as peças das telas de administração. É por meio dela que os plugins trabalham juntos. Quando um formulário do Leadlotse comunica um novo pedido, o Kurato escuta o mesmo evento e cria um contato.
Cada plugin traz a própria cópia, via Composer em vendor/hafenstudios/core. Precisa ser assim: um plugin do diretório também tem de funcionar sozinho. E como cada plugin tem seu próprio ritmo de versões, um site logo reúne várias versões lado a lado:
| Plugin | no diretório até 11/10/2026 | Core incluído | desde 12/10/2026 | Core incluído |
|---|---|---|---|---|
| Heckgalerie | 1.2.5 | 1.4.0 | 1.2.6 | 1.4.4 |
| Leadlotse | 1.4.3 | 1.3.0 | 1.5.1 | 1.4.4 |
| Wellenbrecher | 1.3.2 | 1.4.0 | 1.3.3 | 1.4.4 |
| Wimpel | 1.2.0 | 1.4.1 | 1.3.1 | 1.4.4 |
Em cada requisição, o PHP conhece só uma classe com um determinado nome. Quem carrega Hafenstudios\Core\Core primeiro define a classe para todos. A resposta habitual vem do universo do WooCommerce: o Action Scheduler é incluído por muitos plugins, cada cópia registra sua versão e, no hook plugins_loaded, só a mais alta é carregada. Foi exatamente assim que construímos.
A seleção depende de duas premissas em que quase ninguém pensa. Cada cópia precisa se registrar. E a cópia escolhida precisa de fato fornecer as classes. Nenhuma das duas se cumpria.
Primeira descoberta: o Composer se coloca na frente
O Composer gera um autoloader próprio para cada plugin. Ele se registra com a opção prepend, ou seja, no início da fila que o PHP consulta quando encontra classes desconhecidas. É o que consta no vendor/composer/autoload_real.php gerado:
$loader->register(true);
O WordPress carrega os plugins ativos na ordem da lista active_plugins, que ele ordena alfabeticamente ao ativá-los. Cada plugin a mais coloca seu carregador do Composer à frente dos anteriores. No fim, o primeiro da fila é o do plugin cuja pasta vem por último no alfabeto.
Nossa seleção de versão rodava depois, em plugins_loaded, e acrescentava o carregador da cópia escolhida no fim. Quando uma busca chegava lá, um carregador do Composer já tinha respondido havia muito tempo. Reproduzimos isso sem WordPress, com os pacotes reais do Heckgalerie 1.2.5 e do Leadlotse 1.4.3 do diretório, na ordem em que o WordPress os carrega. O resultado, resumido:
Entradas em __composer_autoload_files: 1
Registradas para a seleção: Core 1.4.0 (Heckgalerie)
Escolhida: Core 1.4.0 do Heckgalerie
DesignSystem fornecido por: Leadlotse (Core 1.3.0)
action_button() presente: não
A seleção informava 1.4.0, o código executado era 1.3.0. Nosso próprio verificador de versão só lia a constante definida pela seleção e, por isso, não viu nada.
O quanto o resultado era aleatório aparece em um teste no Playground com os quatro plugins antigos: o site funcionava. O Wellenbrecher vem depois do Leadlotse no alfabeto, então o carregador dele ficava na frente, e a cópia dele (1.4.0) conhece o método. O Wimpel vem ainda depois no alfabeto, mas a versão 1.2.0 traz um carregador próprio em vez do Composer, e esse entra no fim da fila. Se um site funcionava ou não dependia da ordem dos nomes dos plugins.
A primeira correção, o Core 1.4.2 de 6 de outubro, registra também com prepend o carregador da cópia escolhida. Além disso, as novas versões dos nossos plugins capturam erros na inicialização e os mostram no painel, em vez de derrubar o site. Essa correção só foi publicada em 12 de outubro, junto com as duas seguintes.
Segunda descoberta: o arquivo de inicialização roda só uma vez
Para código que precisa rodar logo no carregamento, como funções ou um registro, o Composer oferece o autoload files. Nossa biblioteca o usa para o seu bootstrap.php, no qual cada cópia se registra para a seleção. No composer.json da biblioteca está, entre outras coisas:
"autoload": {
"files": [ "bootstrap.php" ]
}
A partir disso, o Composer escreve esta trava em cada autoloader:
if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
$GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
require $file;
}
O Composer forma o identificador $fileIdentifier como o md5 do nome do pacote e do caminho. Para o nosso arquivo de inicialização, isso é md5('hafenstudios/core:bootstrap.php'), ou seja, b6716d317db73fd5f2cd1d7c8ab2ffd8, o mesmo valor em todos os plugins. E $GLOBALS vale para a requisição inteira, não para um único plugin. Assim que o arquivo de inicialização do primeiro plugin roda, o identificador fica anotado e o Composer pula o arquivo em todos os outros.
Dentro de um único projeto, isso é o certo: um arquivo de funções não roda duas vezes e o PHP não para com “Cannot redeclare”. Em um site WordPress com várias pastas vendor, a mesma trava atinge cópias diferentes em versões diferentes.
Para a nossa seleção de versão, isso significava que, em regra, só se registrava a cópia do plugin que o WordPress carrega primeiro, quase sempre o Heckgalerie no nosso caso. Na reprodução acima há exatamente uma entrada anotada, e o Leadlotse não aparece entre as cópias registradas, embora traga a sua. A seleção tinha um único candidato. Só percebemos isso em 11 de outubro, ao montar o Wimpel 1.3.1 no Playground.
Isso não é uma particularidade da nossa biblioteca. O symfony/polyfill-mbstring carrega seu bootstrap.php pelo mesmo caminho, e o Guzzle, seu arquivo de funções. Se dois plugins trazem o mesmo pacote sem mudar o namespace, o site executa o arquivo do plugin carregado primeiro, seja qual for a versão que o outro traz.
A correção no Core 1.4.4: cada plugin passa a incluir também por conta própria o seu arquivo de inicialização, direto no arquivo principal. De forma simplificada:
require_once __DIR__ . '/vendor/autoload.php';
// O Composer só executa bootstrap.php para o primeiro plugin carregado.
require_once __DIR__ . '/vendor/hafenstudios/core/bootstrap.php';
require_once lembra os arquivos pelo caminho. A cópia que o Composer já executou não roda uma segunda vez; todas as outras, exatamente uma. O Action Scheduler faz o mesmo, aliás: pelas instruções dele, cada plugin inclui o arquivo de inicialização diretamente. Além disso, desde a 1.4.4 cada cópia mantém um registro sob o próprio diretório, para que duas cópias com o mesmo número continuem visíveis.
Terceira descoberta: a fachada cortada
A terceira camada foi culpa nossa. Nossos pacotes para o diretório do WordPress são mais enxutos que os de distribuição própria: as partes da biblioteca de que um plugin não precisa ficam de fora, no Wimpel 1.2.0, por exemplo, o envio de e-mail, o registro de consentimentos e as ferramentas de privacidade. O script de build também alterava a classe central Core.php, pela qual todos os plugins acessam esses serviços, e cortava métodos como consent(), mailer() e privacy().
No teste no Playground com os quatro plugins antigos, isso não teve consequências. Lá, a cópia escolhida era a cortada do Wimpel 1.2.0, o Core 1.4.1, na época o número mais alto do diretório. Ela tinha se registrado apesar da trava porque o carregador próprio do Wimpel 1.2.0 inclui o arquivo de inicialização por conta própria com require_once. Mesmo assim, todas as classes carregadas vinham do Wellenbrecher. Desde a primeira correção, porém, o carregador da cópia escolhida fica na frente. Se então uma cópia cortada vence a seleção, todos os outros plugins ficam sem esses métodos e todo envio do Leadlotse falha. Esbarramos nisso ao construir as novas versões, antes de publicar qualquer uma delas.
A correção tem duas partes. Desde a 1.4.4, o Core.php fica completo em todos os pacotes e, na inicialização, a biblioteca verifica com class_exists se um serviço está disponível. Além disso, o carregador da cópia escolhida preenche as lacunas sozinho: se falta um arquivo, ele procura em todas as cópias registradas do site, em ordem decrescente de versão. Só se nenhuma o tiver é que os carregadores do Composer dos plugins entram em ação.
Em 12 de outubro, o teste de integração revelou mais um caso. Em uma versão intermediária da 1.4.4, ainda antes da publicação, o script de build continuava cortando ai() do Core.php nos pacotes sem funções de IA. Essa cópia cortada do pacote de diretório do Leadlotse e a completa do Kurato tinham ambas o número 1.4.4. Com o mesmo número, vencia a última carregada, a cortada, e o Kurato falhou em uma função de IA. Desde então ai() também permanece, e com números iguais entra na lista de seleção a cópia mais completa. Um número de versão deveria corresponder a um único conteúdo. Onde isso não é possível, a seleção precisa de um segundo critério.
Medido no WordPress Playground
Antes da publicação, os pacotes rodaram em onze combinações no WordPress Playground, com WordPress 7.1.3 e PHP 8.3, e requisições externas bloqueadas. No dia da publicação, medimos de novo, nas mesmas condições, o que um único plugin novo muda na antiga combinação que travava. Os principais resultados:
| Combinação | Resultado |
|---|---|
| Heckgalerie 1.2.5 e Leadlotse 1.4.3 do diretório | erro fatal em heckgalerie.php:163, a mensagem acima |
| Só Heckgalerie 1.2.6 novo; Leadlotse 1.4.3 antigo | funciona, DesignSystem do Heckgalerie (Core 1.4.4) |
| Só Leadlotse 1.5.1 novo; Heckgalerie 1.2.5 antigo | funciona, DesignSystem do Leadlotse (Core 1.4.4) |
| Só Wimpel 1.3.1 novo; Heckgalerie e Leadlotse antigos | funciona, formulário enviado, e-mail com tremas corretos |
| Só Kurato 1.9.3 novo; Heckgalerie 1.2.5 e Leadlotse 1.4.3 antigos | funciona, DesignSystem do Kurato (Core 1.4.4) |
| Heckgalerie, Leadlotse, Wellenbrecher e Wimpel novos do diretório | funciona, as quatro cópias registradas |
| Kurato 1.9.3 da distribuição própria; Heckgalerie, Leadlotse e Wellenbrecher novos do diretório | funciona, as quatro cópias registradas |
| De antigo para novo, um plugin de cada vez substituído por upload, com uma galeria e uma entrada de formulário | após cada troca, todas as páginas verificadas com status 200, conteúdos preservados |
A reprodução sem WordPress mostra o mesmo em escala menor. Com o Heckgalerie 1.2.6 no lugar do 1.2.5, o DesignSystem vem do Heckgalerie (Core 1.4.4); com o Leadlotse 1.5.1 no lugar do 1.4.3, do Leadlotse (Core 1.4.4). Nos dois casos o método está lá.
O que o teste não cobre: cliques em um navegador real, multisite, a atualização pelo botão “Atualizar agora”, atualizações automáticas por cron, outras versões de PHP e WordPress e, na troca de antigo para novo, só uma das 24 ordens possíveis.
O que você pode fazer como responsável pelo site
- Atualize juntos os plugins do mesmo fabricante. Quando plugins compartilham uma biblioteca, o estado misto é o arriscado. No nosso caso: Heckgalerie 1.2.6, Leadlotse 1.5.1, Wellenbrecher 1.3.3 e Wimpel 1.3.1 estão no diretório do WordPress desde 12 de outubro de 2026, e o Kurato 1.9.3 chega pelo nosso próprio canal de atualizações.
- Se for urgente, um já ajuda. Cada um desses pacotes traz o Core 1.4.4. Essa cópia vence a seleção e, como o carregador dela fica na frente, é ela que fornece as classes, também para os plugins mais antigos. No Playground, a antiga combinação que travava, Heckgalerie 1.2.5 e Leadlotse 1.4.3, funcionava sem erro fatal assim que um dos dois estava atualizado. O mesmo valia quando, em vez disso, entrava o Wimpel 1.3.1 ou o Kurato 1.9.3. Não testamos todas as combinações mistas; o trabalho só termina quando todos estiverem atualizados.
- Se o site já caiu, use o link para o modo de recuperação que o WordPress envia ao e-mail do administrador depois de um erro grave. Sem e-mail? Renomeie a pasta do último plugin que você ativou ou atualizou em
wp-content/plugins/, por SFTP ou pelo gerenciador de arquivos da hospedagem. - Leia a mensagem de erro com atenção. Se ela cita um método supostamente inexistente em uma classe de um namespace que vários dos seus plugins trazem, um conflito de versões é mais provável do que um erro de programação no plugin citado. Para descobrir qual plugin está envolvido, desative os plugins um por um. Sem afetar os visitantes, dá para fazer isso com o plugin Health Check & Troubleshooting: o modo de solução de problemas dele desativa todos os plugins só para a sua própria sessão, e você os reativa um a um.
Como organizar as atualizações para que uma queda não pegue você de surpresa está em atualizar plugins WordPress.
O que desenvolvedores de plugins podem levar daqui
- Não deixe o registro a cargo do autoload files. O que cada cópia precisa fazer vai no arquivo principal, com
require_once. O autoloadfilesexecuta um arquivo uma vez por requisição, não uma vez por plugin. - Registre com prepend o carregador da cópia escolhida. Caso contrário, respondem os carregadores do Composer dos plugins, e vence quem foi carregado por último:
spl_autoload_register( $carregador, true, true ); // terceiro argumento: prepend - Nunca corte a fachada por pacote. Deixe arquivos de fora se for preciso, mas não os métodos da classe que todos os plugins chamam. Se um serviço está disponível,
class_existsverifica em tempo de execução. - Um número de versão, um conteúdo. Se uma cópia cortada e uma completa têm o mesmo número, a seleção não consegue distingui-las. Aí ela precisa de um segundo critério; o nosso é a completude.
- Verifique a origem, não a constante. O que o PHP realmente carregou é revelado pelo arquivo por trás da classe:
$arquivo = ( new \ReflectionClass( \Hafenstudios\Core\Admin\DesignSystem::class ) )->getFileName(); - Teste ao lado de versões antigas. A falha só aparece quando um plugin novo roda ao lado de um antigo, nunca isoladamente. O diretório do WordPress continua oferecendo versões anteriores em ZIP.
- Se não precisa compartilhar nada, use prefixos. Ferramentas como PHP-Scoper ou Strauss dão a cada plugin seu próprio namespace, e assim não existe classe compartilhada para disputar. Nós compartilhamos de propósito, porque nossos plugins conversam entre si pela biblioteca. Se você não precisa disso, prefixar é mais simples.
Site fora do ar?
Tela branca, erro fatal depois de uma atualização, formulário que não envia mais: no nosso suporte emergencial para WordPress começamos em até quatro horas em dias úteis, sem contrato e sem pagamento antecipado. 185 € por hora.
Perguntas frequentes
Por que um único plugin pode derrubar um site WordPress inteiro?
Todos os plugins ativos rodam no mesmo processo PHP. Se um deles para com um erro fatal, a requisição inteira para. Quando vários plugins trazem a mesma biblioteca, surge um segundo caminho: o PHP conhece só uma classe por nome, e a cópia de um plugin pode substituir a de outro, mesmo sendo mais antiga.
O que é o autoload files do Composer?
Uma entrada no composer.json que faz o Composer executar arquivos assim que o autoloader é carregado, por exemplo para funções ou registros. Cada arquivo recebe um identificador formado pelo md5 do nome do pacote e do caminho, e o Composer o executa só uma vez por requisição. Se vários plugins trazem o mesmo pacote, os arquivos têm o mesmo identificador e só roda o do plugin carregado primeiro.
Como descubro de qual plugin uma classe foi carregada?
Com a reflexão do PHP: getFileName() em uma ReflectionClass da classe devolve o caminho do arquivo de onde o PHP a carregou. Se ele apontar para a pasta vendor de outro plugin, quem fornece a classe é uma cópia alheia. Uma constante de versão não prova isso, ela só diz qual cópia foi escolhida.
Preciso atualizar todos os plugins da Hafenstudios ao mesmo tempo?
Sim, de preferência todos juntos. Heckgalerie 1.2.6, Leadlotse 1.5.1, Wellenbrecher 1.3.3, Wimpel 1.3.1 e Kurato 1.9.3 trazem o Core 1.4.4. No WordPress Playground, a antiga combinação que travava, Heckgalerie 1.2.5 e Leadlotse 1.4.3, já funcionava sem erro fatal assim que Heckgalerie, Leadlotse, Wimpel ou Kurato estava na versão nova. Mas não testamos todas as combinações mistas. A instalação só fica completa quando todos estiverem atualizados.
Como desenvolvedores de plugins evitam conflitos com bibliotecas compartilhadas?
Se você não precisa de estado compartilhado, prefixe os namespaces de cada plugin, por exemplo com PHP-Scoper ou Strauss. Se compartilha uma biblioteca de propósito, registre cada cópia você mesmo com require_once, registre o carregador da cópia escolhida com prepend, nunca corte a classe compartilhada por pacote e use cada número de versão para um único conteúdo.