Skip to content

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.

  1. Create a new PHP file in web/app/mu-plugins/. Name it descriptively with a hyphenated slug:

    web/app/mu-plugins/my-feature.php
    
  2. Add the required file structure. Every mu-plugin must include a WordPress plugin header, declare(strict_types=1), and the ABSPATH guard:

    <?php
    
    /**
     * Plugin Name: My Feature
     * Description: Brief description of what this plugin does.
     *              Must-use plugin — always active, version-controlled.
     * Version:     1.0.0
     */
    
    declare(strict_types=1);
    
    defined('ABSPATH') || exit;
    
  3. 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, and require/include statements:

    add_action('init', 'tinnitus_my_feature_init');
    
    function tinnitus_my_feature_init(): void
    {
        // Feature logic here.
    }
    

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.

  1. Create a directory with a main plugin file whose name matches the directory:

    web/app/mu-plugins/my-feature/
        my-feature.php          # Main plugin file (autoloader entry point)
        src/
            class-my-feature-handler.php
            class-my-feature-settings.php
    
  2. The main file follows the same structure as a single-file plugin, plus require_once statements 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.

  3. Each class file must also include the strict types declaration and ABSPATH guard:

    <?php
    
    declare(strict_types=1);
    
    defined('ABSPATH') || exit;
    
    class My_Feature_Handler
    {
        // ...
    }
    

Coding standards checklist

Before committing, verify your plugin follows the project's coding standards:

  • declare(strict_types=1) on every PHP file
  • defined('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, and validate_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');