Fleet

Why a single plugin can take down a whole website

Heckgalerie 1.2.5 and Leadlotse 1.4.3, two of our plugins from the WordPress directory, together caused a fatal error on every page of a site. No single plugin was broken on its own. The fault sat in a library that five of our plugins ship, and to a large degree in two quirks of Composer and WordPress that we had overlooked. That affects anyone who shares code between plugins.

The error message is short. This is what it looks like, reproduced in 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

The odd part: the method exists. Heckgalerie ships it in its own copy of the library. PHP still loaded an older copy from another plugin, Leadlotse, which did not have it yet.

The fault was ours. Two of its three layers, however, sit in the way Composer and WordPress interact, and those affect anyone who shares code between plugins. That is why we are writing them down.

One library, five copies

Heckgalerie, Kurato, Leadlotse, Wellenbrecher and Wimpel share one library, the Hafenstudios Core (not to be confused with our Hafen Core plugin): settings, events, mail delivery, consent log, the building blocks of the admin screens. The plugins work together through it. When a Leadlotse form reports a new enquiry, Kurato listens for the same event and creates a contact.

Each plugin ships its own copy, via Composer under vendor/hafenstudios/core. It has to: a plugin from the directory must also work on its own. And since every plugin has its own release rhythm, a website quickly ends up with several versions side by side:

Pluginin the directory until 11 Oct 2026bundled Coresince 12 Oct 2026bundled Core
Heckgalerie1.2.51.4.01.2.61.4.4
Leadlotse1.4.31.3.01.5.11.4.4
Wellenbrecher1.3.21.4.01.3.31.4.4
Wimpel1.2.01.4.11.3.11.4.4

Within one request PHP knows only one class per name. Whoever loads Hafenstudios\Core\Core first decides it for everyone. The usual answer comes from the WooCommerce world: Action Scheduler is bundled by many plugins, each copy registers its version, and on the plugins_loaded hook only the highest one is loaded. That is exactly what we built.

The selection rests on two assumptions people rarely think about. Every copy has to register. And the selected copy has to actually deliver the classes. Neither held.

First finding: Composer puts itself first

Composer generates a separate autoloader for each plugin. It registers with the prepend flag, at the front of the queue PHP asks about unknown classes. This is what the generated vendor/composer/autoload_real.php says:

$loader->register(true);

WordPress loads active plugins in the order of the active_plugins list, which it sorts alphabetically on activation. Each further plugin pushes its Composer loader in front of the previous ones. In the end, the loader at the very front belongs to the plugin whose folder name comes last in the alphabet.

Our version selection ran later, on plugins_loaded, and appended the selected copy’s loader at the back. By the time a lookup got there, a Composer loader had long since answered. We reproduced this without WordPress, using the real directory packages of Heckgalerie 1.2.5 and Leadlotse 1.4.3, in the order WordPress loads them. The result, summarised:

Entries in __composer_autoload_files:  1
Registered for selection:              Core 1.4.0 (Heckgalerie)
Selected:                              Core 1.4.0 from Heckgalerie
DesignSystem supplied by:              Leadlotse (Core 1.3.0)
action_button() present:               no

The selection reported 1.4.0, the code that ran was 1.3.0. Our own version guard only read the constant the selection sets, so it saw nothing.

How arbitrary the outcome was shows in a Playground run with all four old plugins: the site worked. Wellenbrecher comes after Leadlotse in the alphabet, so its loader stood in front, and its copy (1.4.0) knows the method. Wimpel comes even later in the alphabet, but version 1.2.0 ships its own loader instead of Composer’s, and that one joins the back of the queue. Whether a website ran depended on the order of the plugin names.

The first fix, Core 1.4.2 from 6 October, registers the selected copy’s loader with prepend as well. On top of that, the new versions of our plugins catch errors during start-up and report them in the admin instead of taking the site down. This fix only shipped on 12 October, together with the two that follow.

Second finding: the bootstrap file runs only once

For code that must run immediately on load, such as functions or a registration, Composer offers the files autoload. Our library uses it for its bootstrap.php, where each copy registers for selection. The library’s composer.json contains, among other things:

"autoload": {
    "files": [ "bootstrap.php" ]
}

From this, Composer writes the following guard into every autoloader:

if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
    $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
    require $file;
}

Composer builds the identifier $fileIdentifier as the md5 of package name and path. For our bootstrap file that is md5('hafenstudios/core:bootstrap.php'), or b6716d317db73fd5f2cd1d7c8ab2ffd8, the same value in every plugin. And $GLOBALS covers the whole request, not a single plugin. Once the first plugin’s bootstrap file has run, the identifier is on record, and Composer skips the file in every other plugin.

Within a single project that is the right call: a functions file does not run twice and PHP does not stop with “Cannot redeclare”. On a WordPress site with several vendor folders, the same guard hits different copies in different versions.

