← Back to blog

WordPress mu-plugins: the four rules the guides leave out

The SandyWP team 7 min read

A must-use plugin is any .php file sitting directly in wp-content/mu-plugins. WordPress includes it on every request, before normal plugins, with no activation step and no way to switch it off from the admin.

That is the part every guide covers. The part that matters is what the loading code actually does, because four non-obvious rules fall straight out of twenty lines of core.

What core actually runs

Two functions do all of it. First, wp_get_mu_plugins() in wp-includes/load.php:

$dh = opendir( WPMU_PLUGIN_DIR );
while ( ( $plugin = readdir( $dh ) ) !== false ) {
	if ( str_ends_with( $plugin, '.php' ) ) {
		$mu_plugins[] = WPMU_PLUGIN_DIR . '/' . $plugin;
	}
}
closedir( $dh );
sort( $mu_plugins );

Then wp-settings.php includes each one in order:

foreach ( wp_get_mu_plugins() as $mu_plugin ) {
	$_wp_plugin_file = $mu_plugin;
	include_once $mu_plugin;
	$mu_plugin = $_wp_plugin_file;
	do_action( 'mu_plugin_loaded', $mu_plugin );
}

There is no database lookup, no plugin header parse, and no activation state. A file is loaded because it exists and ends in .php.

Everything below is a consequence of those two blocks.

Rule 1: subdirectories are ignored

readdir reads one level. A file at mu-plugins/my-tool/my-tool.php is never included, which is the single most common reason a mu-plugin "does nothing".

Normal plugins work the other way round, so the habit of shipping a folder is exactly wrong here. If you need a folder, you need a loader file beside it:

<?php
// wp-content/mu-plugins/load-my-tool.php
require_once WPMU_PLUGIN_DIR . '/my-tool/my-tool.php';

The loader is the thing core finds. The folder is just where you keep the code.

Rule 2: load order is a string sort, not a priority

sort() runs on the full paths, so ordering is plain ASCII on the filename. 0-logging.php loads before a-rewrites.php, which loads before z-overrides.php.

Numeric prefixes are the usual convention, and they are a convention, not a feature. There is no Load-Order: header and no dependency resolution.

This matters because mu-plugins run before normal plugins, which is the whole reason to use one. Your code can filter an option or short-circuit a hook that a regular plugin will later read, but only if it is loaded by the time that plugin registers anything.

Rule 3: activation and deactivation hooks never fire

register_activation_hook() and register_deactivation_hook() are wired to the activate and deactivate actions in the admin. A mu-plugin is never activated, so neither one runs, ever.

Anything you would normally do on activation has to move. The usual options are a one-off guard on init:

add_action( 'init', function () {
	if ( get_option( 'my_tool_schema' ) === '3' ) {
		return;
	}
	my_tool_install_tables();
	update_option( 'my_tool_schema', '3' );
} );

or a wp-cli command you run once at deploy time, which is easier to reason about and easier to re-run. There is no uninstall path at all: deleting the file removes the code and leaves every option, table and cron event it created behind.

Taking a regular plugin and dropping it into mu-plugins is where this bites. If it created tables on activation, it will now run against tables that do not exist.

Our plugin-developer notes cover the wider version of this problem, which is that a plugin's activation routine is the least-tested code it ships.

Rule 4: a fatal error here is not covered by Recovery Mode

This is the one none of the top-ranking guides mention, and it is the reason to be careful.

Recovery Mode, the WordPress 5.2 feature that emails you a link and pauses the broken extension, has a specific position in the boot sequence. In current wp-settings.php:

  • line 69 registers the fatal error handler
  • line 509 starts the mu-plugin include loop
  • line 589 calls wp_recovery_mode()->initialize()
  • line 593 starts the normal plugin loop

A fatal error in a mu-plugin happens at line 509, eighty lines before Recovery Mode is initialised. The handler exists, so you get the "There has been a critical error" screen, but the recovery email is never sent.

The pausing machinery does not cover mu-plugins either. wp_get_active_and_valid_plugins() filters its list through wp_skip_paused_plugins(), and wp_get_mu_plugins() has no equivalent call.

Even if Recovery Mode were initialised in time, it has no mechanism to pause a file in that directory.

So the usual recovery routes are all gone at once. You cannot deactivate it from the admin, because the admin is down.

You cannot pause it with a recovery link, because no link arrives. Renaming wp-content/plugins does nothing, because mu-plugins is a different directory.

The only fix is filesystem access: delete or rename the file. That is a real constraint on where you are willing to experiment.

It is also worth knowing before you rely on the safety net: Recovery Mode covers plugins and themes, and this directory is the hole in it.

Where the Must-Use screen helps and where it lies

