A core/ModCp extension adds one section to the front-end Moderator Control Panel — the page at /modcp that moderators use to work through reports, the approval queue, hidden and deleted content, warnings, alerts, announcements and IP lookups. Each extension owns one entry in the left-hand menu, owns the whole content pane when that entry is selected, and may optionally contribute a counter badge to the strip along the top of the page. There is no other way to add a section to that screen; the menu is built entirely from these extensions, in the order the extensions happen to be discovered.
Everything on that page is gated behind two checks at the top of manage() (applications/core/modules/front/modcp/modcp.php): guests are rejected with 2S194/1 (lines 88–91), and then Member::loggedIn()->modPermission() === FALSE aborts with a 403 and 2S194/2 (lines 94–97). Whoever you build the section for, they must already be a moderator.
The contract
The abstract is IPS\Extensions\ModCpAbstract, in system/Extensions/ModCpAbstract.php. It is short — three abstract methods and one optional one, and no constructor.
namespace IPS\Extensions;
abstract class ModCpAbstract
{
/* The tab key. This is the ?tab= value in the URL, and the sidebar
label is looked up as the language string "modcp_<key>".
Return NULL to hide the section from this member entirely. */
abstract public function getTab() : ?string;
/* Which sidebar group the entry goes in.
Acceptable responses are: content, members, or other. */
abstract public function manageType() : string;
/* Render the section. Declared void — you communicate by assigning
to IPS\Output::i()->output, not by returning. */
abstract public function manage() : void;
/* Optional. Counters for the strip at the top of the ModCP.
Each element is an array with keys: title (a language key),
total, and optionally id (an HTML element id). */
public function getCounters() : array
{
return [];
}
}
Return types may be narrowed. applications/core/extensions/core/ModCp/Content.php (line 54) and Unapproved.php (line 51) both declare getTab() : string because their sections are always visible; that is legal covariance and core does it deliberately.
Registration goes in your app's data/extensions.json, under the core application and the ModCp extension type. Core's own block reads:
{
"core": {
"ModCp": {
"Alerts": "IPS\\core\\extensions\\core\\ModCp\\Alerts",
"Announcements": "IPS\\core\\extensions\\core\\ModCp\\Announcements",
...
}
}
}
Unlike most extension points, the key on the left has no meaning at runtime beyond forming the internal <app>_<key> identifier. The URL and the label both come from getTab().
Who calls it
Exactly one file: applications/core/modules/front/modcp/modcp.php. It is the only consumer in the suite — Application::allExtensions( 'core', 'ModCp' ) appears at line 64 (inside __call()) and line 105 (inside manage()), and nowhere else.
manage() loops over every extension once per request and, for each one, does four things in this order (lines 105–147):
- Calls
manageType(). If the return value is truthy and iscontentormembersit is used as the sidebar group; anything else becomesother(line 113). - Calls
getTab(). A non-empty return adds the entry to that group (lines 116–121). - Calls
getCounters()and appends every element to the header strip, filling in a defaultidof"elModCP" . ucfirst( $tab ) . $index . "Counter"when none was supplied (lines 124–132). - If the extension's tab key matches
?tab=(compared case-insensitively, both sides cast to string), it works out a method name from?action=— defaulting tomanage— and calls it (lines 134–146).
The result is rendered by applications/core/dev/html/front/modcp/template.phtml, which draws the three sidebar groups under the headings modcp_content_tools, modcp_member_tools and modcp_tools, and links each entry with {url="app=core&module=modcp&controller=modcp&tab=$key" seoTemplate="modcp_tab"}. The modcp_tab FURL in applications/core/data/furl.json is modcp/{@tab}, so a third-party tab key of my_things gets the friendly URL /modcp/my_things with no work on your part.
The extension objects themselves come from Application::allExtensions() in system/Application/Application.php (line 355). Two details of that method matter here and are covered in the traps below: the whole map is cached in the extensions datastore key (lines 359 and 442), and each object is constructed by constructExtensionClass() (line 469), which passes Member::loggedIn() as the first constructor argument whenever $checkAccess is TRUE (line 479) — and the ModCP controller uses the default, so it always is.
A minimal example
applications/core/extensions/core/ModCp/Warnings.php is the smallest complete implementation in the suite, and it shows the whole pattern: permission check in getTab(), the same permission check repeated in manage(), and output assigned rather than returned.
<?php
namespace IPS\core\extensions\core\ModCp;
use IPS\Extensions\ModCpAbstract;
use IPS\Helpers\Table\Content;
use IPS\Http\Url;
use IPS\Member;
use IPS\Output;
use IPS\Settings;
use IPS\Theme;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Warnings extends ModCpAbstract
{
public function getTab() : ?string
{
if ( ! Member::loggedIn()->modPermission('mod_see_warn') OR ! Settings::i()->warn_on )
{
return null;
}
return 'recent_warnings';
}
public function manageType() : string
{
return 'members';
}
public function manage() : void
{
if ( ! Member::loggedIn()->modPermission('mod_see_warn') )
{
Output::i()->error( 'no_module_permission', '2C224/1', 403, '' );
}
Output::i()->title = Member::loggedIn()->language()->addToStack( 'modcp_recent_warnings' );
Output::i()->breadcrumb[] = array( NULL, Member::loggedIn()->language()->addToStack( 'modcp_recent_warnings' ) );
$table = new Content( 'IPS\core\Warnings\Warning', Url::internal( 'app=core&module=modcp&controller=modcp&tab=recent_warnings', 'front', 'modcp_recent_warnings' ) );
$table->tableTemplate = array( Theme::i()->getTemplate('modcp'), 'recentWarningsTable' );
$table->rowsTemplate = array( Theme::i()->getTemplate('modcp'), 'recentWarningsRows' );
Output::i()->output = (string) $table;
}
}
For your own app, the same file lives at applications/myapp/extensions/core/ModCp/Things.php in the namespace IPS\myapp\extensions\core\ModCp, is registered in applications/myapp/data/extensions.json, and needs one language string named after the tab key — modcp_things — for the sidebar label.
Core also ships a stub for the ACP Developer Centre at applications/core/data/defaults/extensions/ModCp.txt. When you add a ModCp extension there, Application::extensionHelper() (applications/core/Application.php, lines 867–877) asks you for the tab key and offers manageType as a dropdown of exactly content, members and other.
The tab never appears, and nothing anywhere logs an error
This is the usual first failure and it has several distinct causes, all silent.
The class name in extensions.json is wrong. Application::extensions() guards every entry with if( !is_string( $classname ) or !class_exists( $classname ) ) { continue; } (system/Application/Application.php, line 926). A typo, a wrong namespace, or a file in the wrong directory produces no error at all — the extension is simply not in the list.
The list is cached. allExtensions() builds the whole map once and writes it to Store::i()->extensions (line 442), and only rebuilds when the ModCp key is absent. The ACP Developer Centre unsets that key when you add or remove an extension (applications/core/modules/admin/developer/extensions.php, lines 313 and 342), but if you hand-edit extensions.json on a live site you must clear the system cache yourself.
The cache was built by the wrong person. The app-access check happens while the map is being built (line 408): if( !$application->canAccess( ... ) ) { continue; }, and with the default arguments canAccess() tests the currently logged-in member's group against your application's disabled_groups. If the first moderator to open the ModCP after a cache clear is in a group excluded from your app, your extension is left out of the cached list for everyone, and stays out until the cache is cleared again. The same block also skips all third-party apps outright when RECOVERY_MODE is on (line 396), with the same lasting effect.
Your getTab() returned NULL. That is the designed way to hide a section, and eight of core's ten implementations use it for permission checks — only Content and Unapproved are unconditional. It is worth ruling out before hunting for anything more exotic.
The whole Moderator Control Panel is a fatal error after installing my app
Two shapes of this, and they fail differently.
If you extend ModCpAbstract and omit one of the three abstract methods, PHP refuses to compile the class. The fatal is raised by the class_exists() call at line 926 that triggers the autoloader, so it happens before any of your code runs and before the page renders anything. It is a compile-time fatal, so it cannot be caught or logged by IPS's exception handler — you get a blank page or a bare PHP fatal in the web server log.
If you do not extend ModCpAbstract, nothing checks that you implemented the interface. The controller calls manageType() at line 111 and getCounters() at line 124 on every extension unconditionally, so a missing method throws Error: Call to undefined method. That one is caught, by IPS\IPS::exceptionHandler (registered in init.php, line 633): the moderator sees the generic error page with no message, and the real trace is in ACP → System → Logs under uncaught_exception. Note that core itself does this — applications/core/extensions/core/ModCp/Reports.php extends IPS\Content\Controller, not ModCpAbstract, and hand-implements the four methods. It works, but it removes your safety net.
TypeError before any of my methods run
ModCpAbstract declares no constructor, but constructExtensionClass() instantiates every extension as new $classToUse( $checkAccess === TRUE ? Member::loggedIn() : ( $checkAccess === FALSE ? NULL : $checkAccess ) ) (system/Application/Application.php, line 479) — and for the ModCP that first argument is always Member::loggedIn(). PHP silently discards the argument when the class has no constructor, which is why core's extensions work. If you declare your own __construct(), its first parameter must accept an IPS\Member — a signature like __construct( array $options = [] ) throws a TypeError that the surrounding try does not catch, because line 497 catches only RuntimeException | OutOfRangeException.
That narrow catch is also a usable escape hatch: throwing OutOfRangeException from your constructor removes your extension from the list for this request with no error, which is a cleaner way to say "not applicable on this site" than returning NULL from getTab() if you also want getCounters() suppressed.
My tab is labelled "modcp_things" in the sidebar
The sidebar label is {lang="modcp_{$key}"} where $key is your tab key (template.phtml, lines 25, 33 and 41). When a language key does not exist, Lang::replaceWords() falls through to $replacement = $values['key'] (system/Lang/Lang.php, line 1987 onwards) and prints the key itself, HTML-escaped. So a missing string is not an error and not a blank entry — it is the literal text modcp_things in the menu. The same applies to each counter's title.
The tab appears, but clicking it lands on a different section
manage() is typed : void, so $content = $extension->$method(); at line 140 always assigns NULL. The controller then falls back to Output::i()->output (lines 141–144). If your manage() never assigns Output::i()->output, $content stays empty, and lines 152–161 redirect to the first tab found in the whole menu:
if ( !$content )
{
foreach ( $tabs as $tabType => $tabList )
{
foreach( $tabList as $k => $data )
{
Output::i()->redirect( Url::internal( "app=core&module=modcp&controller=modcp&tab={$k}", 'front', "modcp_{$k}" ) );
}
}
}
If your section happens to be that first tab, the redirect target is your own tab and the browser loops until it gives up. There is no error and no log entry; the symptom is either "the ModCP keeps sending me to Alerts" or "too many redirects". Assign something to Output::i()->output — even an empty-state template — on every code path through manage().
Note also that on an AJAX request the controller emits $content alone with no page furniture (line 167), so your output must stand on its own.
Every public method on the extension is a URL
This is the most important security consequence of the design. Line 136 reads:
$method = ( Request::i()->action and preg_match( '/^[a-zA-Z\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', Request::i()->action ) ) ? Request::i()->action : 'manage';
if ( $method !== 'getTab' AND ( method_exists( $extension, $method ) or method_exists( $extension, '__call' ) ) )
{
$content = $extension->$method();
}
Any method name matching that pattern is callable as ?tab=yourtab&action=yourMethod. The only name excluded is getTab. Core relies on this: ?action=create reaches Alerts::create() and Announcements::create(), and the approval-queue JavaScript polls ?tab=approval&action=getCount to reach Unapproved::getCount() (applications/core/dev/js/front/controllers/modcp/ips.modcp.approveQueue.js, line 68).
Three things follow. First, the controller performs no permission check beyond "is a moderator at all", so every public method must repeat its own modPermission() check — which is why core's implementations check the same permission twice, once in getTab() and once in manage(). Returning NULL from getTab() does stop the content pane rendering, because the tab comparison at line 134 can never match an empty string, but it does not stop getCounters() from running (see below). Second, the controller performs no CSRF check either; every state-changing method in core calls Session::i()->csrfCheck() itself (for example Alerts.php lines 155 and 220, Announcements.php lines 133 and 171, Deleted.php line 117). Third, method_exists() returns TRUE for protected and private methods too, so a protected helper named buildTable would pass the check and then throw Error: Call to protected method. Core avoids this by prefixing every non-public helper with an underscore (_getContentTypes, _buildWhere) — the regex requires the first character to be a letter, so underscore-prefixed names are rejected before method_exists() is ever reached. Follow that convention.
If you define __call() on your extension, the method_exists( $extension, '__call' ) branch means every action string is routed into it. That is rarely what you want.
getCounters() runs even when getTab() returned NULL
The counter loop at line 124 is outside the if ( $tab ) block at line 118. Every extension's getCounters() is called on every ModCP page view regardless of whether its section is visible, and regardless of which tab is active. Two consequences:
- Put the permission check in
getCounters()as well.Reports.php(lines 94–104) does exactly this by calling its owngetTab()first and returning[]when it is NULL. - If you return counters while
getTab()returns NULL and you omit theidkey, the default id is built withucfirst( $tab )where$tabis NULL — a PHP 8.1 deprecation notice. Always supply an explicitid, as both core implementations do.
The counters run on every request, so anything expensive there slows the entire ModCP. Unapproved::getCounters() passes a $bypassCache flag that is TRUE only for AJAX requests (line 78) for this reason.
My counter renders wrongly, or throws an undefined-key warning
The header template accesses all three keys directly, with only !empty( $counters ) guarding the loop (template.phtml, lines 6–12):
<li {{if !$counter['total']}}class='i-color_soft'{{endif}}><span id="{$counter['id']}" class="ipsBadge {{if !$counter['total']}}ipsBadge--positive{{else}}ipsBadge--warning{{endif}} i-margin-end_icon">{$counter['total']}</span> {lang="$counter['title']"}</li>
So title and total are effectively mandatory even though the docblock calls id the only optional one — the controller fills in id for you, but nothing fills in the other two. Note also that the badge is styled ipsBadge--positive when total is falsy and ipsBadge--warning otherwise: zero is the good state. A counter that reports "how many things I have" rather than "how many things need attention" will look permanently alarming.
manageType() returning something unexpected does not error
Line 113 is in_array( $extension->manageType(), array( 'content', 'members' ) ) ? $extension->manageType() : 'other'. A typo such as member or Content (the check is case-sensitive) silently files your section under "Tools" instead of the group you intended. There is no validation and no warning; the only symptom is the section appearing under the wrong heading.
?do= reaches my extension and then the page 404s anyway
The controller defines __call() (lines 54–78) purely so that a ?do= parameter naming a method that exists on an extension does not trip the dispatcher's own 404 (system/Dispatcher/Controller.php, error code 2S106/1). What it actually does is call $this->manage() — the normal ModCP render — and then, when that returns, falls straight through to:
/* Still here? */ Output::i()->error( 'page_not_found', '2C139/6', 404, '' );
Nothing between those two points stops execution unless something inside your code terminates the request itself — Output::i()->json(), Output::i()->redirect() or Output::i()->error(). If your method merely assigns Output::i()->output, the moderator gets a 404 page with error code 2C139/6. Use ?action= rather than ?do= for anything that renders a page; core's own ModCP templates only ever link with action=.
One further wrinkle in that method: line 66 calls mb_strtolower( $extension->getTab() ) without the (string) cast that line 134 has. Any request carrying a ?do= parameter therefore raises a PHP 8.1 deprecation for every extension whose getTab() returns NULL. It is core's bug, not yours, but it will appear in your logs alongside your extension's name if you are looking at a stack trace.
The order of the sidebar, and of the fallback redirect, is not configurable
There is no priority or weight property on ModCpAbstract. Entries appear in the order allExtensions() returns them, which is application order and then the order of keys within each app's extensions.json. Application order is core_applications sorted by app_position (Application::getStore(), system/Application/Application.php line 249), which an admin can reorder in the ACP — core is normally first but is not guaranteed to be. The three group headings, by contrast, are not affected by iteration order at all: template.phtml hardcodes them as content (line 21), then members (line 29), then other (line 37). Iteration order only decides the position of an entry within its group — and, separately, which tab the fallback redirect below picks. In practice core's own extensions dominate the top of the list and a third-party section lands at the bottom of its group. If you need a specific position, there is nothing in this extension point that provides it.
The $tabs array is initialised as array( 'reports' => array(), 'approval' => array() ) at line 101, but nothing ever writes to those two keys — the loop only writes to content, members and other — and array_filter( $tabs, 'count' ) at line 149 removes them. They are leftovers with no effect.
Adding a sub-tab to Member Management is a different extension point
The tabs inside the "Member Management" section (Banned, Restricted, Queued) are not ModCp extensions. They come from core/ModCpMemberManagement, whose abstract is system/Extensions/ModCpMemberManagementAbstract.php and which declares only two methods:
abstract public function getTab() : ?string; abstract public function manage() : string;
Note that this manage() returns a string rather than assigning to Output. They are loaded by Members::manage() (applications/core/extensions/core/ModCp/Members.php, line 100) with Application::allExtensions( 'core', 'ModCpMemberManagement', TRUE, 'core', 'Banned' ) — the last two arguments force core's Banned extension to sort first so it can be the default. The match against the ?area= parameter at line 108 is unusual:
$exploded = explode( "_", $key ); if( mb_strtolower( $key ) == $exploded[0] . "_" . mb_strtolower( $area ) )
$key here is <appdirectory>_<extensionkey>. The comparison therefore requires your extension key in extensions.json to equal the area name case-insensitively, and it splits on the first underscore in the whole string — so an application directory containing an underscore breaks the match and your sub-tab can never be selected. Also, $content at lines 118 and 124 is only assigned inside that if, so an unrecognised ?area= value produces an "Undefined variable $content" warning rather than a clean 404.
Moderators may not be able to find your section at all
The link in the user bar that most moderators use to reach the ModCP is gated on report centre access, not on moderator status generally: {{if \IPS\Member::loggedIn()->canAccessModule( \IPS\Application\Module::get( 'core', 'modcp' ) ) and \IPS\Member::loggedIn()->canAccessReportCenter()}} (applications/core/dev/html/front/global/userBar.phtml, line 89). A moderator who has your permission but not report centre access has no visible route to /modcp from the user bar. Whether every other entry point in the default theme carries the same condition is unverified; if your section needs to be reachable by such moderators, link to /modcp/<your-tab-key> from somewhere in your own app.
Verified against
Read from Invision Community 5.0.19 source (applications/core/data/versions.json, final entry 5001908 => "5.0.19"). Files inspected: system/Extensions/ModCpAbstract.php and system/Extensions/ModCpMemberManagementAbstract.php; the sole consumer applications/core/modules/front/modcp/modcp.php; Application::allExtensions(), Application::extensions() and Application::constructExtensionClass() in system/Application/Application.php; Application::extensionHelper() in applications/core/Application.php; system/Dispatcher/Controller.php; system/Lang/Lang.php; init.php; the stub applications/core/data/defaults/extensions/ModCp.txt; applications/core/data/furl.json; the templates front/modcp/template.phtml and front/modcp/members.phtml; front/global/userBar.phtml; ips.modcp.approveQueue.js; and all ten first-party implementations in applications/core/extensions/core/ModCp/. In this installation no other application — forums, cms, nexus, downloads, gallery, blog, calendar or convert — ships a core/ModCp extension, so every example above is from core.
Recommended Comments