Por qué un solo plugin puede tumbar una web entera
Heckgalerie 1.2.5 y Leadlotse 1.4.3, dos de nuestros plugins del directorio de WordPress, provocaban juntos un error fatal en todas las páginas de una web. Ningún plugin estaba roto por sí solo. El fallo estaba en una biblioteca que llevan cinco de nuestros plugins y, en buena parte, en dos particularidades de Composer y WordPress que habíamos pasado por alto. Eso afecta a cualquiera que comparta código entre plugins.
El mensaje de error es breve. Así se ve, reproducido en 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
Lo curioso: el método existe. Heckgalerie lo trae en su propia copia de la biblioteca. Aun así, PHP cargó una copia más antigua de otro plugin, Leadlotse, que todavía no lo tenía.
El fallo fue nuestro. Pero dos de sus tres capas están en la forma en que interactúan Composer y WordPress, y afectan a cualquiera que comparta código entre plugins. Por eso las contamos aquí.
Una biblioteca, cinco copias
Heckgalerie, Kurato, Leadlotse, Wellenbrecher y Wimpel comparten una biblioteca, el Hafenstudios Core (no confundir con nuestro plugin Hafen Core): ajustes, eventos, envío de correo, registro de consentimientos, las piezas de las pantallas de administración. A través de ella los plugins trabajan juntos. Cuando un formulario de Leadlotse comunica una nueva solicitud, Kurato escucha el mismo evento y crea un contacto.
Cada plugin trae su propia copia, mediante Composer en vendor/hafenstudios/core. Tiene que ser así: un plugin del directorio debe funcionar también solo. Y como cada plugin tiene su propio ritmo de versiones, en una web enseguida conviven varias versiones:
| Plugin | en el directorio hasta el 11/10/2026 | Core incluido | desde el 12/10/2026 | Core incluido |
|---|---|---|---|---|
| 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 |
En cada petición PHP solo conoce una clase con un nombre dado. Quien carga primero Hafenstudios\Core\Core la fija para todos. La respuesta habitual viene del entorno de WooCommerce: Action Scheduler lo incluyen muchos plugins, cada copia registra su versión y en el hook plugins_loaded solo se carga la más alta. Exactamente así lo construimos.
La selección se apoya en dos supuestos en los que casi nadie piensa. Cada copia tiene que registrarse. Y la copia elegida tiene que entregar de verdad las clases. No se cumplía ninguno de los dos.
Primer hallazgo: Composer se pone delante
Composer genera un autoloader propio para cada plugin. Se registra con la opción prepend, es decir, al principio de la cola a la que PHP pregunta por clases desconocidas. Así figura en el vendor/composer/autoload_real.php generado:
$loader->register(true);
WordPress carga los plugins activos en el orden de la lista active_plugins, que ordena alfabéticamente al activarlos. Cada plugin adicional coloca su cargador de Composer delante de los anteriores. Al final, el primero de la cola es el del plugin cuya carpeta va última en el alfabeto.
Nuestra selección de versión se ejecutaba después, en plugins_loaded, y añadía el cargador de la copia elegida al final. Cuando una búsqueda llegaba hasta ahí, un cargador de Composer ya había respondido hacía rato. Lo reprodujimos sin WordPress, con los paquetes reales de Heckgalerie 1.2.5 y Leadlotse 1.4.3 del directorio, en el orden en que WordPress los carga. El resultado, resumido:
Entradas en __composer_autoload_files: 1
Registradas para la selección: Core 1.4.0 (Heckgalerie)
Elegida: Core 1.4.0 de Heckgalerie
DesignSystem aportado por: Leadlotse (Core 1.3.0)
action_button() presente: no
La selección decía 1.4.0, el código que se ejecutó era 1.3.0. Nuestro propio vigilante de versiones solo leía la constante que fija la selección, así que no vio nada.
Lo arbitrario del resultado se ve en una prueba en Playground con los cuatro plugins antiguos: la web funcionaba. Wellenbrecher va después de Leadlotse en el alfabeto, así que su cargador estaba delante, y su copia (1.4.0) conoce el método. Wimpel va aún más tarde en el alfabeto, pero la versión 1.2.0 trae su propio cargador en lugar del de Composer, y ese se coloca al final de la cola. Que una web funcionara dependía del orden de los nombres de los plugins.
La primera corrección, Core 1.4.2 del 6 de octubre, registra también con prepend el cargador de la copia elegida. Además, las nuevas versiones de nuestros plugins capturan los errores durante el arranque y los muestran en el escritorio, en lugar de tumbar la web. Esta corrección no se publicó hasta el 12 de octubre, junto con las dos siguientes.
Segundo hallazgo: el archivo de arranque se ejecuta una sola vez
Para código que debe ejecutarse nada más cargar, como funciones o un registro, Composer ofrece el autoload files. Nuestra biblioteca lo usa para su bootstrap.php, en el que cada copia se registra para la selección. En el composer.json de la biblioteca pone, entre otras cosas:
"autoload": {
"files": [ "bootstrap.php" ]
}
A partir de ahí, Composer escribe esta barrera en cada autoloader:
if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
$GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
require $file;
}
Composer forma el identificador $fileIdentifier como el md5 del nombre del paquete y la ruta. Para nuestro archivo de arranque es md5('hafenstudios/core:bootstrap.php'), es decir, b6716d317db73fd5f2cd1d7c8ab2ffd8, el mismo valor en todos los plugins. Y $GLOBALS vale para toda la petición, no para un plugin concreto. En cuanto se ha ejecutado el archivo de arranque del primer plugin, el identificador queda anotado y Composer se salta el archivo en todos los demás.
Dentro de un único proyecto eso es lo correcto: un archivo de funciones no se ejecuta dos veces y PHP no se detiene con «Cannot redeclare». En una web WordPress con varias carpetas vendor, la misma barrera afecta a copias distintas en versiones distintas.
Para nuestra selección de versión significaba que, por regla general, solo se registraba la copia del plugin que WordPress carga primero, casi siempre Heckgalerie en nuestro caso. En la reproducción de arriba hay exactamente una entrada anotada, y Leadlotse no aparece entre las copias registradas, aunque trae la suya. La selección tenía un único candidato. No nos dimos cuenta hasta el 11 de octubre, al construir Wimpel 1.3.1 en Playground.
No es una particularidad de nuestra biblioteca. symfony/polyfill-mbstring carga su bootstrap.php por la misma vía, y Guzzle su archivo de funciones. Si dos plugins incluyen el mismo paquete sin cambiar su espacio de nombres, en la web se ejecuta el archivo del plugin que se carga primero, traiga la otra la versión que traiga.
La corrección en Core 1.4.4: cada plugin incluye además su archivo de arranque por sí mismo, directamente en el archivo principal. Simplificado:
require_once __DIR__ . '/vendor/autoload.php';
// Composer solo ejecuta bootstrap.php para el primer plugin cargado.
require_once __DIR__ . '/vendor/hafenstudios/core/bootstrap.php';
require_once recuerda los archivos por su ruta. La copia que Composer ya ejecutó no se ejecuta una segunda vez, todas las demás exactamente una. Action Scheduler lo hace igual, por cierto: según sus instrucciones, cada plugin incluye el archivo de arranque directamente. Además, desde la 1.4.4 cada copia deja una entrada bajo su propio directorio, para que dos copias con el mismo número sigan siendo visibles.
Tercer hallazgo: la fachada recortada
La tercera capa fue cosa nuestra. Nuestros paquetes para el directorio de WordPress son más ligeros que los de distribución propia: las partes de la biblioteca que un plugin no necesita se quedan fuera, en Wimpel 1.2.0 por ejemplo el envío de correo, el registro de consentimientos y las herramientas de privacidad. El script de compilación también modificaba la clase central Core.php, a través de la cual todos los plugins acceden a esos servicios, y eliminaba métodos como consent(), mailer() y privacy().
En la prueba en Playground con los cuatro plugins antiguos, eso no tuvo consecuencias. Allí estaba elegida la copia recortada de Wimpel 1.2.0, Core 1.4.1, entonces el número más alto del directorio. Se había registrado pese a la barrera porque el cargador propio de Wimpel 1.2.0 incluye el archivo de arranque por sí mismo con require_once. Aun así, todas las clases cargadas venían de Wellenbrecher. Desde la primera corrección, sin embargo, el cargador de la copia elegida está delante. Si entonces gana la selección una copia recortada, a todos los demás plugins les faltan esos métodos y cada envío de Leadlotse falla. Nos topamos con ello al construir las nuevas versiones, antes de publicar ninguna.
La corrección tiene dos partes. Desde la 1.4.4, Core.php permanece completo en todos los paquetes y, al arrancar, la biblioteca comprueba con class_exists si un servicio está disponible. Además, el cargador de la copia elegida rellena él mismo los huecos: si le falta un archivo, lo busca en todas las copias registradas de la web, en orden descendente de versión. Solo si ninguna lo tiene entran en juego los cargadores de Composer de los plugins.
El 12 de octubre, la prueba de integración destapó un caso más. En una versión intermedia de 1.4.4, todavía antes de publicar, el script de compilación seguía quitando ai() de Core.php en los paquetes sin funciones de IA. Esa copia recortada del paquete de directorio de Leadlotse y la completa de Kurato llevaban ambas el número 1.4.4. Con el mismo número ganaba la última cargada, la recortada, y Kurato falló en una función de IA. Desde entonces ai() también se queda, y a igualdad de número entra en la lista de selección la copia más completa. Un número de versión debería corresponder a un único contenido. Donde no puede, la selección necesita un segundo criterio.
Medido en WordPress Playground
Antes de publicar, los paquetes se probaron en once combinaciones en WordPress Playground, con WordPress 7.1.3 y PHP 8.3, y las peticiones externas bloqueadas. El día de la publicación medimos de nuevo, en las mismas condiciones, qué cambia un solo plugin nuevo en la antigua combinación que fallaba. Los resultados principales:
| Combinación | Resultado |
|---|---|
| Heckgalerie 1.2.5 y Leadlotse 1.4.3 del directorio | error fatal en heckgalerie.php:163, el mensaje de arriba |
| Solo Heckgalerie 1.2.6 nuevo; Leadlotse 1.4.3 antiguo | funciona, DesignSystem de Heckgalerie (Core 1.4.4) |
| Solo Leadlotse 1.5.1 nuevo; Heckgalerie 1.2.5 antiguo | funciona, DesignSystem de Leadlotse (Core 1.4.4) |
| Solo Wimpel 1.3.1 nuevo; Heckgalerie y Leadlotse antiguos | funciona, formulario enviado, correo con diéresis correctas |
| Solo Kurato 1.9.3 nuevo; Heckgalerie 1.2.5 y Leadlotse 1.4.3 antiguos | funciona, DesignSystem de Kurato (Core 1.4.4) |
| Heckgalerie, Leadlotse, Wellenbrecher y Wimpel nuevos del directorio | funciona, las cuatro copias registradas |
| Kurato 1.9.3 de distribución propia; Heckgalerie, Leadlotse y Wellenbrecher nuevos del directorio | funciona, las cuatro copias registradas |
| De antiguo a nuevo, un plugin tras otro sustituido mediante subida, con una galería y una entrada de formulario | tras cada cambio, todas las páginas revisadas con estado 200, contenidos conservados |
La reproducción sin WordPress muestra lo mismo en pequeño. Con Heckgalerie 1.2.6 en lugar de 1.2.5, DesignSystem viene de Heckgalerie (Core 1.4.4); con Leadlotse 1.5.1 en lugar de 1.4.3, de Leadlotse (Core 1.4.4). En ambos casos el método está.
Lo que la prueba no cubre: clics en un navegador real, multisitio, la actualización con el botón «Actualizar ahora», las actualizaciones automáticas por cron, otras versiones de PHP y WordPress y, al pasar de antiguo a nuevo, solo uno de los 24 órdenes posibles.
Qué puedes hacer si gestionas una web
- Actualiza juntos los plugins de un mismo fabricante. Cuando varios plugins comparten una biblioteca, el estado mixto es el arriesgado. En nuestro caso: Heckgalerie 1.2.6, Leadlotse 1.5.1, Wellenbrecher 1.3.3 y Wimpel 1.3.1 están en el directorio de WordPress desde el 12 de octubre de 2026, y Kurato 1.9.3 llega por nuestro propio canal de actualizaciones.
- Si corre prisa, uno ya ayuda. Cada uno de estos paquetes incluye Core 1.4.4. Esa copia gana la selección y, como su cargador está delante, es la que entrega las clases, también a los plugins más antiguos. En Playground, la antigua combinación que fallaba, Heckgalerie 1.2.5 y Leadlotse 1.4.3, funcionaba sin error fatal en cuanto uno de los dos estaba actualizado. Lo mismo ocurría si en su lugar se añadía Wimpel 1.3.1 o Kurato 1.9.3. No hemos probado todas las mezclas; la tarea está terminada cuando todos están actualizados.
- Si la web ya ha caído, usa el enlace al modo de recuperación que WordPress envía a la dirección del administrador tras un error grave. ¿Sin correo? Cambia el nombre de la carpeta del último plugin que activaste o actualizaste en
wp-content/plugins/, por SFTP o con el gestor de archivos del hosting. Qué más ayuda ante una página en blanco lo explicamos en nuestro artículo sobre la pantalla blanca en WordPress. - Lee con atención el mensaje de error. Si menciona un método supuestamente inexistente en una clase de un espacio de nombres que incluyen varios de tus plugins, es más probable un conflicto de versiones que un fallo de programación en el plugin mencionado. Para saber qué plugin está implicado, desactiva los plugins uno a uno. Sin afectar a los visitantes puedes hacerlo con el plugin Health Check & Troubleshooting: su modo de resolución de problemas desactiva todos los plugins solo para tu propia sesión, y tú los vuelves a activar uno a uno.
Cómo organizar las actualizaciones para que una caída no te pille por sorpresa lo contamos en actualizar plugins de WordPress.
Qué pueden llevarse los desarrolladores de plugins
- No dejes el registro en manos del autoload files. Lo que cada copia debe hacer va en el archivo principal, con
require_once. El autoloadfilesejecuta un archivo una vez por petición, no una vez por plugin. - Registra con prepend el cargador de la copia elegida. Si no, responden los cargadores de Composer de los plugins y gana el último que se cargó:
spl_autoload_register( $cargador, true, true ); // tercer argumento: prepend - No recortes nunca la fachada por paquete. Deja fuera archivos si hace falta, pero no los métodos de la clase a la que llaman todos los plugins. Si un servicio está disponible lo comprueba
class_existsen tiempo de ejecución. - Un número de versión, un contenido. Si una copia recortada y una completa llevan el mismo número, la selección no puede distinguirlas. Entonces necesita un segundo criterio; el nuestro es la completitud.
- Comprueba el origen, no la constante. Lo que PHP cargó de verdad lo revela el archivo que hay detrás de la clase:
$archivo = ( new \ReflectionClass( \Hafenstudios\Core\Admin\DesignSystem::class ) )->getFileName(); - Prueba junto a versiones antiguas. El fallo solo aparece cuando un plugin nuevo funciona al lado de uno antiguo, nunca por separado. El directorio de WordPress sigue ofreciendo las versiones anteriores en ZIP.
- Si no necesitas compartir nada, usa prefijos. Herramientas como PHP-Scoper o Strauss dan a cada plugin su propio espacio de nombres, así no hay ninguna clase compartida por la que pelearse. Nosotros compartimos a propósito, porque nuestros plugins se comunican a través de la biblioteca. Si no lo necesitas, los prefijos son más sencillos.
¿Tu web no responde?
Pantalla blanca, error fatal tras una actualización, un formulario que ya no envía nada: con nuestra ayuda urgente para WordPress empezamos en un plazo de cuatro horas en días laborables, sin contrato y sin pago por adelantado. 185 € por hora.
Preguntas frecuentes
¿Por qué un solo plugin puede tumbar toda una web WordPress?
Todos los plugins activos se ejecutan en el mismo proceso de PHP. Si uno se detiene con un error fatal, se detiene toda la petición. Cuando varios plugins incluyen la misma biblioteca, aparece una segunda vía: PHP solo conoce una clase por nombre, y la copia de un plugin puede sustituir a la de otro, aunque sea más antigua.
¿Qué es el autoload files de Composer?
Una entrada en composer.json con la que Composer ejecuta archivos en cuanto se carga el autoloader, por ejemplo para funciones o registros. Cada archivo recibe un identificador formado por el md5 del nombre del paquete y la ruta, y Composer lo ejecuta una sola vez por petición. Si varios plugins incluyen el mismo paquete, sus archivos comparten ese identificador y solo se ejecuta el del plugin que se carga primero.
¿Cómo averiguo desde qué plugin se cargó una clase?
Con la reflexión de PHP: getFileName() sobre una ReflectionClass de la clase devuelve la ruta del archivo desde el que PHP la cargó. Si apunta a la carpeta vendor de otro plugin, la clase la está aportando una copia ajena. Una constante de versión no lo demuestra, solo indica qué copia se seleccionó.
¿Tengo que actualizar todos los plugins de Hafenstudios a la vez?
Sí, mejor todos a la vez. Heckgalerie 1.2.6, Leadlotse 1.5.1, Wellenbrecher 1.3.3, Wimpel 1.3.1 y Kurato 1.9.3 incluyen Core 1.4.4. En WordPress Playground, la antigua combinación que fallaba, Heckgalerie 1.2.5 y Leadlotse 1.4.3, ya funcionaba sin error fatal en cuanto Heckgalerie, Leadlotse, Wimpel o Kurato estaba en su versión nueva. Pero no hemos probado todas las mezclas. La instalación queda completa cuando todos están actualizados.
¿Cómo evitan los desarrolladores conflictos con bibliotecas compartidas?
Si no necesitas estado compartido, añade un prefijo a los espacios de nombres de cada plugin, por ejemplo con PHP-Scoper o Strauss. Si compartes una biblioteca a propósito, registra cada copia tú mismo con require_once, registra el cargador de la copia elegida con prepend, no recortes nunca la clase compartida por paquete y asigna cada número de versión a un único contenido.