wp-admin/plugins.php grows a Must-Use tab when the directory is non-empty, and it lists the files it found with whatever Plugin Name header they carry. Files with no header show up under their filename.

Two things it will not tell you. It does not show subdirectory files, so a mu-plugin that is silently not loading looks identical to one that was never installed.

Nothing in that list is ever flagged for updates either: mu-plugins are excluded from update checks, so a vendored library in there stays at whatever version you dropped in until you notice.

Moving the directory

WPMU_PLUGIN_DIR and WPMU_PLUGIN_URL are both definable in wp-config.php, before wp-settings.php is required:

define( 'WPMU_PLUGIN_DIR', dirname( __FILE__ ) . '/platform-code' );
define( 'WPMU_PLUGIN_URL', 'https://example.com/platform-code' );

This is worth knowing mostly as a diagnostic. If a host has moved the directory, your file in the default location is not loading and the Must-Use tab will not explain why.

Check the constant before you debug the code.

How we use one, and what it taught us

Every SandyWP sandbox ships with a mu-plugin. It is one file, sandywp-helper.php, about 1,300 lines, providing the health endpoint, the signed magic-login route, the outgoing-mail capture behind pre_wp_mail, and the activity heartbeat.

It is a mu-plugin for exactly the reason the directory exists: a sandbox user can deactivate anything they like from the plugins screen, and the platform's own control surface has to survive that. The provisioner treats a missing helper as a failed build and refuses to hand the sandbox over.

Three things we learned running it that the rules above predict.

It is one flat file because of rule 1. We keep the code organised with namespaces inside a single file rather than a folder plus a loader, because one file has one failure mode.

It installs nothing on first load because of rule 3. There is no activation hook to hang a migration on, so anything stateful is done by the provisioner before the site is ever served.

And it is the file we are most careful with, because of rule 4. A syntax error in the helper does not degrade a sandbox, it takes the site down with no admin and no recovery mail, which is precisely the failure mode described above.

The ownership detail is worth stating too, since it is a common self-inflicted wound. Writing the file over SSH or docker exec creates it as root, and PHP running as www-data then cannot read it.

We chown 33:33 after every copy for that reason, and our file permissions post has the general version.

Testing one without bricking anything

The honest answer is that mu-plugins are the worst thing in WordPress to test in place, because the failure mode removes your tools. So do not test in place.

What a throwaway install gives you is the filesystem access the failure mode demands. On a SandyWP sandbox you have SSH and SFTP into wp-content, so recovery from a fatal error is rm rather than a support ticket:

sandywp ssh my-sandbox
rm /var/www/html/wp-content/mu-plugins/broken.php

A workable loop:

  1. Turn on debug mode first, so WP_DEBUG_LOG is writing to wp-content/debug.log before you install anything. A parse error in a mu-plugin is logged there, and that log is the only thing that will tell you which file broke.
  2. Copy the file in over SFTP, and chown it to the web user if you wrote it as root.
  3. Load the front end and the admin separately. A mu-plugin can break one and not the other, particularly if it touches admin_init.
  4. Check wp-admin/plugins.php?plugin_status=mustuse and confirm the file is actually listed. If it is not, you have hit rule 1.
  5. Deliberately break it. Add a syntax error; line, reload, confirm what you see, and confirm whether the recovery email arrives. It will not, and knowing that before it happens on a client site is the point of the exercise.

Step 5 is the one people skip and the one worth doing. If it goes wrong, reset the sandbox and start again.

When not to use one

A mu-plugin is the right tool when the code must not be switchable: platform glue, a security header you do not want an editor to turn off, a filter that has to run before any normal plugin. That is a narrow list.

For everything else, a normal plugin is better in every way that matters. It can be deactivated when it misbehaves, it is covered by Recovery Mode, it gets update notices, and its activation hooks work.

"I do not want anyone disabling this" is a reason. "It is quicker than writing a real plugin" is not, and you pay for it the first time it has a typo.

A functionality plugin in wp-content/plugins that you simply do not deactivate gets you most of the benefit and keeps the whole safety net. Reach for mu-plugins when you need the guarantee, not the convenience.

The short version

  • Only .php files directly in mu-plugins load. Subdirectories need a loader file.
  • Order is an ASCII sort on the filename. Prefix with numbers if order matters.
  • Activation and deactivation hooks never fire, and there is no uninstall.
  • A fatal error here is not caught by Recovery Mode, because mu-plugins load 80 lines before it initialises. Filesystem access is your only recovery.
  • Test it somewhere you can rm the file, and break it on purpose once before you trust it.

Sources: wp-includes/load.php and wp-settings.php (read 2026-10-08), and the must-use plugins page in the WordPress Advanced Administration handbook.