WordPress only loads the top-level .php files in wp-content/mu-plugins/, it never scans subdirectories, and it runs no activation hooks there. So you cannot just drop a multi-file, Composer-autoloaded plugin into mu-plugins/ and expect it to boot. The fix is a one-file loader: a single mu-plugins/te-loader.php that requires a bootstrap living inside a subdirectory mu-plugins/te/, where the composer.json, src/, and vendor/ sit normally.
<?php
/**
* Plugin Name: TE MU Loader
* Plugin URI: https://techearl.com/wordpress-mu-plugin-composer-autoload
* Description: Loads the Composer-autoloaded must-use plugin that lives in the te/ subdirectory.
* Version: 1.0.0
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
* License: GPL-2.0-or-later
* Text Domain: te-mu
*/
namespace TE;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$te_autoload = __DIR__ . '/te/vendor/autoload.php';
if ( ! file_exists( $te_autoload ) ) {
add_action( 'admin_notices', __NAMESPACE__ . '\\te_missing_deps_notice' );
return;
}
require_once $te_autoload;
Plugin::boot();That single file is the only thing WordPress loads directly. Everything else lives one level down in te/, autoloaded by Composer. Below I walk through why the loader is necessary, the subdirectory layout, the composer.json PSR-4 block, the graceful "Composer deps missing" notice, and the one real catch: with no activation hook, any setup work has to run on a normal hook instead.
The mu-plugins limitations that force this pattern
Must-use plugins are not just "plugins that cannot be deactivated." They load through a different, much simpler code path than regular plugins, and that path has three properties you have to design around.
Only top-level files are scanned. Per the WordPress must-use plugins documentation, "WordPress only looks for PHP files right inside the mu-plugins directory, and (unlike for normal plugins) not for files in subdirectories." A regular plugin can live in wp-content/plugins/my-plugin/my-plugin.php and WordPress finds it by reading the header. A must-use plugin gets no such treatment: a file at mu-plugins/my-plugin/my-plugin.php is invisible. WordPress glob()s the top level of mu-plugins/ for *.php and includes each match directly. This is exactly why the loader file must sit at the top level and pull in everything else by hand.
They load in alphabetical order, before regular plugins. The docs note mu-plugins are "Loaded by PHP, in alphabetical order, before normal plugins." That ordering matters if you have more than one mu-plugin and one depends on another, and the "before normal plugins" part is the reason people reach for mu-plugins in the first place: hooks you register here are in place before any regular plugin or the theme loads.
There are no activation or deactivation hooks. The same page is explicit: "Activation hooks are not executed in plugins added to the must-use plugins folder." register_activation_hook() is dead weight here. There is no install event, no uninstall event, nothing fires when the file appears or disappears. Any one-time setup (creating a table, registering a custom post type and flushing rewrite rules, seeding an option) has to happen another way. I cover that below.
Put together, these three facts mean the idiomatic "namespaced, autoloaded, dependency-managed plugin" structure cannot be a must-use plugin directly. The loader pattern is the bridge.
The subdirectory layout
The top-level loader is the only file WordPress sees. The actual plugin is a normal Composer package one directory down:
wp-content/mu-plugins/
├── te-loader.php ← the only file WordPress loads
└── te/
├── composer.json
├── src/
│ └── Plugin.php
└── vendor/
├── autoload.php
└── ...Inside te/ it is an ordinary PHP project. You run composer install there, it writes vendor/, and the loader requires te/vendor/autoload.php. Nothing about this directory is special to WordPress, which is the point: your editor, your tests, and your static analysis all see a plain PSR-4 package, not a pile of require statements.
composer.json with a PSR-4 map
The whole reason to do this is autoloading: define a namespace-to-directory map once and stop hand-requiring files. Composer's autoload schema maps a namespace prefix to a path relative to the package root under the psr-4 key:
{
"name": "techearl/te-mu-plugin",
"description": "Must-use plugin loaded via the te-loader.php bridge.",
"type": "wordpress-muplugin",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=7.4"
},
"autoload": {
"psr-4": {
"TE\\": "src/"
}
}
}The trailing \\ on the prefix is required, and it is load-bearing: Composer's docs point out it keeps TE\\ from also matching something like TEFoo\\. With this map, PSR-4 resolves the class TE\Plugin to src/Plugin.php, and TE\Admin\Settings to src/Admin/Settings.php. The sub-namespace segments become subdirectories, the class name becomes the filename, case included. Add a class, name it under TE\, drop the file in the matching src/ path, and it loads with no edit to the loader.
After editing composer.json, run composer install (or composer dump-autoload if you only changed the map) inside te/ so vendor/composer/autoload_psr4.php reflects the prefix. Requiring vendor/autoload.php wires up the rest.
Fail gracefully when vendor/ is missing
vendor/ is generated, not committed (or at least it should not be on a real project), which means there is always a window where the loader exists but composer install has not run: a fresh clone, a deploy that skipped the install step, a teammate who pulled and forgot. If the loader blindly requires a file that is not there, you get a fatal error and a white screen on every request, admin included, which is a miserable way to discover a missing build step.
So the loader guards the require with file_exists() and, when the autoloader is absent, registers an admin notice instead of crashing:
function te_missing_deps_notice() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
printf(
'<div class="notice notice-error"><p>%s</p></div>',
esc_html__(
'TE MU Loader: Composer dependencies are missing. Run "composer install" in wp-content/mu-plugins/te/.',
'te-mu'
)
);
}The site keeps serving. An administrator sees a clear, actionable error in wp-admin, and nobody else sees anything broken. This is the half of the pattern people skip, and it is the half that saves you a frantic afternoon when a deploy goes out without the install step.
Boot a namespaced class
With the autoloader required, the loader calls into a real class rather than scattering add_action() calls across a flat file. A minimal src/Plugin.php:
<?php
namespace TE;
class Plugin {
public static function boot() {
$plugin = new self();
$plugin->register_hooks();
}
private function register_hooks() {
add_action( 'init', array( $this, 'on_init' ) );
}
public function on_init() {
// Register post types, taxonomies, shortcodes, REST routes, etc.
// This runs on every load, so it must be idempotent.
}
}Now the plugin is a normal object graph. You can pull in dependencies through Composer (require a logging library, an HTTP client, whatever the work genuinely needs), unit-test Plugin and its collaborators in isolation, and split features into more classes under TE\ without touching the loader. That is the entire payoff over one giant mu-plugin file: autoloading, namespacing, real dependencies, and code you can actually test.
Handling setup without an activation hook
Because mu-plugins get no activation hook, there is no register_activation_hook() to lean on. Anything you would normally run once, on activation, has to move to a hook that fires on a normal request. The usual landing spot is init (or admin_init for admin-only setup), made idempotent and cheap so running it on every load costs nothing:
public function on_init() {
if ( get_option( 'te_mu_setup_done' ) ) {
return;
}
// One-time setup: create a table, seed an option, flush rewrite rules
// after registering a post type, etc.
flush_rewrite_rules();
add_option( 'te_mu_setup_done', 1 );
}The get_option() gate makes this a no-op after the first run. The same approach covers a schema version check: store a te_mu_db_version option, compare it to the code's constant on init, and run migrations only when they differ. It is slightly more work than an activation hook, but it is also more robust, because it self-heals when the option is missing rather than depending on a one-time event that never fires for mu-plugins anyway.
Verify it loaded