For our version selection this meant that, as a rule, only the copy of the plugin WordPress loads first registered, usually Heckgalerie in our case. In the reproduction above there is exactly one entry on record, and Leadlotse does not appear among the registered copies, even though it ships its own. The selection had a single candidate. We only noticed on 11 October, while building Wimpel 1.3.1 in Playground.

This is not peculiar to our library. symfony/polyfill-mbstring loads its bootstrap.php the same way, and Guzzle its functions file. If two plugins ship the same package without changing its namespace, the site runs the file of the plugin loaded first, whatever version the other one brings along.

The fix in Core 1.4.4: each plugin also includes its bootstrap file itself, right in the main plugin file. Simplified:

require_once __DIR__ . '/vendor/autoload.php';
// Composer runs bootstrap.php only for the plugin loaded first.
require_once __DIR__ . '/vendor/hafenstudios/core/bootstrap.php';

require_once tracks files by path. The copy Composer already ran does not run a second time, every other copy runs exactly once. Action Scheduler does the same, by the way: its instructions have each plugin include the bootstrap file directly. In addition, since 1.4.4 every copy keeps an entry under its own directory, so two copies with the same number both stay visible.

Third finding: the trimmed facade

The third layer was of our own making. Our directory packages are leaner than the ones we distribute ourselves: parts of the library a plugin does not need are left out, in Wimpel 1.2.0 for example mail delivery, the consent log and privacy tools. The build script also edited the central class Core.php, through which every plugin reaches these services, and cut out methods such as consent(), mailer() and privacy().

In the Playground run with all four old plugins, this had no effect. The trimmed copy from Wimpel 1.2.0 was selected there, Core 1.4.1, at the time the highest number in the directory. It had registered despite the guard because Wimpel 1.2.0’s own loader includes the bootstrap file itself with require_once. Even so, every class that was loaded came from Wellenbrecher. Since the first fix, however, the selected copy’s loader sits at the front. If a trimmed copy then wins the selection, every other plugin loses these methods, and every Leadlotse submission fails. We ran into this while building the new versions, before any of them shipped.

The fix has two parts. Since 1.4.4, Core.php stays complete in every package, and at start-up the library checks with class_exists whether a service is there. On top of that, the selected copy’s loader fills gaps itself: if a file is missing, it searches all registered copies on the site, in descending version order. Only if none has it do the plugins’ Composer loaders get a turn.

On 12 October the integration test turned up one more case. In an interim build of 1.4.4, before anything shipped, the build script still cut ai() out of Core.php for packages without AI features. That trimmed copy from Leadlotse’s directory package and the complete one from Kurato both carried the number 1.4.4. With equal numbers, the copy loaded last won, the trimmed one, and Kurato failed in an AI feature. Since then ai() stays in as well, and with equal numbers the most complete copy goes on the selection list. A version number should stand for exactly one set of contents. Where it cannot, the selection needs a second criterion.

Measured in WordPress Playground

Before shipping, the packages ran in eleven combinations in WordPress Playground, on WordPress 7.1.3 and PHP 8.3, with external requests blocked. On release day we measured again, under the same conditions, what a single new plugin does to the former crash combination. The main results:

CombinationResult
Heckgalerie 1.2.5 and Leadlotse 1.4.3 from the directoryfatal error in heckgalerie.php:163, the message above
Only Heckgalerie 1.2.6 new, Leadlotse 1.4.3 oldruns, DesignSystem from Heckgalerie (Core 1.4.4)
Only Leadlotse 1.5.1 new, Heckgalerie 1.2.5 oldruns, DesignSystem from Leadlotse (Core 1.4.4)
Only Wimpel 1.3.1 new, Heckgalerie and Leadlotse oldruns, form submitted, mail with correct umlauts
Only Kurato 1.9.3 new, Heckgalerie 1.2.5 and Leadlotse 1.4.3 oldruns, DesignSystem from Kurato (Core 1.4.4)
Heckgalerie, Leadlotse, Wellenbrecher and Wimpel new from the directoryruns, all four copies registered
Kurato 1.9.3 self-distributed, Heckgalerie, Leadlotse and Wellenbrecher new from the directoryruns, all four copies registered
From old to new, one plugin at a time replaced by upload, with one gallery and one form entryafter every swap all checked pages returned status 200, content intact

The reproduction without WordPress shows the same in miniature. With Heckgalerie 1.2.6 instead of 1.2.5, DesignSystem comes from Heckgalerie (Core 1.4.4); with Leadlotse 1.5.1 instead of 1.4.3, from Leadlotse (Core 1.4.4). In both cases the method is there.

What the test does not cover: clicks in a real browser, multisite, updating via the “Update now” button, automatic updates via cron, other PHP and WordPress versions, and only one of 24 possible orders when swapping old for new.

