Guide

Creating a module

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().

Reference

Module_Interface

From includes/Modules/Module_Interface.php. Every method is public.
8 fields
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_modules option. 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_modules option, short-circuiting to true for type core.
add_menu_item() array
Adds an admin submenu entry via the mantle.admin_submenus filter.

Build the module class

php includes/Modules/Reports/Reports_Module.php
<?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(),		];	}}
Only get_id() and get_name() are strictly required; the rest are optional overrides.

Register it

As a core module

Add the class to Core\Modules::register_modules(), following the existing pattern.

php includes/Core/Modules.php
use Mantle\Modules\Reports\Reports_Module;// …inside register_modules()add_action( 'mantle_register_module', [ Reports_Module::get_instance(), 'register_module' ] );
get_instance() comes from Singleton_Interface via Abstract_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 my-reports-plugin/my-reports-plugin.php
<?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();	});
Guard on the base class so your plugin degrades safely when Mantle is absent.

Directory layout

includes/Modules/Reports/ File tree
  • Reports_Module.php
  • Reports_REST.php
  • Helpers/
    • Report_Builder.php
PSR-4 maps Mantle\Modules\Reports\ to this directory.

Add the matching admin UI under src/modules/Reports/ — see Extending the admin UI.

Verify

  1. Confirm registration

    wp eval 'var_dump( array_keys( \Mantle\Modules\Module_Loader::get_all() ) );' should include your ID.

  2. Confirm the schema

    wp mantle settings audit should report your new keys as missing, then --fix-missing writes their defaults.

  3. Confirm capabilities

    POST to /mantle/v1/capabilities/sync, then check the capability appears on the intended roles.

  4. Confirm routes

    Enable the module and list routes; your controller’s routes should appear, and disappear again when it is disabled.

Registration and schema check bash
wp eval 'var_export( array_keys( \Mantle\Modules\Module_Loader::get_all() ) );'
wp mantle settings audit
array (
  0 => 'client_info',
  1 => 'communication',
  ...
  16 => 'reports',
)
Option: mantle
Schema keys: 104
Saved keys: 102
Missing schema keys: 2
Orphaned saved keys: 0
Warning
Two missing keys is the expected first result

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.

reports_enabled, reports_recipient

Checklist before shipping

Reference

Pre-flight

6 fields
parent::init() called boolean required
Otherwise REST controllers and the submenu never register.
Unique module ID boolean required
A duplicate ID is silently ignored by Module_Loader::register().
Prefixed settings keys boolean required
All keys share one flat option, so prefix them with your module name to avoid collisions.
Multi-line keys allow-listed boolean
Add any multi-line key to $textarea_allowed_fields in REST_Base::sanitize_settings(), or newlines are stripped on save.
Capability on every route boolean required
Each REST route needs a permission_callback checking one of your capabilities.
register.js imported boolean required
JavaScript modules are not auto-discovered. An unimported register.js fails silently.

See also

Was this helpful?