---
title: Managing modules
---

Modules are the unit of functionality in Mantle. This guide covers inspecting what is registered, changing state safely, and understanding what does and does not disappear when a module is switched off.

## Inspect current state

<!-- wp:docspress/terminal-session {"title":"List modules","shell":"bash","prompt":"$","command":"wp eval 'foreach ( get_option( \"mantle_modules\", [] ) as $m ) { printf( \"%-22s %-11s %s\" . PHP_EOL, $m[\"slug\"], $m[\"type\"], ! empty( $m[\"active\"] ) ? \"active\" : \"inactive\" ); }'","output":"client_info            core        active\ncommunication          functional  inactive\nmonitoring             functional  inactive\nai_chatbot             functional  inactive\nwelcome                core        active\nmaintenance            functional  inactive\nsecurity               functional  inactive\nsso                    functional  inactive\noptimizations          functional  inactive\nconvergence_cache      functional  inactive\nuser_management        functional  inactive\ndashboard              core        active\nsite_code_snippets     functional  inactive\nwhite-label            functional  inactive\nplugin_sync            functional  active\nlinchpin_licenses      functional  active"} /-->

That is real output from a development installation. The `mantle_modules` option is the source of truth for enabled state; the module classes are the source of truth for what exists.

<!-- wp:docspress/callout {"tone":"note","title":"The option is created lazily","content":"<p><code>Module_Loader::init()</code> writes <code>mantle_modules</code> only when <code>get_option()</code> returns <code>false</code>, and refreshes it on the <code>mantle_activated</code> action. On a site that has never completed activation the option can be absent, in which case functional modules fall back to their default state.</p>","collapsible":false} /-->

## Change module state

<!-- wp:docspress/code-tabs {"tabs":[{"label":"REST","language":"bash","filename":"Terminal","code":"curl -X POST \\\n  --cookie \"$COOKIE\" \\\n  -H \"X-WP-Nonce: $NONCE\" \\\n  https://example.com/wp-json/mantle/v1/modules/monitoring"},{"label":"PHP","language":"php","filename":"example.php","code":"use Mantle\\Modules\\Module_Loader;\n\nModule_Loader::update_module_status( 'monitoring', true );"},{"label":"Admin UI","language":"plaintext","filename":"","code":"Mantle → Settings → Modules\n\nToggle the module, then save."}],"showLineNumbers":false,"caption":"All three paths write the same mantle_modules option."} /-->

`update_module_status()` is deliberately conservative. It returns early — doing nothing at all — in three cases:

<!-- wp:docspress/fields {"title":"When update_module_status does nothing","description":"","fields":[{"name":"Unregistered module","type":"string","required":false,"defaultValue":"","description":"<code>Module_Loader::has()</code> returns false. A typo in the ID fails silently rather than erroring — <code>white_label</code> instead of <code>white-label</code> is the common case.","values":"","deprecated":false},{"name":"State already matches","type":"string","required":false,"defaultValue":"","description":"The requested state equals <code>is_enabled()</code>. This means calling it on a core module with <code>true</code> is a no-op, and with <code>false</code> is also a no-op.","values":"","deprecated":false},{"name":"Absent from the storable list","type":"string","required":false,"defaultValue":"","description":"The module ID is not present in the freshly built option list.","values":"","deprecated":false}],"searchable":false,"compact":false} /-->

<!-- wp:docspress/callout {"tone":"warning","title":"Core modules cannot be disabled, and trying is silent","content":"<p><code>is_enabled()</code> short-circuits to <code>true</code> for type <code>core</code> before consulting the option. Because <code>update_module_status()</code> bails when the requested state already matches, calling it with <code>false</code> on <code>client_info</code>, <code>dashboard</code>, or <code>welcome</code> returns without an error and without a change. Do not interpret the absence of an error as success.</p>","collapsible":false} /-->

## What disabling actually does

This is the part most worth internalising, because it is not simply "the module goes away".

<!-- wp:docspress/diagram {"title":"Enabled versus disabled","type":"flow","source":"Disabled module -> register_schema: still runs\nregister_schema -> settings keys: still registered\nregister_schema -> capability definitions: still registered\nDisabled module -> init: skipped\ninit -> hooks: not added\ninit -> REST controllers: not registered\ninit -> admin UI: not rendered","caption":"Schema survives; behaviour does not."} /-->

<!-- wp:docspress/fields {"title":"Effect of disabling a module","description":"","fields":[{"name":"Settings keys","type":"string","required":false,"defaultValue":"retained","description":"Still in the schema, still readable, still returned by the settings REST route. Stored values are preserved and are not reported as orphaned.","values":"","deprecated":false},{"name":"Capability definitions","type":"string","required":false,"defaultValue":"retained","description":"Still listed by <code>get_all_capabilities()</code>, and still granted to roles by a capability sync. They are inert because the routes are gone.","values":"","deprecated":false},{"name":"REST routes","type":"string","required":false,"defaultValue":"removed","description":"Requests return 404 rest_no_route. The AI routes are an exception — they are controller-registered and always present.","values":"","deprecated":false},{"name":"Hooks and behaviour","type":"string","required":false,"defaultValue":"removed","description":"<code>init()</code> is never called, so nothing the module hooks takes effect.","values":"","deprecated":false},{"name":"Admin UI","type":"string","required":false,"defaultValue":"removed","description":"The module's tab and panels are not rendered.","values":"","deprecated":false}],"searchable":false,"compact":false} /-->

Because settings survive, disabling and re-enabling a module is non-destructive: configuration is exactly as it was.

## Dependencies between modules

One dependency is enforced in code. `Core\Modules::sanitize_module_settings()` runs on every save of the `mantle_modules` option and forces `ai_chatbot` inactive whenever `communication` is inactive.

<!-- wp:docspress/flow {"start":1,"steps":[{"title":"Enable communication","content":"<p>POST to <code>/mantle/v1/modules/communication</code>, or toggle it in the admin UI.</p>"},{"title":"Enable ai_chatbot","content":"<p>Only now will it persist. Attempting it first appears to succeed but the sanitiser reverts it.</p>"},{"title":"Configure a provider","content":"<p>The chat surface also requires <code>Provider_Manager::is_configured()</code> to return true, in addition to the <code>mantle_access_chatbot</code> capability.</p>"}]} /-->

<!-- wp:docspress/result {"status":"warning","title":"Enabling ai_chatbot alone looks like it worked","content":"<p>The write succeeds and the response is a success, but the sanitiser sets <code>active</code> back to false before the option is stored. Re-read <code>/mantle/v1/modules</code> to confirm the state actually persisted.</p>","meta":"Core\\Modules::sanitize_module_settings()"} /-->

## Verify after a change

<!-- wp:docspress/terminal-session {"title":"Confirm state and schema","shell":"bash","prompt":"$","command":"wp eval 'echo \\Mantle\\Modules\\Module_Loader::get( \"monitoring\" )->is_enabled() ? \"enabled\" : \"disabled\", PHP_EOL;'\nwp mantle settings audit","output":"disabled\nOption: mantle\nSchema keys: 102\nSaved keys: 102\nMissing schema keys: 0\nOrphaned saved keys: 0\n\nSuccess: Settings audit passed. Saved keys match the registered schema."} /-->

Note the key count does not drop when a module is disabled — further confirmation that schema registration is independent of module state.

## See also

- [Module reference](../reference/modules.md) — every module and what it owns.
- [Creating a module](creating-a-module.md).
