Add a Must-Use Plugin¶
This guide walks through creating a new must-use plugin, from file structure and coding conventions to test bootstrap registration.
Overview¶
Must-use plugins live in web/app/mu-plugins/ and are always active -- they cannot be deactivated from WP Admin. They are committed to git (unlike regular plugins, which are Composer-managed and gitignored). Use mu-plugins for site-specific logic: custom post types, REST API controllers, security hardening, third-party integrations.
Single-file mu-plugin¶
For self-contained functionality that fits in one file.
-
Create a new PHP file in
web/app/mu-plugins/. Name it descriptively with a hyphenated slug: -
Add the required file structure. Every mu-plugin must include a WordPress plugin header,
declare(strict_types=1), and the ABSPATH guard: -
Register all logic via hooks. No side effects at include time -- the file's top-level code should only contain
add_action(),add_filter(), class definitions, andrequire/includestatements:
Prefix everything with tinnitus_
All function names, hook tags, option names, meta keys, transient keys, and class names must use the tinnitus_ prefix to avoid collisions with WordPress core and third-party plugins.
Directory-based mu-plugin¶
For larger features that need multiple files (classes, templates, assets). The Bedrock autoloader discovers plugins in subdirectories automatically.
-
Create a directory with a main plugin file whose name matches the directory:
-
The main file follows the same structure as a single-file plugin, plus
require_oncestatements for class files:<?php /** * Plugin Name: My Feature * Description: Multi-file mu-plugin with class architecture. * Version: 1.0.0 */ declare(strict_types=1); defined('ABSPATH') || exit; require_once __DIR__ . '/src/class-my-feature-handler.php'; require_once __DIR__ . '/src/class-my-feature-settings.php'; add_action('init', [My_Feature_Handler::class, 'register']);The Bedrock autoloader (
bedrock-autoloader.php) handles loading the main file from the subdirectory. No additional registration is needed. -
Each class file must also include the strict types declaration and ABSPATH guard:
Coding standards checklist¶
Before committing, verify your plugin follows the project's coding standards:
declare(strict_types=1)on every PHP filedefined('ABSPATH') || exit;guard on every PHP file- Full type declarations on all function parameters, return types, and class properties
- All user-visible strings wrapped in
esc_html__()or__()with text domain'tinnitus' - Output escaped late with
esc_html(),esc_attr(),esc_url()as appropriate - REST routes include
permission_callback,sanitize_callback, andvalidate_callback
Run the quality tools:
composer lint # Pint formatting
composer lint:phpcs # WordPress Coding Standards
composer analyse # PHPStan static analysis
Register in the test bootstrap¶
The PHPUnit test suite does not auto-load mu-plugins. You must explicitly add your plugin to the bootstrap files.
Integration tests¶
Edit tests/bootstrap.php and add a require_once in the correct dependency order inside the muplugins_loaded callback:
tests_add_filter('muplugins_loaded', function (): void {
$mu_dir = dirname(__DIR__) . '/web/app/mu-plugins';
// Foundation — no dependencies.
require_once $mu_dir . '/bedrock-autoloader.php';
require_once $mu_dir . '/content-types.php';
// ...existing plugins...
require_once $mu_dir . '/my-feature.php'; // Add here
// Depends on content-types (CPTs must be registered first).
require_once $mu_dir . '/tinnitus-rest-api/tinnitus-rest-api.php';
// ...
});
Respect dependency order
If your plugin depends on CPTs (registered in content-types.php) or ACF fields, place its require_once after those plugins in the bootstrap.
Unit tests¶
If your plugin has class files suitable for unit testing (no WordPress dependencies at the class level), add a require_once in tests/bootstrap-unit.php:
// Class-only files (no top-level side effects):
require_once dirname(__DIR__) . '/web/app/mu-plugins/my-feature/src/class-my-feature-handler.php';
// Files with top-level hook registrations (absorbed by stubs above):
require_once dirname(__DIR__) . '/web/app/mu-plugins/my-feature.php';
Files with top-level add_action()/add_filter() calls are safe to require because tests/bootstrap-unit.php stubs those functions via Brain\Monkey before any files are loaded.
Example: existing mu-plugin structure¶
For reference, here is the plugin header from the existing content-types.php:
<?php
/**
* Plugin Name: Content Types
* Description: Registers custom post types and taxonomies for the Tinnitus site.
* Must-use plugin — always active, version-controlled.
* Version: 1.0.0
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
add_action('init', 'tinnitus_register_post_types');
add_action('init', 'tinnitus_register_taxonomies');