A moderator who opens the Moderator Control Panel and clicks "Member Management" gets a member search box and, under it, a strip of tabs: Banned, Restricted, Queued. Each tab is a table of members who are in some administrative state the moderator may need to act on — members serving a ban or suspension, members whose posts are being held for approval, members whose content is queued. A core/ModCpMemberManagement extension is one of those tabs. It supplies the tab key and the HTML of the table beneath it, and nothing else. Core's own description of the extension type, in applications/core/dev/lang.php line 1300, is "Add filters to the ModCP member management page."
The extension is deliberately thin. It does not declare a permission of its own, and it gets no routing of its own — there is no do= dispatch, no separate URL, and no method that core will call for an action. Everything reaches it as one unparameterised manage() call on the parent page's URL, so anything you want to handle (a submitted form, a do= parameter of your own invention) you must detect yourself by reading IPS\Request::i() inside manage(). If you need a moderator screen that core actually routes to, you want a core/ModCp extension instead, which is the thing that owns the whole "Member Management" tab in the first place.
The contract
The abstract is system/Extensions/ModCpMemberManagementAbstract.php. It is fifteen lines of actual code, with two abstract methods, no properties and no constructor:
namespace IPS\Extensions;
abstract class ModCpMemberManagementAbstract
{
/**
* Returns the tab key for the navigation bar
*
* @return string|null
*/
abstract public function getTab() : ?string;
/**
* Get content to display
*
* @return string
*/
abstract public function manage() : string;
}
getTab() is called on every request to the member management page, for every registered extension. Its return value is used as the tab's identity: it becomes the area query string parameter in the tab's link, and it becomes the suffix of the language key used for the tab's label. Returning NULL — or any falsy string, including '' — removes the tab from the page. This is the only place an extension can hide itself, so any permission check you want belongs here.
manage() is called only on the extension whose tab is currently selected. It must return a string, which is injected raw into the tab panel. The three core implementations build an IPS\Helpers\Table\Db and return (string) $table. Nothing stops you returning any other markup; core does not inspect it.
Note that manage() takes no arguments. It has no idea which tab is active, which member is logged in, or what the request was. If it needs any of that it must read IPS\Request::i() and IPS\Member::loggedIn() itself.
Registration
The class lives at applications/<app>/extensions/core/ModCpMemberManagement/<Key>.php in the namespace IPS\<app>\extensions\core\ModCpMemberManagement, and the Developer Centre writes it into applications/<app>/data/extensions.json. That JSON file is the sole registration mechanism — Application::extensions() reads it and nothing else, and it silently continues past any entry whose class does not exist (system/Application/Application.php line 926). Core's own entry, at applications/core/data/extensions.json lines 246–250, is:
"ModCpMemberManagement": {
"Banned": "IPS\\core\\extensions\\core\\ModCpMemberManagement\\Banned",
"Queued": "IPS\\core\\extensions\\core\\ModCpMemberManagement\\Queued",
"Restricted": "IPS\\core\\extensions\\core\\ModCpMemberManagement\\Restricted"
}
The extension list is cached in the datastore under the extensions key (system/Application/Application.php lines 359 and 442). A newly added extension does not appear until that cache is rebuilt.
Who calls it
There is exactly one consumer in the whole suite: applications/core/extensions/core/ModCp/Members.php, the core/ModCp extension that owns the "Member Management" tab. Its manage() method, lines 98–113, is the entire integration:
/* Load the extensions */
$tabs = array();
foreach ( Application::allExtensions( 'core', 'ModCpMemberManagement', TRUE, 'core', 'Banned' ) as $key => $extension )
{
$tab = $extension->getTab();
if ( $tab )
{
$tabs[ $tab ][] = $key;
$exploded = explode( "_", $key );
if( mb_strtolower( $key ) == $exploded[0] . "_" . mb_strtolower( $area ) )
{
$content = $extension->manage();
}
}
}
Four things in that block matter.
First, $area comes from the query string and defaults to banned (line 78: $area = Request::i()->area ?: 'banned';).
Second, $checkAccess is TRUE. That has two quite different effects, and only one of them is per-request. It constructs each extension with Member::loggedIn() as the sole constructor argument (system/Application/Application.php line 479) — that happens on every request. The abstract declares no constructor, so that argument is normally discarded; if you declare a constructor, expect to receive a Member. It also makes allExtensions() skip any application the member cannot access ($application->canAccess(), line 408) — but that filter runs only inside the cache-building branch (lines 367–443), and the filtered result is then written to the shared extensions datastore key for everyone. So the access check is effectively baked in by whichever member happened to trigger the rebuild, and is not re-evaluated per request. Do not rely on $checkAccess to hide your tab; put the check in getTab(), which genuinely does run every time.
Third, $firstApp is 'core' and $firstExtensionKey is 'Banned', which is what fixes the tab order: core's three tabs first, Banned leftmost, everything from other applications after them.
Fourth — and this is the part that catches people — the active tab is chosen by the extension's class name, not by what getTab() returned. $key is <appDirectory>_<ExtensionKey>, built at Application.php line 419. $exploded[0] is the text before the first underscore, normally the application directory. So the test reduces to "is the lowercased extension key equal to the lowercased area". getTab() decides whether the tab is shown and what it is called; the class name decides which area value actually loads it.
Two properties of that comparison are worth knowing, because both are unfixable from an extension. The left side is lowercased in full but $exploded[0] is not, so an application directory containing an uppercase letter can never match (myapp_flagged vs MyApp_flagged). And explode() keeps only the first segment, so an application directory containing an underscore can never match either (my_app_flagged vs my_flagged). Every shipped IPS application directory is lowercase with no underscore; keep yours that way.
The whole page is gated behind one moderator permission, checked twice in Members.php — getTab() at lines 45–48 returns NULL without it, and manage() at lines 72–75 throws a 403 with code 2C228/1:
if ( ! Member::loggedIn()->modPermission('can_modify_profiles') )
There is no way for a core/ModCpMemberManagement extension to be visible to a moderator who lacks can_modify_profiles.
How the tab is rendered
The template is applications/core/dev/html/front/modcp/members.phtml. Lines 7–11 loop over $tabs, keyed by tab key:
{{foreach $tabs as $key => $tab}}
<a href='{url="app=core&module=modcp&tab=members&area=$key" seoTemplate="modcp_members"}' ...>
{lang="modcp_members_{$key}"}
</a>
{{endforeach}}
So a tab key of flagged produces the URL /modcp/members?area=flagged and needs a language string named modcp_members_flagged. Core ships modcp_members_banned, modcp_members_restricted and modcp_members_queued at applications/core/dev/lang.php lines 5958–5960.
The tab bar is an ipsTabBar with data-ipsTabBar-contentArea='#elmodCPTabs_content', so clicking a tab fetches the same URL over AJAX; Members::manage() line 118 then returns bare $content with no page furniture.
Failure modes
A missing method is a hard fatal. Both methods are abstract. Omitting either one means PHP refuses to compile the class — "Class ... contains 1 abstract method and must therefore be declared abstract". This is an E_COMPILE_ERROR, not a Throwable, so it is not catchable at all: the catch( RuntimeException | OutOfRangeException ) in Application::constructExtensionClass() (Application.php line 497) is irrelevant, and so would be a catch( Error ) if you added one. The autoload is triggered by class_exists() — at Application.php line 926 while the list is being built, and at line 472 on every subsequent request from the cached list — and the entire ModCP page dies there. You will notice this one immediately.
Returning the wrong type from manage() is a TypeError. The declared return type is string. Returning an array or NULL throws a TypeError, and nothing between manage() and the dispatcher catches it — the call at Members.php line 110 is not inside a try. It reaches \IPS\IPS::exceptionHandler, which logs it to core_log under uncaught_exception and renders the generic error page with HTTP 500. Unlike the blank-tab case described next, this one does leave a trace. Returning an object works only if it has __toString(), since the coercion happens in weak typing mode; core's own implementations cast explicitly with (string) $table rather than rely on that.
A tab key that does not match the class name produces a permanently blank tab, silently. This is the failure that costs the most time. Suppose your class is Flagged but getTab() returns 'reported'. The tab appears, correctly labelled from modcp_members_reported. Clicking it sets $area=reported, but the match test compares acme_flagged against acme_reported and fails, so manage() is never called. $content is never assigned — Members::manage() does not initialise it — so PHP raises an "Undefined variable $content" E_WARNING at line 118 or 124, and the tab panel renders empty.
On a production install that warning goes nowhere. init.php line 632 registers \IPS\IPS::errorHandler, and that handler (init.php lines 792–809) returns immediately for E_WARNING, E_NOTICE, E_STRICT and E_DEPRECATED. Because a PHP error handler that returns anything other than FALSE suppresses the default handler, nothing is written to the PHP error log and nothing is written to core_log. No exception, no error page, no log line, just an empty tab — which is why this one costs so much time. If a tab of yours is blank, check that getTab() returns your class name, case-insensitively.
The one place it is not silent is a development install with IN_DEV and DEV_USE_WHOOPS both enabled and dev/Whoops/Run.php present. In that case init.php registers Whoops instead of IPS::errorHandler, and Whoops converts the warning into an ErrorException (dev/Whoops/Run.php line 478) and shows its own error page. So the same mistake looks like a hard error in dev and like an empty tab in production.
Note that the Developer Centre stub, applications/core/data/defaults/extensions/ModCpMemberManagement.txt line 36, generates return '{class}'; — the class name with its original capitalisation. That matches correctly, because the comparison lowercases both sides. But the language key is not lowercased, so the generated stub for a class Flagged needs a string called modcp_members_Flagged, with the capital F. Core's own extensions return lowercase from getTab() and use lowercase keys.
A missing language string prints the key. If modcp_members_<tab> does not exist, Lang::replaceWords() falls through to line 2134, $replacement = $values['key'];, and the raw key is HTML-escaped and printed. The tab's visible label becomes the literal text modcp_members_flagged. Nothing is logged.
getTab() returning NULL removes the tab with no trace. Line 104 is a bare if ( $tab ). An empty string behaves the same way. If your tab has vanished, the usual causes are your own permission check inside getTab(), the moderator lacking can_modify_profiles, your class being missing or unloadable so that Application::extensions() skipped it at line 926, the application being disabled or your app having been inaccessible to whichever member last rebuilt the cached list, or the extension datastore cache still holding the old list.
Class names collide across applications. The match test does not restrict itself to one application, so if two applications both register an extension named Flagged, both match area=flagged and the later one in iteration order overwrites $content. The same applies to core's names: an extension of yours called Banned will be evaluated after core's, because core is forced first, and will replace core's banned-members table. Give your extension a name nobody else will use.
Two extensions can share a tab key, but only one can fill it. Line 106 collects keys into $tabs[ $tab ][], an array, so duplicates are tolerated and only one tab link is drawn. Which extension's manage() runs still depends solely on the class-name match. Core never does this, and the collected extension keys are never read for anything useful: the only place the value array is touched is the template's aria-selected test at line 8, {{if $activeTab == $tab}}, which compares the string $activeTab against that array. Under PHP 8 an array is never equal to a string, so that test is always false and aria-selected="false" is emitted on every tab, including the active one. That is a core presentation bug and not something an extension can influence.
A complete example
This is core's Restricted extension, from applications/core/extensions/core/ModCpMemberManagement/Restricted.php, retargeted to a hypothetical application acme and a tab of members flagged by that application. It is structurally complete: a tab key, a permission-free getTab(), and a database table returned as a string. One thing in it is not real — the WHERE clause selects on a column acme_flagged that a hypothetical acme application would have had to add to core_members in its own migration. Substitute a column that exists or the table will throw a Db exception on the first query. The rest is copied verbatim from core.
<?php
namespace IPS\acme\extensions\core\ModCpMemberManagement;
use IPS\DateTime;
use IPS\Extensions\ModCpMemberManagementAbstract;
use IPS\Helpers\Table\Db;
use IPS\Http\Url;
use IPS\Member;
use IPS\Member\Group;
use IPS\Theme;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Flagged extends ModCpMemberManagementAbstract
{
/**
* Returns the tab key for the navigation bar
*
* MUST match this class name, case-insensitively, or the tab
* will render but never load anything.
*
* @return string|null
*/
public function getTab() : ?string
{
return 'flagged';
}
/**
* Get content to display
*
* @return string
*/
public function manage() : string
{
/* Create the table. The third argument is the WHERE clause. */
$table = new Db( 'core_members', Url::internal( 'app=core&module=modcp&tab=members&area=flagged' ), 'acme_flagged<>0' );
$table->rowsTemplate = array( Theme::i()->getTemplate( 'modcp', 'core', 'front' ), 'memberManagementRow' );
$table->langPrefix = 'members_';
/* Columns we need. memberManagementRow reads name, photo and
member_id, so those three are not optional if you reuse it. */
$table->include = array( 'name', 'email', 'joined', 'member_group_id', 'photo', 'member_id' );
$table->mainColumn = 'name';
$table->parsers = array(
'joined' => function( $val, $row )
{
return DateTime::ts( $val )->localeDate();
},
'member_group_id' => function( $val, $row )
{
return Group::load( $val )->formattedName;
},
'photo' => function( $val, $row )
{
return Theme::i()->getTemplate( 'global', 'core' )->userPhoto( Member::constructFromData( $row ), 'mini' );
}
);
/* Individual member actions */
$table->rowButtons = function( $row )
{
$member = Member::constructFromData( $row );
return array(
'edit' => array(
'icon' => 'pencil',
'title' => 'edit',
'link' => Url::internal( 'app=core&module=members&controller=profile&do=edit&id=' . $member->member_id, 'front', 'edit_profile', $member->members_seo_name )
)
);
};
$table->sortBy = $table->sortBy ?: 'joined';
$table->sortDirection = $table->sortDirection ?: 'desc';
return (string) $table;
}
}
Two supporting pieces are required and are easy to forget. The entry in applications/acme/data/extensions.json:
{
"core": {
"ModCpMemberManagement": {
"Flagged": "IPS\\acme\\extensions\\core\\ModCpMemberManagement\\Flagged"
}
}
}
And the language string in applications/acme/dev/lang.php:
'modcp_members_flagged' => "Flagged",
The row template used above, applications/core/dev/html/front/modcp/memberManagementRow.phtml, reads $row['photo'], $row['name'], $row['member_id'] and $row['_buttons']. Line 10 hard-codes a single special case: any row button whose title is exactly the string modcp_view_warnings is also rendered as a plain inline <a> in the list under the member's name. Every button, including that one, additionally goes into the gear dropdown. The example above defines only an edit button, so it gets a gear menu and no inline link; if you want your own inline link there you will need your own row template.
What else ships with one
Nothing. In a full 5.0.19 install, core is the only application with a ModCpMemberManagement key in its data/extensions.json, and it registers three: Banned (temp_ban<>0), Queued (mod_posts<>0) and Restricted (restrict_post<>0). Forums, Pages, Commerce, Downloads, Gallery, Blog and Calendar register none. The three core files are near-identical — Banned differs only in that it adds member_id twice to $table->include and adds a name parser that renders "banned" or "suspended until <date>" depending on whether temp_ban is -1.
A leftover language string modcp_members_suspended exists at applications/core/dev/lang.php line 5961 with no extension behind it. It is unused as far as this extension point is concerned.
Unverified
Everything above is read from source, not observed on a running install. Three claims in particular are source-reasoning rather than reproduced behaviour:
The exact rendered output when the extension datastore cache is stale has not been reproduced here; the claim that a new extension does not appear until the cache is rebuilt is inferred from the caching in Application::allExtensions() (Application.php lines 357–367 and 442).
The claim that the blank-tab warning is never logged follows from IPS::errorHandler returning early for E_WARNING (init.php lines 796–800) plus PHP's documented rule that a handler which does not return FALSE suppresses the default handler. It has not been confirmed against a live php_errors.log, and a site running its own auto_prepend_file or an APM agent that hooks errors ahead of IPS could see it.
The claim that the canAccess() filter is baked into the shared cache follows from that filter sitting inside the if( !array_key_exists( $extension, $allExtensions ) ) branch that ends with Store::i()->extensions = $allExtensions;. The practical consequence — that a member without access to your app can still get your extension constructed — has not been reproduced.
Verified against
Read from the source of Invision Community 5.0.19. Key files: system/Extensions/ModCpMemberManagementAbstract.php, applications/core/extensions/core/ModCp/Members.php (the only consumer), applications/core/extensions/core/ModCpMemberManagement/Banned.php, Queued.php and Restricted.php, applications/core/data/defaults/extensions/ModCpMemberManagement.txt, applications/core/data/extensions.json, applications/core/dev/html/front/modcp/members.phtml, applications/core/dev/html/front/modcp/memberManagementRow.phtml, applications/core/modules/front/modcp/modcp.php, system/Application/Application.php (allExtensions(), constructExtensionClass(), extensions()), system/Lang/Lang.php (addToStack(), replaceWords()), system/Helpers/Table/Db.php, init.php (errorHandler(), exceptionHandler()), applications/core/data/furl.json and applications/core/dev/lang.php. Nothing here is inferred from Invision Community 4. The concrete extensions carry @since 29 Oct 2013, so the extension point itself predates 5, but the ModCpMemberManagementAbstract class that now defines the contract is dated @since 11/20/2023 — the typed signatures, the extensions.json registration and the datastore caching described above are all 5-era. No claim here was checked against a 4.x tree, so if you are porting a 4.x extension, re-check the signatures rather than assuming they are unchanged.
Recommended Comments