When an administrator opens a member in the AdminCP — app=core&module=members&controller=members&do=view&id=1 — they see a two-column page of blocks: basic information, groups, warnings, content statistics, and so on. The core/MemberACPProfileTabs extension adds a second (third, fourth) whole page of blocks to that screen, reached by a tab bar across the top. On a stock install with Commerce installed there are exactly two: "Member View" and "Customer View". Your extension is what puts a third tab there and decides which blocks fill its two columns.
This is the outer container only. The individual boxes are a separate extension point, core/MemberACPProfileBlocks, and a tab does nothing but name them.
The contract
The base class is not under system/Extensions/. Unlike almost every other IPS 5 extension point there is no MemberACPProfileTabsAbstract. The class you extend is IPS\core\MemberACPProfile\MainTab, which lives at applications/core/sources/MemberACPProfile/MainTab.php.
The class itself is declared abstract, so it cannot be instantiated directly — but no method in it is abstract. Every method has a working default, so a subclass with an empty body compiles and runs; it just renders an empty tab. These are the real signatures, from MainTab.php:
abstract class MainTab
{
/* Line 35. The member being viewed. Set by the constructor. */
protected ?Member $member = null;
/* Line 43. Takes the member, NOT the "checkAccess" argument that most
other IPS extension constructors receive. See "who calls it" below. */
public function __construct( Member $member )
/* Line 53. Whether this admin may see the tab at all. Default TRUE.
Returning FALSE removes the tab from the bar entirely. */
public function canView() : bool
/* Line 64. STATIC. Called on the class name from the template, never on
an instance, so it must not touch $this->member. The default derives a
language key from the namespace - see below. */
public static function title() : string
/* Line 76. Array of fully-qualified MemberACPProfileBlocks class-name
STRINGS for the narrow left column. Default: empty array. */
public function leftColumnBlocks() : array
/* Line 86. Same, for the wide main column. Default: empty array. */
public function mainColumnBlocks() : array
/* Line 96. Builds the tab's HTML. You rarely override this outright;
override it and call parent::output() if you need to push CSS or JS. */
public function output() : string
}
The two block methods return strings, not objects. MainTab::output() instantiates each one itself at lines 100–111 with new $class( $this->member ).
Where the tab's label comes from
MainTab::title() at lines 64–69 does not ask you for a title. It splits its own class name on backslashes and builds a language key from parts 1 and 5:
$class = get_called_class(); $exploded = explode( '\\', $class ); return Member::loggedIn()->language()->addToStack( 'memberACPProfileTitle_' . $exploded[1] . '_' . $exploded[5] );
For IPS\nexus\extensions\core\MemberACPProfileTabs\Main that is memberACPProfileTitle_nexus_Main, which Commerce defines as "Customer View" in applications/nexus/dev/lang.php line 1045. Core's own is memberACPProfileTitle_core_Main — "Member View" — in applications/core/dev/lang/members.php line 380.
The Developer Center knows about this key. applications/core/modules/admin/developer/details.php line 576 maps the extension type to the pattern memberACPProfileTitle_{app}_{key} and reports it under missing admin language strings if you have not defined it.
Registration
Two files. The class at applications/myapp/extensions/core/MemberACPProfileTabs/MyTab.php, and an entry in applications/myapp/data/extensions.json:
{
"core": {
"MemberACPProfileTabs": {
"MyTab": "IPS\\myapp\\extensions\\core\\MemberACPProfileTabs\\MyTab"
}
}
}
The key core uses internally is {appDirectory}_{ExtensionName} — built in Application::allExtensions() at system/Application/Application.php line 419 as $application->directory . '_' . $key. So the example above is myapp_MyTab, and that string is what appears in the &tab= query parameter. It must match the $exploded[1] . '_' . $exploded[5] that title() computes, which it will as long as you follow the standard namespace.
The Developer Center will write both the file and the JSON entry for you; the skeleton it copies is applications/core/data/defaults/extensions/MemberACPProfileTabs.txt.
Who calls it
Exactly one place in the whole suite: IPS\core\modules\admin\members\members::view(), at applications/core/modules/admin/members/members.php lines 1349–1363.
/* Get the available tabs */
$extensions = array();
foreach( Application::allExtensions( 'core', 'MemberACPProfileTabs', TRUE, 'core', 'Main', FALSE ) AS $key => $ext )
{
$class = new $ext( $member );
if ( $class->canView() )
{
$extensions[ $key ] = $ext;
}
}
/* What's our active tab? */
$activeTab = ( isset( Request::i()->tab ) and array_key_exists( Request::i()->tab, $extensions ) ) ? Request::i()->tab : 'core_Main';
$classname = $extensions[ $activeTab ];
$tab = new $classname( $member );
Three details in that call matter.
- The last argument is
FALSE, meaning$construct=FALSE.allExtensions()returns class-name strings, and the controller constructs them itself.Application::constructExtensionClass()— which wraps construction incatch( RuntimeException | OutOfRangeException )atApplication.phplines 477–497 — is never reached. The usual safety net does not apply to this extension point. - The fourth and fifth arguments,
'core'and'Main', sortcore_Mainto the front. "Member View" is always the first tab; everyone else appends in application order. - The third argument,
TRUE, is an application-level access check (Application::canAccess(),Application.phpline 5299), not a per-extension one. An admin with thecore / applications / app_managerestriction passes it unconditionally.
Every registered tab class is constructed on every member view, whether or not it is the tab being displayed, because canView() is an instance method. The active tab is constructed twice — once in the loop, once at line 1363. Keep constructors cheap.
The list is then handed to applications/core/dev/html/admin/memberprofile/mainTemplate.phtml, which renders the bar. Note line 4:
{{if \count( $extensions ) > 1}}
With only one tab there is no tab bar at all. Line 9 calls {$classname::title()} statically on the stored string.
Tab switching is AJAX. dev/js/framework/common/ui/ips.ui.tabbar.js fetches the tab's href and injects the response into the panel; members::view() line 1385 returns bare $tab->output() for that request.
A complete working example
Commerce's tab is the only non-core implementation in the suite and is a good template. This is applications/nexus/extensions/core/MemberACPProfileTabs/Main.php in full, abridged only in the block lists:
<?php
namespace IPS\nexus\extensions\core\MemberACPProfileTabs;
use IPS\core\MemberACPProfile\MainTab;
use IPS\Member;
use IPS\nexus\Customer;
use IPS\Output;
use IPS\Theme;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Main extends MainTab
{
public function __construct( Member $member )
{
/* Swap in a richer object. Note: parent::__construct() is NOT called. */
$this->member = Customer::load( $member->member_id );
}
public function canView(): bool
{
return (bool) Member::loggedIn()->hasAcpRestriction( 'nexus', 'customers', 'customers_view' );
}
public function leftColumnBlocks(): array
{
return array(
'IPS\nexus\extensions\core\MemberACPProfileBlocks\AccountInformation',
);
}
public function mainColumnBlocks(): array
{
$return = array();
if ( Member::loggedIn()->hasAcpRestriction( 'nexus', 'customers', 'customers_view_statistics' ) )
{
$return[] = 'IPS\nexus\extensions\core\MemberACPProfileBlocks\Statistics';
}
$return[] = 'IPS\nexus\extensions\core\MemberACPProfileBlocks\ParentAccounts';
/* ...further blocks, each behind its own hasAcpRestriction() check... */
return $return;
}
public function output(): string
{
Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'customer.css', 'nexus', 'admin' ) );
Output::i()->jsFiles = array_merge( Output::i()->jsFiles, Output::i()->js( 'admin_customer.js', 'nexus', 'admin' ) );
return parent::output();
}
}
Plus 'memberACPProfileTitle_nexus_Main' => "Customer View", in applications/nexus/dev/lang.php, and the extensions.json entry.
Core's own tab, applications/core/extensions/core/MemberACPProfileTabs/Main.php, is simpler still: it overrides nothing but the two block methods, and gates individual blocks on ACP restrictions (membertools_ip, member_mfa) rather than gating the tab.
Failure: the tab file exists but no tab appears, and nothing is logged
This is the common one and it is completely silent. Application::extensions() (Application.php lines 911–974) does not scan the extensions/ directory — that was the IPS 4 behaviour. It reads data/extensions.json and nothing else, and it skips any entry whose class fails class_exists() (lines 926–930):
if( !is_string( $classname ) or !class_exists( $classname ) )
{
/* Switching between branches confuses extensions */
continue;
}
A typo in the namespace, a missing JSON entry, or a file whose declared class name does not match the fully-qualified name in the JSON all produce the same result: no tab, no error, no system log entry. (A JSON key that differs from the class basename is a different bug — the tab still appears, but title() computes a language key that nobody defined; see the next section.) The resolved list is then cached in the datastore as Store::i()->extensions keyed by extension type (Application.php line 442), and rebuilt only when the MemberACPProfileTabs key is absent — so fixing the file on a live site does nothing until the cache is cleared. Application::extensions() also holds a per-request static, static::$_loadedExtensions.
The same cache carries the recovery-mode trap that affects every extension type: Application.php lines 395–399 skip third-party apps when RECOVERY_MODE is on, and the filtered result is still written to the datastore. One member view loaded in recovery mode bakes a core-and-nexus-only tab list in until the cache is cleared.
Failure: the tab label renders as "memberACPProfileTitle_myapp_MyTab"
You did not define the language string, or you defined it with the wrong key. IPS renders unresolved keys as their literal text, so the tab bar shows the raw key. Add it to applications/myapp/dev/lang.php and rebuild; the Developer Center's language-string scan lists it for you (details.php line 576).
A subtler variant: title() hard-codes array offsets 1 and 5 of the exploded class name. It assumes exactly IPS\{app}\extensions\core\MemberACPProfileTabs\{Name}. Deviate and you get one of two failures. A shorter namespace — a shared base class in sources/ registered directly, say IPS\myapp\sources\Profile\MyTab — leaves $exploded[5] undefined; PHP 8 raises an "Undefined array key 5" warning and the key degrades to memberACPProfileTitle_{app}_. A deeper one — a subdirectory, IPS\myapp\extensions\core\MemberACPProfileTabs\Sub\MyTab — is worse, because $exploded[5] is defined but wrong: it is Sub, so the key silently becomes memberACPProfileTitle_myapp_Sub with no warning at all. Override title() as a public static function if you must deviate.
Failure: the whole member view dies with an uncaught error
Because members::view() constructs the classes directly rather than through constructExtensionClass(), there is no try/catch anywhere in lines 1351–1363. Consequences:
- If your constructor throws — an
OutOfRangeExceptionfrom a::load()on a record that does not exist, say — the exception escapes and the AdminCP member view breaks for every member, not just yours. Commerce's constructor callsCustomer::load()unguarded; it gets away with it becauseview()already 404s on a missing member at line 1344. - If your class does not extend
MainTab(or extends nothing),$class->canView()at line 1354 is a fatalError: Call to undefined method. - If your override of
canView()declares a return type incompatible with the parent's: bool, that is a fatal at class-compile time, raised by the autoloader insideclass_exists()— which means it fires wherever the extension list is built, not only on the member view.
Note the asymmetry: a broken tab class breaks the page, whereas a broken registration is silent. If the member view suddenly white-screens after you install an app, the tab extension is the first thing to look at.
Failure: the tab appears but is blank, or a block is missing
An empty tab means both leftColumnBlocks() and mainColumnBlocks() returned empty arrays — which is the inherited default, so this is what you get if you forgot to override them. tabTemplate.phtml renders an empty <section> without complaint.
A wrong class-name string is not silent. MainTab::output() lines 100–111 do new $class( $this->member ) with no class_exists() guard, so a typo in a block class name is a fatal "Class not found". Note also that the strings are literal — the blocks are constructed by name, so a block that lives in another application works fine as long as that application is installed. It is not checked, so listing a block from an app the site does not have installed is a fatal error.
Individual blocks can also opt in to your tab from the outside. MainTab::output() lines 118–142 walk every registered MemberACPProfileBlocks extension, skip any your two block methods already named (lines 121–124 keep a $seenBlocks list, so a block cannot be added twice), and inject any of the rest whose static $displayTab equals {app}_{ExtensionName} of the current tab:
if( isset( $ext::$displayTab ) AND $ext::$displayTab == $thisTab )
{
$class = new $ext( $this->member );
if( isset( $ext::$displayColumn ) )
{
switch( $ext::$displayColumn )
{
case 'left': $leftColumnBlocks[] = $class; break;
case 'main': $mainColumnBlocks[] = $class; break;
}
}
}
There is a quiet trap here for the block author, worth knowing because you will be asked about it: a block that sets $displayTab but leaves $displayColumn at the skeleton's default empty string matches neither case. The block is constructed and then thrown away, with no output and no error. The skeleton at applications/core/data/defaults/extensions/MemberACPProfileBlocks.txt lines 35 and 44 ships both properties defaulted to ''. No first-party block in 5.0.19 uses this mechanism; it exists purely for third-party blocks.
Failure: your CSS is missing after switching tabs
Commerce adds its stylesheet by overriding output() and pushing onto Output::i()->cssFiles before calling parent::output(). That works on a full page load, where the head is rendered after the tab body is built. It does not help on the AJAX tab switch: ips.ui.tabbar.js requests the tab URL and injects the returned fragment into the existing page, and members::view() line 1385 returns only $tab->output() — the head of the already-rendered page is not revisited, so a stylesheet registered during that request has nowhere to go. Loading the tab's URL directly (with &tab=myapp_MyTab) is a full render and does pick it up. If your tab needs styling under all entry paths, ship it in the ACP's global CSS rather than registering it from output(). The precise behaviour of an AJAX-only tab switch on a first paint has not been reproduced against a running site; this is read from the controller and the tab-bar JavaScript.
Failure: a bookmarked tab URL silently lands on Member View
Line 1361 falls back to 'core_Main' whenever Request::i()->tab is not a key in the filtered list. An admin whose canView() check fails, or who has a link to a tab from an app that has since been disabled, is not told anything — they simply see the default tab. There is no error and no redirect, so the URL in the address bar still says &tab=myapp_MyTab.
Related but different extension points
core/MemberACPProfileBlocks— the boxes inside a tab. Base classIPS\core\MemberACPProfile\Block, withLazyLoadingBlockandTabbedBlocksubclasses in the same directory. This is what you usually want.core/MemberACPProfileContentTab— a tab inside the Content Statistics block, not a page-level tab. Consumed byapplications/core/extensions/core/MemberACPProfileBlocks/ContentStatistics.phpline 171. Downloads and Gallery implement it.core/MemberACPManagement— the member list, not the member view. This one does have an abstract atsystem/Extensions/MemberACPManagementAbstract.php.core/MemberRestrictions— extends the Warnings & Restrictions block's form. Base classIPS\core\MemberACPProfile\Restriction.
Verified against
Read from Invision Community 5.0.19 source (applications/core/data/versions.json, whose highest — and last — entry is 5001908 => "5.0.19"; the file is ordered oldest-first). Files inspected: applications/core/sources/MemberACPProfile/MainTab.php, Block.php, LazyLoadingBlock.php, TabbedBlock.php and Restriction.php; both implementations that ship in the suite, applications/core/extensions/core/MemberACPProfileTabs/Main.php and applications/nexus/extensions/core/MemberACPProfileTabs/Main.php; the sole consumer applications/core/modules/admin/members/members.php; Application::allExtensions(), Application::extensions(), Application::constructExtensionClass() and Application::canAccess() in system/Application/Application.php; applications/core/modules/admin/developer/details.php; the templates applications/core/dev/html/admin/memberprofile/mainTemplate.phtml and tabTemplate.phtml; the skeletons applications/core/data/defaults/extensions/MemberACPProfileTabs.txt and MemberACPProfileBlocks.txt; and dev/js/framework/common/ui/ips.ui.tabbar.js. A grep of the full tree for MemberACPProfileTabs finds implementations in core and nexus only — forums, cms, downloads, gallery, blog and calendar ship none — and exactly one consumer.
Recommended Comments