---
title: 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()`.

<!-- wp:docspress/fields {"title":"Module_Interface","description":"From <code>includes/Modules/Module_Interface.php</code>. Every method is public.","fields":[{"name":"get_id()","type":"string","required":true,"defaultValue":"","description":"Unique module identifier. Snake_case by convention. Must be implemented.","values":"","deprecated":false},{"name":"get_name()","type":"string","required":true,"defaultValue":"","description":"Human-readable name, stored in the <code>mantle_modules</code> option. Must be implemented.","values":"","deprecated":false},{"name":"get_version()","type":"string","required":false,"defaultValue":"","description":"Module version.","values":"","deprecated":false},{"name":"init()","type":"any","required":false,"defaultValue":"","description":"Called only for enabled modules. Override to add hooks, then call <code>parent::init()</code>.","values":"","deprecated":false},{"name":"register_settings()","type":"array","required":false,"defaultValue":"[]","description":"Settings schema this module contributes. Registered even when the module is disabled.","values":"","deprecated":false},{"name":"register_capabilities()","type":"array","required":false,"defaultValue":"[]","description":"Capability definitions. Each needs label, description, group, and roles.","values":"","deprecated":false},{"name":"is_enabled()","type":"boolean","required":false,"defaultValue":"","description":"Reads the <code>mantle_modules</code> option, short-circuiting to true for type <code>core</code>.","values":"","deprecated":false},{"name":"add_menu_item()","type":"array","required":false,"defaultValue":"","description":"Adds an admin submenu entry via the <code>mantle.admin_submenus</code> filter.","values":"","deprecated":false}],"searchable":true,"compact":false} /-->

<!-- wp:docspress/callout {"tone":"warning","title":"Always call parent::init() when overriding init()","content":"<p><code>Abstract_Module::init()</code> registers the schema, adds the submenu filter, fires <code>mantle_module_{id}_init</code> and <code>mantle_module_load_libraries</code>, and registers REST controllers. Overriding <code>init()</code> without calling <code>parent::init()</code> silently drops all of that — your REST routes never register and your submenu never appears. The bundled modules call it first, then do their own work.</p>","collapsible":false} /-->

## Build the module class

<!-- wp:docspress/colorful-code {"language":"php","filename":"includes/Modules/Reports/Reports_Module.php","code":"<?php\n/**\n * Reports Module\n *\n * @package Mantle\\Modules\\Reports\n */\n\nnamespace Mantle\\Modules\\Reports;\n\nif ( ! defined( 'ABSPATH' ) ) {\n\texit;\n}\n\nuse Mantle\\Modules\\Abstract_Module;\n\n/**\n * Class Reports_Module\n */\nclass Reports_Module extends Abstract_Module {\n\n\t/**\n\t * Module type. Omit to default to 'functional'.\n\t *\n\t * @var string\n\t */\n\tprotected string $type = 'functional';\n\n\t/**\n\t * Get the module identifier.\n\t *\n\t * @return string\n\t */\n\tpublic function get_id(): string {\n\t\treturn 'reports';\n\t}\n\n\t/**\n\t * Get the module name.\n\t *\n\t * @return string\n\t */\n\tpublic function get_name(): string {\n\t\treturn __( 'Reports', 'mantle' );\n\t}\n\n\t/**\n\t * Initialize the module.\n\t *\n\t * @return void\n\t */\n\tpublic function init(): void {\n\t\tparent::init();\n\n\t\tadd_action( 'admin_init', [ $this, 'schedule_report' ] );\n\t}\n\n\t/**\n\t * Settings schema contributed by this module.\n\t *\n\t * @return array\n\t */\n\tpublic function register_settings(): array {\n\t\treturn [\n\t\t\t'reports_enabled'   => [\n\t\t\t\t'type'    => [ 'boolean', 'string', 'null' ],\n\t\t\t\t'default' => false,\n\t\t\t],\n\t\t\t'reports_recipient' => [\n\t\t\t\t'type'    => [ 'string', 'null' ],\n\t\t\t\t'default' => '',\n\t\t\t],\n\t\t];\n\t}\n\n\t/**\n\t * Capabilities contributed by this module.\n\t *\n\t * @return array\n\t */\n\tpublic function register_capabilities(): array {\n\t\treturn [\n\t\t\t'mantle_view_reports' => [\n\t\t\t\t'label'       => __( 'View Reports', 'mantle' ),\n\t\t\t\t'description' => __( 'Access to the reports section', 'mantle' ),\n\t\t\t\t'group'       => 'view',\n\t\t\t\t'roles'       => [ 'power_admin', 'administrator' ],\n\t\t\t],\n\t\t];\n\t}\n\n\t/**\n\t * REST controllers owned by this module.\n\t *\n\t * @return array\n\t */\n\tprotected function get_rest_controllers(): array {\n\t\treturn [\n\t\t\tnew Reports_REST(),\n\t\t];\n\t}\n}","highlightedLines":"51,52","showLineNumbers":true,"caption":"Only get_id() and get_name() are strictly required; the rest are optional overrides.","copyMode":"all","diffMode":"none","annotations":[{"line":51,"content":"Call the parent first — it registers the schema, submenu, and REST controllers."},{"line":52,"content":"Then 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.

<!-- wp:docspress/colorful-code {"language":"php","filename":"includes/Core/Modules.php","code":"use Mantle\\Modules\\Reports\\Reports_Module;\n\n// …inside register_modules()\nadd_action( 'mantle_register_module', [ Reports_Module::get_instance(), 'register_module' ] );","highlightedLines":"4","showLineNumbers":true,"caption":"get_instance() comes from Singleton_Interface via Abstract_Module.","copyMode":"all","diffMode":"none"} /-->

### 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.

<!-- wp:docspress/colorful-code {"language":"php","filename":"my-reports-plugin/my-reports-plugin.php","code":"<?php\n/**\n * Plugin Name: Mantle Reports\n * Requires Plugins: mantle\n */\n\nadd_action(\n\t'mantle_register_module',\n\tfunction (): void {\n\t\tif ( ! class_exists( '\\\\Mantle\\\\Modules\\\\Abstract_Module' ) ) {\n\t\t\treturn;\n\t\t}\n\n\t\trequire_once __DIR__ . '/includes/Reports_Module.php';\n\n\t\t\\My\\Reports\\Reports_Module::get_instance()->register_module();\n\t}\n);","highlightedLines":"10,16","showLineNumbers":true,"caption":"Guard on the base class so your plugin degrades safely when Mantle is absent.","copyMode":"all","diffMode":"none","annotations":[{"line":10,"content":"Without this guard your plugin fatals when Mantle is deactivated."},{"line":16,"content":"Set <code>protected string $type = 'third-party';</code> on the class so the UI can distinguish it."}]} /-->

<!-- wp:docspress/callout {"tone":"note","title":"mantle_register_module can fire more than once","content":"<p>The action fires during boot and again from <code>update_module_status()</code> after a state change. <code>Module_Loader::register()</code> ignores an ID that is already registered, so re-registration is harmless — but do not put side effects such as option writes or scheduling inside your registration callback. Put those in <code>init()</code>.</p>","collapsible":false} /-->

## Directory layout

<!-- wp:docspress/file-tree {"root":"includes/Modules/Reports/","tree":"Reports_Module.php\nReports_REST.php\nHelpers/\n  Report_Builder.php","caption":"PSR-4 maps Mantle\\Modules\\Reports\\ to this directory.","collapsible":true,"open":true} /-->

Add the matching admin UI under `src/modules/Reports/` — see [Extending the admin UI](extending-the-admin-ui.md).

## Verify

<!-- wp:docspress/flow {"start":1,"steps":[{"title":"Confirm registration","content":"<p><code>wp eval 'var_dump( array_keys( \\Mantle\\Modules\\Module_Loader::get_all() ) );'</code> should include your ID.</p>"},{"title":"Confirm the schema","content":"<p><code>wp mantle settings audit</code> should report your new keys as missing, then <code>--fix-missing</code> writes their defaults.</p>"},{"title":"Confirm capabilities","content":"<p>POST to <code>/mantle/v1/capabilities/sync</code>, then check the capability appears on the intended roles.</p>"},{"title":"Confirm routes","content":"<p>Enable the module and list routes; your controller's routes should appear, and disappear again when it is disabled.</p>"}]} /-->

<!-- wp:docspress/terminal-session {"title":"Registration and schema check","shell":"bash","prompt":"$","command":"wp eval 'var_export( array_keys( \\Mantle\\Modules\\Module_Loader::get_all() ) );'\nwp mantle settings audit","output":"array (\n  0 => 'client_info',\n  1 => 'communication',\n  ...\n  16 => 'reports',\n)\nOption: mantle\nSchema keys: 104\nSaved keys: 102\nMissing schema keys: 2\nOrphaned saved keys: 0"} /-->

<!-- wp:docspress/result {"status":"warning","title":"Two missing keys is the expected first result","content":"<p>A newly added module contributes schema keys that no existing installation has saved yet. Reading a setting still returns the default because <code>get_settings()</code> merges saved values over defaults. Run <code>wp mantle settings audit --fix-missing</code> to write them explicitly.</p>","meta":"reports_enabled, reports_recipient"} /-->

## Checklist before shipping

<!-- wp:docspress/fields {"title":"Pre-flight","description":"","fields":[{"name":"parent::init() called","type":"boolean","required":true,"defaultValue":"","description":"Otherwise REST controllers and the submenu never register.","values":"","deprecated":false},{"name":"Unique module ID","type":"boolean","required":true,"defaultValue":"","description":"A duplicate ID is silently ignored by Module_Loader::register().","values":"","deprecated":false},{"name":"Prefixed settings keys","type":"boolean","required":true,"defaultValue":"","description":"All keys share one flat option, so prefix them with your module name to avoid collisions.","values":"","deprecated":false},{"name":"Multi-line keys allow-listed","type":"boolean","required":false,"defaultValue":"","description":"Add any multi-line key to $textarea_allowed_fields in REST_Base::sanitize_settings(), or newlines are stripped on save.","values":"","deprecated":false},{"name":"Capability on every route","type":"boolean","required":true,"defaultValue":"","description":"Each REST route needs a permission_callback checking one of your capabilities.","values":"","deprecated":false},{"name":"register.js imported","type":"boolean","required":true,"defaultValue":"","description":"JavaScript modules are not auto-discovered. An unimported register.js fails silently.","values":"","deprecated":false}],"searchable":false,"compact":false} /-->

## See also

- [Module reference](../reference/modules.md)
- [Settings reference](../reference/settings.md)
- [Hooks reference](../reference/hooks.md)
