A module is a class extending Abstract_Module that declares an ID, a name, its settings, and its capabilities. This guide builds one from scratch, first inside Mantle and then as a standalone plugin.
The contract
Module_Interface declares eight methods. Abstract_Module supplies working defaults for all but the first two, so a minimal module overrides only get_id() and get_name().
Module_Interface
includes/Modules/Module_Interface.php. Every method is public.-
get_id()string required -
Unique module identifier. Snake_case by convention. Must be implemented.
-
get_name()string required -
Human-readable name, stored in the
mantle_modulesoption. Must be implemented. -
get_version()string -
Module version.
-
init()any -
Called only for enabled modules. Override to add hooks, then call
parent::init(). -
register_settings()array -
Settings schema this module contributes. Registered even when the module is disabled.
-
register_capabilities()array -
Capability definitions. Each needs label, description, group, and roles.
-
is_enabled()boolean -
Reads the
mantle_modulesoption, short-circuiting to true for typecore. -
add_menu_item()array -
Adds an admin submenu entry via the
mantle.admin_submenusfilter.
No fields match this filter.
Build the module class
<?php/** * Reports Module * * @package Mantle\Modules\Reports */namespace Mantle\Modules\Reports;if ( ! defined( 'ABSPATH' ) ) { exit;}use Mantle\Modules\Abstract_Module;/** * Class Reports_Module */class Reports_Module extends Abstract_Module { /** * Module type. Omit to default to 'functional'. * * @var string */ protected string $type = 'functional'; /** * Get the module identifier. * * @return string */ public function get_id(): string { return 'reports'; } /** * Get the module name. * * @return string */ public function get_name(): string { return __( 'Reports', 'mantle' ); } /** * Initialize the module. * * @return void */ public function init(): void { parent::init(); add_action( 'admin_init', [ $this, 'schedule_report' ] ); } /** * Settings schema contributed by this module. * * @return array */ public function register_settings(): array { return [ 'reports_enabled' => [ 'type' => [ 'boolean', 'string', 'null' ], 'default' => false, ], 'reports_recipient' => [ 'type' => [ 'string', 'null' ], 'default' => '', ], ]; } /** * Capabilities contributed by this module. * * @return array */ public function register_capabilities(): array { return [ 'mantle_view_reports' => [ 'label' => __( 'View Reports', 'mantle' ), 'description' => __( 'Access to the reports section', 'mantle' ), 'group' => 'view', 'roles' => [ 'power_admin', 'administrator' ], ], ]; } /** * REST controllers owned by this module. * * @return array */ protected function get_rest_controllers(): array { return [ new Reports_REST(), ]; }}
-
Line 51Call the parent first — it registers the schema, submenu, and REST controllers.
-
Line 52Then add your own hooks. This line only runs when the module is enabled.
Register it
As a core module
Add the class to Core\Modules::register_modules(), following the existing pattern.
use Mantle\Modules\Reports\Reports_Module;// …inside register_modules()add_action( 'mantle_register_module', [ Reports_Module::get_instance(), 'register_module' ] );
As an external plugin
Nothing needs to change in Mantle. Hook the same action from your own plugin — Mantle fires mantle_register_module during its own boot, and again whenever a module's state changes.
<?php/** * Plugin Name: Mantle Reports * Requires Plugins: mantle */add_action( 'mantle_register_module', function (): void { if ( ! class_exists( '\\Mantle\\Modules\\Abstract_Module' ) ) { return; } require_once __DIR__ . '/includes/Reports_Module.php'; \My\Reports\Reports_Module::get_instance()->register_module(); });
-
Line 10Without this guard your plugin fatals when Mantle is deactivated.
-
Line 16Set
protected string $type = 'third-party';on the class so the UI can distinguish it.
Directory layout
-
Reports_Module.php -
Reports_REST.php -
Helpers/-
Report_Builder.php
-
Add the matching admin UI under src/modules/Reports/ — see Extending the admin UI.
Verify
-
Confirm registration
wp eval 'var_dump( array_keys( \Mantle\Modules\Module_Loader::get_all() ) );'should include your ID. -
Confirm the schema
wp mantle settings auditshould report your new keys as missing, then--fix-missingwrites their defaults. -
Confirm capabilities
POST to
/mantle/v1/capabilities/sync, then check the capability appears on the intended roles. -
Confirm routes
Enable the module and list routes; your controller’s routes should appear, and disappear again when it is disabled.
wp eval 'var_export( array_keys( \Mantle\Modules\Module_Loader::get_all() ) );'
wp mantle settings auditarray (
0 => 'client_info',
1 => 'communication',
...
16 => 'reports',
)
Option: mantle
Schema keys: 104
Saved keys: 102
Missing schema keys: 2
Orphaned saved keys: 0
A newly added module contributes schema keys that no existing installation has saved yet. Reading a setting still returns the default because get_settings() merges saved values over defaults. Run wp mantle settings audit --fix-missing to write them explicitly.
Checklist before shipping
Pre-flight
-
parent::init() calledboolean required -
Otherwise REST controllers and the submenu never register.
-
Unique module IDboolean required -
A duplicate ID is silently ignored by Module_Loader::register().
-
Prefixed settings keysboolean required -
All keys share one flat option, so prefix them with your module name to avoid collisions.
-
Multi-line keys allow-listedboolean -
Add any multi-line key to $textarea_allowed_fields in REST_Base::sanitize_settings(), or newlines are stripped on save.
-
Capability on every routeboolean required -
Each REST route needs a permission_callback checking one of your capabilities.
-
register.js importedboolean required -
JavaScript modules are not auto-discovered. An unimported register.js fails silently.
No fields match this filter.