When an administrator opens a member in the AdminCP — app=core&module=members&controller=members&do=view&id=1 — the page they see is not a template. It is a two-column grid assembled at runtime from a list of small classes, one per panel: the header with the 30-day posting sparkline, the groups panel, the warnings and restrictions panel, the known devices panel, the quota bars, the Commerce customer panel. Each of those is a core/MemberACPProfileBlocks extension. The extension point exists so that an application can put its own panel on that screen, either on a tab the application owns or dropped into a tab owned by somebody else. Core's own language file describes it in one line: 'ext__MemberACPProfileBlocks' => "Add a block to the AdminCP member view." (applications/core/dev/lang.php line 1294).
The screen is also used by Commerce for its "Customer View" tab, so a block is not necessarily a small thing — IPS\nexus\extensions\core\MemberACPProfileBlocks\AccountInformation is a four-tab panel with stored cards, billing agreements and alternate contacts inside it.
The contract
Unusually for IPS 5, the base class is not under system/Extensions/. There is no MemberACPProfileBlocksAbstract. The base class lives in the core application, at applications/core/sources/MemberACPProfile/Block.php, and is IPS\core\MemberACPProfile\Block. That path matters, because the two AdminCP routes that accept a class name from the URL both validate it with is_subclass_of( $class, "\IPS\core\MemberACPProfile\Block" ).
abstract class Block
{
/* Set by the constructor. Your code reads it; you never assign it. */
protected ?Member $member = null;
/* Called by MainTab::output() as `new $class( $this->member )`.
Override it if you need to gather data up front, but call parent. */
public function __construct( Member $member )
/* Builds the language key memberACPProfileTitle_{app}_{Key}.
DEAD for blocks in 5.0.19 - nothing calls it. See below. */
public static function title() : string
/* REQUIRED. The only abstract member. Must return a string of HTML.
Return '' to render nothing at all. */
abstract public function output() : string;
/* Optional. Reached only via do=editBlock. The default implementation
does not return - it calls Output::i()->error( 'node_error',
'2C114/T', 404, '' ). */
public function edit() : ?string
}
There are two subclasses of Block that you may extend instead, and they change the contract.
IPS\core\MemberACPProfile\LazyLoadingBlock (applications/core/sources/MemberACPProfile/LazyLoadingBlock.php) implements output() for you — it emits a spinner wired to the do=lazyBlock AJAX route — and makes a different method abstract:
abstract class LazyLoadingBlock extends Block
{
/* Final in practice. Returns the lazyLoad.phtml spinner. */
public function output() : string
/* REQUIRED instead of output(). Runs on the second, AJAX request. */
abstract public function lazyOutput() : string;
}
IPS\core\MemberACPProfile\TabbedBlock (applications/core/sources/MemberACPProfile/TabbedBlock.php) implements output() as a tab strip and requires two methods:
abstract class TabbedBlock extends Block
{
public function output() : string /* implemented for you */
/* REQUIRED. Keys are tab ids, values are either a language key
(string) or array( fontAwesomeIconName, languageKey ). Return an
empty array and output() returns '' - the block disappears. */
abstract public function tabs() : array; /* see note below on the array form */
/* REQUIRED. $tab is one key from tabs(). */
abstract public function tabOutput( string $tab ): mixed;
/* Optional. A language key for the panel heading, or NULL for no
heading at all. Default NULL. */
public function blockTitle() : ?string
/* Optional. Default FALSE. When TRUE, the heading gets an "Edit"
link to do=editBlock for this class. */
protected function showEditLink() : bool
/* Optional. Default is do=editBlock with block= and id= set. */
protected function editLink() : Url
}
The array form accepted by tabs() is numerically indexed: tabbedBlock.phtml lines 16–21 render <i class="fa-solid fa-{$name[0]}"> and {lang="{$name[1]}"}. Do not copy the shape used by Commerce's AccountInformation::tabs() or Purchases::tabs() — those return array( 'icon' => ..., 'count' => ... ), but both classes also override output() in full and render their own nexus templates, so they never touch tabbedBlock.phtml. Every core class that does use the inherited output() (ContentStatistics, ProfileData) returns plain language-key strings, so the numeric array form is documented by the template rather than exercised by the suite.
Finally, there are two static properties that core reads but that no class in the suite declares. They are only ever touched behind isset(), in MainTab::output() at applications/core/sources/MemberACPProfile/MainTab.php lines 126–141. They are documented in the stub the Developer Center generates, applications/core/data/defaults/extensions/MemberACPProfileBlocks.txt:
/** * Optionally show this profile block on a tab outside of your application. * Example: to show on the main profile tab, set this to 'core_Main'. */ public static string $displayTab = ''; /** * Used in conjunction with static::$displayTab. * If showing on a profile tab outside of your application, * set this to 'left' or 'main' to place it in the proper column. */ public static string $displayColumn = '';
For a third-party application that does not want a whole tab of its own, those two properties are the extension point. Everything else is optional.
Who calls it
There are exactly four call sites in 5.0.19. Nothing else in the suite reads this extension type.
applications/core/sources/MemberACPProfile/MainTab.php,output(), lines 96–145. This is the main consumer. It instantiates every class name returned by the tab's ownleftColumnBlocks()andmainColumnBlocks(), then callsApplication::allExtensions( 'core', 'MemberACPProfileBlocks', TRUE, 'core', 'Main', FALSE )and appends any block whose$displayTabmatches this tab. The resulting objects go totabTemplate.phtml, which calls$block->output()on each.applications/core/modules/admin/members/members.phpline 1374, insideview(). On an AJAX request carryingblockKey, it resolves the class withApplication::getExtensionClass()and calls$block->tabOutput( Request::i()->block[ Request::i()->blockKey ] ). This is how aTabbedBlockswitches tabs.members.phplines 1432–1459,editBlock(). Takes the class name straight fromRequest::i()->block, checks themember_editAdminCP restriction (plusmember_edit_adminif the target is an administrator), checksis_subclass_of( $class, "\IPS\core\MemberACPProfile\Block" ), then calls$object->edit().members.phplines 1466–1485,lazyBlock(). Same class-name-from-URL pattern, sameis_subclass_ofcheck, then$object->lazyOutput(). Note the check is againstBlock, notLazyLoadingBlock.
Core's own blocks are placed by name, not by $displayTab: applications/core/extensions/core/MemberACPProfileTabs/Main.php names seven main-column and six left-column core blocks (three of the six left-column entries are gated on an AdminCP restriction, and the two not named there — Locations and LoginMethods — are the lazy-loaded pair described below), and applications/nexus/extensions/core/MemberACPProfileTabs/Main.php does the same for Commerce's six. No class anywhere in the shipped suite declares $displayTab or $displayColumn; that handling was added for everybody else.
A minimal example
The smallest complete first-party block is applications/core/extensions/core/MemberACPProfileBlocks/DevicesAndIPAddresses.php, which is a constructor-free class with one method:
class DevicesAndIPAddresses extends Block
{
/**
* Get output
*
* @return string
*/
public function output(): string
{
$lastUsedIp = $this->member->lastUsedIp();
$devices = new ActiveRecordIterator( Db::i()->select( '*', 'core_members_known_devices', array( 'member_id=?', $this->member->member_id ), 'last_seen DESC', 5 ), 'IPS\Member\Device' );
return (string) Theme::i()->getTemplate('memberprofile')->devicesAndIPAddresses( $this->member, $lastUsedIp, $devices );
}
}
A third-party version that adds itself to the core member tab needs the same shape plus the two statics. applications/myapp/extensions/core/MemberACPProfileBlocks/Tickets.php:
<?php
namespace IPS\myapp\extensions\core\MemberACPProfileBlocks;
use IPS\core\MemberACPProfile\Block;
use IPS\Db;
use IPS\Member;
use IPS\Theme;
use function defined;
/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Tickets extends Block
{
/**
* @brief Show on core's member view tab
*/
public static string $displayTab = 'core_Main';
/**
* @brief 'left' or 'main' - anything else is silently dropped
*/
public static string $displayColumn = 'main';
/**
* Get output
*
* @return string
*/
public function output(): string
{
/* There is no canView(). Hide the block by returning an empty string. */
if ( !Member::loggedIn()->hasAcpRestriction( 'myapp', 'tickets', 'tickets_view' ) )
{
return '';
}
$count = Db::i()->select( 'COUNT(*)', 'myapp_tickets', array( 'ticket_member=?', $this->member->member_id ) )->first();
return (string) Theme::i()->getTemplate( 'memberprofile', 'myapp', 'admin' )->tickets( $this->member, $count );
}
}
And the registration in applications/myapp/data/extensions.json:
{
"core": {
"MemberACPProfileBlocks": {
"Tickets": "IPS\\myapp\\extensions\\core\\MemberACPProfileBlocks\\Tickets"
}
}
}
No language string is required. See below for why.
The block is registered, the file is correct, and nothing appears
This is the common case, and it is completely silent — no exception, no system log entry, no ACP notice.
Registering a MemberACPProfileBlocks extension does not place it anywhere. A block is rendered only if one of three things is true: a core/MemberACPProfileTabs extension names it in leftColumnBlocks() or mainColumnBlocks(); it declares $displayTab matching a tab that is being rendered; or some template hand-writes a do=lazyBlock URL naming the class. Core uses that third route for two of its own fifteen registered blocks — Locations is launched from devicesAndIPAddresses.phtml line 28 and LoginMethods from basicInformation.phtml line 109. Neither is listed by MemberACPProfileTabs\Main and neither declares $displayTab; they exist only as targets of that route. If none of the three is true, MainTab::output() never constructs the block and the class simply sits in extensions.json doing nothing.
Three variants of this produce the same silence:
$displayTabis set but$displayColumnis not. Look atMainTab.phplines 128–141. The object is constructed — so your constructor's database queries do run — and then, becauseisset( $ext::$displayColumn )is false or theswitchmatches neither'left'nor'main', the object is dropped on the floor. The stub inMemberACPProfileBlocks.txtinitialises both to'', and''matches neither case, so a stub left half-filled is a working class that never renders.- The properties are declared without
static. Core reads them off a class name string,$ext::$displayTab, becauseMainTabasksallExtensions()for names rather than objects (the last argument isFALSE). In PHP 8,isset( $className::$instanceProperty )returnsfalseand raises nothing at all. Drop thestatickeyword and your block vanishes without a diagnostic. - The tab key is wrong.
$displayTabis compared against$exploded[1] . '_' . $exploded[5]of the tab class name — the application directory and the extension key fromextensions.json, not the tab's visible title. ForIPS\core\extensions\core\MemberACPProfileTabs\Mainthat iscore_Main; for Commerce's Customer View tab it isnexus_Main. "Member View" and "Customer View" are the language strings, not the keys.
Fatal error: Class ... contains 1 abstract method and must therefore be declared abstract
output() is the one abstract member of Block. Omit it and the class will not compile:
Fatal error: Class IPS\myapp\extensions\core\MemberACPProfileBlocks\Tickets contains 1 abstract method and must therefore be declared abstract or implement the remaining method (IPS\core\MemberACPProfile\Block::output)
This is a compile-time error, raised by the autoloader the moment anything touches the class name. The first thing that does is class_exists() inside Application::extensions() (system/Application/Application.php line 926). Its blast radius is narrower than it looks: Application::extensions() is called per extension type, and allExtensions() caches each type separately in the datastore, so the MemberACPProfileBlocks list — and therefore your class file — is only ever loaded by MainTab::output(). In practice that means the AdminCP member view dies, not every page of the site. The same applies to lazyOutput() if you extend LazyLoadingBlock, and to tabs() and tabOutput() if you extend TabbedBlock.
The whole AdminCP member profile 500s and the trace points at core
Nothing wraps your block. MainTab::output() constructs blocks in a bare foreach:
foreach ( $this->mainColumnBlocks() as $class )
{
$seenBlocks[] = $class;
$mainColumnBlocks[] = new $class( $this->member );
}
and tabTemplate.phtml renders them in a bare loop of {$block->output()|raw}. There is no try/catch at either point, and members.php::view() calls $tab->output() unprotected at line 1422. An uncaught exception anywhere in your constructor or your output() takes down the entire member view for every member on the site, including the core tab, and the resulting error page names core's template or controller rather than your extension.
Note that core does defend itself against other extension types in this file — Warnings::output() wraps each MemberRestrictions extension in try { } catch ( Exception $e ) { }, and ContentStatistics::tabs() does the same for MemberACPProfileContentTab. It does not do this for blocks. Catch your own exceptions and return ''.
A related trap: output() is typed : string. Returning a template object, an array or NULL is a TypeError, with the same blast radius. Core casts defensively — return (string) Theme::i()->getTemplate(...)->whatever( ... ); — and you should too. Widening the signature with an optional parameter is permitted; applications/core/extensions/core/MemberACPProfileBlocks/Referrals.php declares public function output( bool $edit = FALSE ) : string.
There is no canView(), and your constructor runs before you can refuse
MainTab has a canView() method. Block does not. The only way for a block to decline to render is to return an empty string, and by then it has already been constructed. Core's blocks all do it this way: MFA::output() opens with a hasAcpRestriction( 'core', 'members', 'member_mfa' ) check and returns ''; OAuth::output() returns '' when the admin lacks oauth_tokens; Referrals::output() returns "" when the referrals feature is off; Commerce's ParentAccounts::output() returns '' when there is nothing to show.
The practical consequence is that expensive work belongs in output() or in a LazyLoadingBlock, not in the constructor. If you put queries in the constructor they run even when the block is about to be discarded — including the $displayColumn mismatch described above.
The lazy-loading route can call your block with an empty member
lazyBlock() in members.php line 1466 has a missing return:
$member = Member::load( Request::i()->id );
if ( !$member->member_id )
{
Output::i()->output = '';
}
$class = Request::i()->block;
if( !is_subclass_of( $class, "\IPS\core\MemberACPProfile\Block" ) )
{
Output::i()->error( 'node_error', '2C114/15', 404, '' );
}
$object = new $class( $member );
Output::i()->output = $object->lazyOutput();
Setting Output::i()->output does not stop execution. When the id in the URL does not resolve — a member deleted between page render and the AJAX call, or a hand-edited URL — your constructor and lazyOutput() still run, with $this->member->member_id equal to NULL. Queries filtered on member_id=? will match nothing, which is harmless; anything that dereferences a loaded record will not be. Guard on $this->member->member_id at the top of lazyOutput().
The second half of the same route is worth knowing about: the guard is is_subclass_of( $class, "\IPS\core\MemberACPProfile\Block" ), not LazyLoadingBlock. A URL naming any block at all passes the check, and a plain Block then dies with "Call to undefined method ...::lazyOutput()". Your block cannot cause this by existing, but it means you should not point a lazyLoad URL at a class that is not a LazyLoadingBlock.
Clicking a tab in a TabbedBlock blanks the panel
This one is caused by an underscore in a name. The tab strip built by TabbedBlock::output() uses $exploded[1] . '_' . $exploded[5] of your class name as the request parameter — application directory, underscore, extension key. When the tab link is followed, members.php::view() takes it apart again at line 1371:
$exploded = explode( '_', Request::i()->blockKey );
try
{
$class = Application::getExtensionClass( $exploded[0], 'MemberACPProfileBlocks', $exploded[1] );
$block = new $class( $member );
Output::i()->output = $block->tabOutput( Request::i()->block[ Request::i()->blockKey ] );
}
catch( OutOfRangeException )
{
Output::i()->output = '';
}
explode( '_', ... ) with no limit. An extension key of My_Block, or an application directory containing an underscore, gives $exploded[1] a truncated value, getExtensionClass() throws OutOfRangeException, and the catch writes an empty string. The first tab renders on a full page load — that path does not go through the split — and every subsequent tab click returns nothing. Keep the extension key a single word. ContentStatistics::tabOutput() splits the same way for its own sub-extensions, so the rule applies to MemberACPProfileContentTab keys too.
The Edit link 404s with code 2C114/T
Block::edit()'s default body is a single line: Output::i()->error( 'node_error', '2C114/T', 404, '' ). If you set showEditLink() to TRUE on a TabbedBlock, or emit a do=editBlock link from your own template, without implementing edit(), the administrator gets a 404 dialog carrying that core error code — which looks like a core bug rather than a missing method in your app.
Two further points about edit(). First, the route is gated by core, not by you: editBlock() calls Dispatcher::i()->checkAcpPermission( 'member_edit' ) and, for administrators, 'member_edit_admin', before your method is reached. Second, the route deliberately performs no CSRF check — the docblock reads @csrfChecked Doesn't actually save changes, shows dialog 7 Oct 2019. That assumption holds for blocks that render a Form, because Form::values() carries its own CSRF key. It does not hold if your edit() mutates data straight from the query string. Core's one block that does this calls the check itself, on the first line of Quotas::edit():
public function edit(): string
{
Session::i()->csrfCheck();
...
If your edit() writes anything without going through a Form, you must do the same.
The language key memberACPProfileTitle_myapp_MyBlock is never read
Block::title() exists and builds that key, but in 5.0.19 nothing calls it for a block. The only caller of ::title() on this screen is mainTemplate.phtml line 9, and the class names it iterates are MemberACPProfileTabs extensions, resolved by MainTab::title(). The Developer Center's missing-language-string audit agrees: the $extensionStrings map in applications/core/modules/admin/developer/details.php, lines 564–578, lists 'MemberACPProfileTabs' => 'memberACPProfileTitle_{app}_{key}' at line 576 and has no entry for MemberACPProfileBlocks.
A block's heading is whatever its own template markup says. The exception is TabbedBlock: blockTitle() returns a language key which tabbedBlock.phtml renders with {lang="$title"}, and returning NULL (the default) suppresses the heading row entirely. ContentStatistics::blockTitle() returns 'content_statistics'; ProfileData::blockTitle() returns 'profile_data'.
A new block does not appear until the cache is cleared
Two layers of caching sit in front of this extension type. Application::extensions() reads only applications/{app}/data/extensions.json — a file dropped into extensions/core/MemberACPProfileBlocks/ with no JSON entry does not exist as far as core is concerned — and keeps a per-request static in static::$_loadedExtensions. Above that, Application::allExtensions() caches the whole resolved class-name list in the datastore under Store::i()->extensions, keyed by extension type, and rebuilds only when that key is absent (system/Application/Application.php lines 355–442).
Application declares protected array $caches = array( 'updatecount_applications', 'applications', 'extensions' ) at line 117, so installing, upgrading, enabling or disabling an application clears it, as does the Developer Center's create/remove extension action, which calls unset( Store::i()->extensions ) explicitly (applications/core/modules/admin/developer/extensions.php lines 313 and 342). Hand-editing extensions.json on a live site clears nothing.
One consequence of that cache is worth flagging but is easy to state wrongly, so here is only what the source shows. MainTab::output() passes TRUE as the $checkAccess argument, which makes allExtensions() skip applications for which $application->canAccess( NULL ) is false, and the filtered result is then written to the datastore. Application::canAccess() returns TRUE early for an AdminCP request when the logged-in administrator holds the core / applications / app_manage restriction, and otherwise falls through to the application's disabled_groups. Whether the cached list can therefore end up reflecting one administrator's access for everybody is an interaction I have not tested on a running site; treat it as a reason to clear the system cache after changing an application's group access rather than as a documented behaviour.
Verified against
Read from Invision Community 5.0.19 source (applications/core/data/versions.json, final entry 5001908 => "5.0.19" at line 446). Files inspected: applications/core/sources/MemberACPProfile/Block.php, LazyLoadingBlock.php, TabbedBlock.php, MainTab.php and Restriction.php; all fifteen implementations under applications/core/extensions/core/MemberACPProfileBlocks/; all six under applications/nexus/extensions/core/MemberACPProfileBlocks/; both MemberACPProfileTabs/Main.php extensions; the consumer applications/core/modules/admin/members/members.php (view(), editBlock(), lazyBlock()); Application::extensions(), Application::allExtensions(), Application::getExtensionClass() and Application::canAccess() in system/Application/Application.php; applications/core/modules/admin/developer/details.php and developer/extensions.php; the stub applications/core/data/defaults/extensions/MemberACPProfileBlocks.txt; the templates mainTemplate.phtml, tabTemplate.phtml, tabbedBlock.phtml, lazyLoad.phtml, basicInformation.phtml and devicesAndIPAddresses.phtml under applications/core/dev/html/admin/memberprofile/; and the controller applications/core/dev/js/admin/controllers/members/ips.members.lazyLoadingProfileBlock.js. Only core and nexus ship this extension — forums, cms, downloads, gallery, blog and calendar ship none. The PHP behaviour of isset( $className::$instanceProperty ) was confirmed on PHP 8.5.5.
Recommended Comments