What you can do as a site owner

  • Update plugins from the same vendor together. When plugins share a library, the mixed state is the risky one. For ours that means: Heckgalerie 1.2.6, Leadlotse 1.5.1, Wellenbrecher 1.3.3 and Wimpel 1.3.1 have been in the WordPress directory since 12 October 2026, and Kurato 1.9.3 comes through our own update channel.
  • If it has to be quick, one already helps. Each of these packages ships Core 1.4.4. That copy wins the selection, and because its loader sits at the front, it supplies the classes, for the older plugins too. In Playground, the former crash combination of Heckgalerie 1.2.5 and Leadlotse 1.4.3 ran without a fatal error as soon as one of the two was updated. The same held when Wimpel 1.3.1 or Kurato 1.9.3 was added instead. We have not tested every mix; the job is done once all of them are updated.
  • If the site is already down, use the recovery mode link WordPress emails to the admin address after a fatal error. No email? Rename the folder of the plugin you activated or updated last under wp-content/plugins/, via SFTP or your host’s file manager. More on getting out of a blank page is in our guide to the white screen of death.
  • Read the error message closely. If it names a supposedly missing method in a class from a namespace that several of your plugins ship, a version conflict is more likely than a bug in the plugin it mentions. To find the plugin involved, deactivate plugins one at a time. The Health Check & Troubleshooting plugin does this without affecting visitors: its troubleshooting mode disables all plugins for your own session only, and you switch them back on one by one.

How to organise updates so an outage does not catch you off guard is covered in updating WordPress plugins.

What plugin developers can take away

  1. Do not leave registration to the files autoload. Whatever every copy has to do belongs in the main file, via require_once. The files autoload runs a file once per request, not once per plugin.
  2. Register the selected copy’s loader with prepend. Otherwise the plugins’ Composer loaders answer, and whoever loaded last wins:
    spl_autoload_register( $loader, true, true ); // third argument: prepend
  3. Never trim the facade per package. Leave files out if you must, but not the methods of the class every plugin calls. Whether a service is available is something class_exists can check at runtime.
  4. One version number, one set of contents. If a trimmed and a complete copy carry the same number, the selection cannot tell them apart. It then needs a second criterion; ours is completeness.
  5. Check the origin, not the constant. What PHP actually loaded is revealed by the file behind the class:
    $file = ( new \ReflectionClass( \Hafenstudios\Core\Admin\DesignSystem::class ) )->getFileName();
  6. Test next to old versions. The fault only shows when a new plugin runs next to an old one, never in isolation. The WordPress directory keeps offering older versions as ZIP files.
  7. If you need to share nothing, prefix. Tools such as PHP-Scoper or Strauss give each plugin its own namespace, so there is no shared class to fight over. We share on purpose, because our plugins talk to each other through the library. If you do not need that, prefixing is simpler.

Site down?

White screen, fatal error after an update, a form that no longer sends: with our WordPress emergency help we start within four hours on business days, no contract, no upfront payment. 185 € per hour.

Request emergency help

Frequently asked questions

Why can a single plugin take down an entire WordPress site?

All active plugins run in the same PHP process. If one of them stops with a fatal error, the whole request stops. When several plugins ship the same library, there is a second route: PHP knows only one class per name, and one plugin’s copy can stand in for another’s, even if it is older.

What is Composer’s files autoload?

An entry in composer.json that makes Composer run files as soon as the autoloader loads, for functions or registrations, for example. Each file gets an identifier built from the md5 of package name and path, and Composer runs it only once per request. If several plugins ship the same package, their files share that identifier, and only the one from the plugin loaded first runs.

How do I find out which plugin a class was loaded from?

With PHP’s reflection: getFileName() on a ReflectionClass of the class returns the path of the file PHP loaded it from. If that path points into another plugin’s vendor folder, a foreign copy is supplying the class. A version constant does not prove anything here, it only tells you which copy was selected.

Do I have to update all Hafenstudios plugins at the same time?

Yes, ideally all of them together. Heckgalerie 1.2.6, Leadlotse 1.5.1, Wellenbrecher 1.3.3, Wimpel 1.3.1 and Kurato 1.9.3 ship Core 1.4.4. In WordPress Playground, the former crash combination of Heckgalerie 1.2.5 and Leadlotse 1.4.3 already ran without a fatal error as soon as Heckgalerie, Leadlotse, Wimpel or Kurato was present in its new version. We have not tested every mix, though. The setup is complete once all of them are updated.

How can plugin developers avoid conflicts with shared libraries?

If you need no shared state, prefix the namespaces per plugin, with PHP-Scoper or Strauss for instance. If you share a library on purpose, register every copy yourself with require_once, register the selected copy’s loader with prepend, never trim the shared class per package, and give each version number to exactly one set of contents.

Back to blog A post by hafenstudios