Go to Plugins → Must-Use in wp-admin. The loader's header (the Plugin Name: TE MU Loader docblock) is read by WordPress, so it shows up there by name, with no activate or deactivate link, which is the whole point of mu-plugins. If you see it listed, WordPress loaded the top-level file.
To confirm the autoloaded class actually booted (and not just the loader file parsed), check that a hook you registered runs. A registered post type appears under its admin menu; a registered shortcode renders; or temporarily add an error_log() call inside Plugin::boot() and watch wp-content/debug.log with WP_DEBUG_LOG on. If the loader shows under Must-Use but nothing your class does takes effect, the usual cause is a missing composer install (you will see the admin notice from above) or a typo in the PSR-4 prefix versus the namespace in src/Plugin.php.
See also
- Run Plugin Logic on a Custom Hook with Filterable CTAs: once your code is namespaced and autoloaded, this is the pattern for exposing clean extension points to the rest of the site
- Keep Secrets out of wp-config with a .env File: the natural next dependency to pull through Composer in the same
te/package, so API keys never sit in tracked PHP - Register a Custom WP-CLI Command: give your autoloaded plugin a CLI surface for the setup tasks an mu-plugin cannot run on activation
- Clean Up wp_head in WordPress: a smaller example of the must-use plugin approach, stripping default head output site-wide before regular plugins load
- Disable WordPress Emojis to Speed Up Your Site: the simplest single-file mu-plugin, the opposite end of the spectrum from a Composer-autoloaded package
Sources
Authoritative references this article was fact-checked against.
- Must-Use Plugins: WordPress Developer documentationdeveloper.wordpress.org
- The composer.json schema (autoload / PSR-4): Composer documentationgetcomposer.org
- PSR-4: Autoloader: PHP-FIGphp-fig.org





