The core/AccountSettings extension adds a tab to the member-facing account settings screen — the page at /settings, served by IPS\core\modules\front\system\settings. Core calls it in exactly one place, that controller's manage() method, and it calls it twice per page load: once to find which extension owns the requested area and render its body, and once more (inside _wrapOutputInTemplate()) to build the sidebar list of every tab.
Core ships exactly one implementation of this extension, IPS\core\extensions\core\AccountSettings\Referrals. Everything else on that page — email, password, MFA, devices, username, signature, OAuth apps — is a hard-coded method on the controller, not an extension. That asymmetry is the source of about half the traps below.
The contract
namespace IPS\Extensions;
abstract class AccountSettingsAbstract
{
/* Font Awesome icon name, WITHOUT the "fa-" prefix.
The template renders it as: <i class="fa-solid fa-{$icon}">
Must stay static — core reads it as $ext::$icon, never $ext->icon. */
public static string $icon = 'cog';
/* REQUIRED. The tab key. Doubles as:
- the URL segment (/settings/<key>)
- the array key in the $tabs list
- the default language key for the tab label
Return NULL to hide the tab AND make its content unreachable. */
abstract public function getTab() : string|null;
/* REQUIRED. The HTML body of the tab. Returned, not echoed.
Returning '' is NOT the same as returning nothing useful — see below. */
abstract public function getContent() : string;
/* OPTIONAL. A LANGUAGE KEY, not a literal title.
Default implementation returns $this->getTab(). */
public function getTitle() : string;
/* OPTIONAL. TRUE puts a fa-triangle-exclamation badge on the sidebar item. */
public function showWarning() : bool;
}
No method on this abstract declares a thrown exception, and — this matters — core does not catch any.
The file lives at applications/<app>/extensions/core/AccountSettings/<Class>.php in namespace IPS\<app>\extensions\core\AccountSettings, and must be registered in applications/<app>/data/extensions.json:
{
"core": {
"AccountSettings": {
"MyTab": "IPS\\myapp\\extensions\\core\\AccountSettings\\MyTab"
}
}
}
This is the exact consumer code, from manage() in applications/core/modules/front/system/settings.php:
$area = Request::i()->area ?: 'overview';
$methodName = "_{$area}";
if ( method_exists( $this, $methodName ) )
{
$output = $this->$methodName();
}
else
{
foreach( Application::allExtensions( 'core', 'AccountSettings', TRUE, 'core' ) as $ext )
{
$tabName = $ext->getTab();
if( $tabName == $area )
{
if( isset( Request::i()->action ) AND method_exists( $ext, Request::i()->action ) )
{
$method = Request::i()->action;
$output = $ext->$method();
}
else
{
$output = $ext->getContent();
}
}
}
}
if( !isset( $output ) )
{
Output::i()->error( 'node_error', '2C122/2', 404 );
}
Note the things that are not there: no try, no break, and no CSRF check.
A minimal example
applications/myapp/extensions/core/AccountSettings/Privacy.php:
<?php
namespace IPS\myapp\extensions\core\AccountSettings;
use IPS\Extensions\AccountSettingsAbstract;
use IPS\Log;
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 Privacy extends AccountSettingsAbstract
{
/**
* @var string
*/
public static string $icon = 'shield-halved';
/**
* The tab key, or NULL to hide the tab entirely.
*
* This runs on EVERY request to the settings controller, including
* requests for somebody else's tab. Keep it cheap and make it return,
* never throw and never redirect.
*/
public function getTab() : string|null
{
if ( !Settings::i()->myapp_enabled or !Member::loggedIn()->member_id )
{
return NULL;
}
return 'myapp_privacy';
}
/**
* The tab label. A LANGUAGE KEY. Omit this method and the key
* returned by getTab() is used instead.
*/
public function getTitle() : string
{
return 'myapp_privacy_tab';
}
/**
* The tab body.
*/
public function getContent() : string
{
/* Re-check here. getTab() gates access, but a defence in depth
check costs nothing and error() is safe in this method. */
if ( !Settings::i()->myapp_enabled )
{
Output::i()->error( 'myapp_disabled', '2MYAPP/1', 403, '' );
}
try
{
return Theme::i()->getTemplate( 'settings', 'myapp', 'front' )->privacy( Member::loggedIn() );
}
catch ( \Throwable $e )
{
/* Contain it. An uncaught throw here takes down the whole
settings page, including the core password and email tabs. */
Log::log( $e, 'myapp' );
return Member::loggedIn()->language()->addToStack( 'myapp_privacy_unavailable' );
}
}
}
Then define a language string for the title in applications/myapp/dev/lang.php:
'myapp_privacy_tab' => "Privacy",
Members report they can no longer change their password or email address
This is the big one, and the cause is nowhere near the symptom. The loop that collects tabs has no try/catch anywhere in it, and it calls getTab() on every registered extension on every request to the settings controller — including the request for /settings/password, which has nothing to do with your app.
So if your getTab() throws — a bad Db query, a Settings value your install migration never wrote, an OutOfRangeException from a Member::load() — every tab on the account settings page dies with a 500 or a generic EX error, including all of core's. The member (and the admin) will report "I can't change my password". Nothing in that report points at your app.
The same is true of getTitle() and showWarning(), which run inside _wrapOutputInTemplate() for every extension on every full page load.
Fix: treat all four methods as if they run in a shared, unguarded loop, because they do. getTab() should contain nothing that can throw — read a setting, check member_id, return. Wrap anything risky in getContent() in a try/catch ( \Throwable $e ), log it, and return something. Do not return '' from the catch block; see the blank-page trap below.
Every settings tab returns my app's "you do not have permission" error
The mirror image of the previous trap, and it has caught people who moved a permission check into the wrong method. If you call Output::i()->error() or Output::i()->redirect() inside getTab(), it fires during the tab-collection loop for whatever area the member actually requested. A member clicking "Devices" gets your app's 403 instead.
Core's own extension shows the correct split. Referrals::getTab() only returns a value or NULL:
public function getTab() : string|null
{
return Settings::i()->ref_on ? 'referrals' : null;
}
and the Output::i()->error( 'referrals_disabled', '2C122/V', 403, '' ) call lives in getContent(), which only runs when the member is actually on that tab.
My tab renders a completely blank page with no sidebar
Returning an empty string from getContent() does not produce an empty tab — it produces an empty page. manage() tests the return value for truthiness, not for isset:
if ( $output )
{
Output::i()->output .= $this->_wrapOutputInTemplate( $area, $output );
}
An empty string is falsy, so _wrapOutputInTemplate() is never called, the sidebar and the entire settings chrome are never rendered, and the member gets the site header, the footer, and nothing between them. No error is raised, no 404, nothing in the logs.
Note also that '' does not trigger the node_error 404, because isset( $output ) is still TRUE. You get a silent blank page rather than a diagnosable failure.
Fix: never return ''. Return a message, even a bare language string, from every path including your catch blocks.
My tab shows the raw key "myapp_privacy" as its label
Both the sidebar's title attribute and its visible text run the value through the language system:
{{if isset( $tab['title'] )}}{lang="$tab['title']"}{{else}}{lang="$key"}{{endif}}
So getTitle() must return a language key, not a title. And because the abstract's default getTitle() is return $this->getTab();, an extension that does not override it needs a language key whose name is exactly the tab key. Core does exactly that — applications/core/dev/lang.php contains 'referrals' => "Referrals".
When the key is missing, IPS\Lang::replaceWords() falls through to its else branch and substitutes the key itself:
else
{
$values['options']['escape'] = TRUE;
$replacement = $values[ 'key' ];
}
There is no warning and no log entry. The tab just reads myapp_privacy.
My tab key is ignored and core's Email or Password page renders instead
manage() checks the controller for a method named _<area> before it looks at any extension, and method_exists() in PHP is case-insensitive. If your tab key collides with one of the controller's private area methods, your extension is never consulted at all — getTab() is not even called.
As of 5.0.19 the reserved area keys, taken from the _-prefixed methods on IPS\core\modules\front\system\settings, are:
overview,email,password,devices,mfa,securityquestions,username,links,signature,login,apps- plus two that are not tabs at all and will produce an
ArgumentCountErrorif requested:wrapOutputInTemplateandperformRedirect
This list can grow in any core release, which is the real argument for the fix: prefix your tab key with your app directory, e.g. myapp_privacy rather than privacy. That also protects you from the next trap.
Two apps installed, one tab disappears and clicking it shows the other app's content
The extension loop has no break, and the sidebar builder does a plain array assignment:
foreach( Application::allExtensions( 'core', 'AccountSettings', TRUE, 'core' ) as $ext )
{
if( $key = $ext->getTab() )
{
$tabs[ $key ] = [ /* ... */ ];
}
}
If two extensions return the same key, the later one wins in both places — it overwrites the sidebar entry (icon, title, warning) and, because manage() keeps looping after a match, its getContent() overwrites $output too. The earlier extension's getTab() and getContent() still run, they just have their result thrown away. Any side effects or queries they perform still happen.
Extension order is not alphabetical. allExtensions() is called with $firstApp = 'core', which usorts core to the front; everything after that follows application load order. Do not rely on it. Namespace your key.
My tab does not appear at all and nothing is logged
There are four independent silent-drop paths, none of which produce an error:
- The class cannot be loaded.
Application::extensions()skips any entry that failsis_string( $classname ) or class_exists( $classname )with the comment "Switching between branches confuses extensions". A namespace that does not match the directory, a typo inextensions.json, or a parse error in your file all land here and are simplycontinued. - Your constructor threw.
Application::constructExtensionClass()doescatch( RuntimeException | OutOfRangeException $e ){}— an empty catch — and then returnsnull, which drops the extension from the returned array. Note that the constructor is called asnew $class( Member::loggedIn() ), so if you define a constructor it receives a Member. - Your app is not accessible to this member. The call passes
$checkAccess = TRUE, so$application->canAccess( NULL )is evaluated against the logged-in member and the whole app's extensions are skipped if it fails. - The extension list is cached.
allExtensions()reads and writesStore::i()->extensions, keyed only by extension type. It is cleared by the ACP Developer Center when you add or delete an extension (applications/core/modules/admin/developer/extensions.php), but if you hand-editextensions.jsonthe stale cache persists until something clears it.
Also note that RECOVERY_MODE skips every non-IPS application, so no third-party tab appears at all in recovery mode.
My extension vanishes as soon as I add a method called generate()
This one is genuinely surprising. Application::extensions() checks method_exists( $classname, 'generate' ) and, if present, treats your class as a generator of other extensions rather than as an extension:
if ( method_exists( $classname, 'generate' ) )
{
$generated = $classname::generate();
foreach( $generated as $k => $v )
{
$classes[ $k ] = [ 'generate' => $v::class, 'class' => $v->class ];
}
}
else
{
$classes[ $name ] = $classname;
}
generate is a reserved method name across every IPS extension type, not just this one. If you add a helper called generate() to an AccountSettings class, the class is called statically, the return value is iterated as a list of objects with a ->class property, and your tab simply never appears. Pick another name.
My tab's CSS only works when I load the URL directly, not when I click the tab
The settings sidebar is an ipsTabBar, and dev/js/framework/common/ui/ips.ui.tabbar.js loads each tab over AJAX from its href, replacing only the content area. On that request Request::i()->isAjax() is TRUE, so manage() takes the other branch:
elseif ( $output )
{
Output::i()->output .= $output;
}
_wrapOutputInTemplate() is skipped and no global template — and therefore no global/includeCSS — is rendered. Anything you appended to Output::i()->cssFiles or Output::i()->jsFiles inside getContent() is silently discarded. IPS\Dispatcher\Front::baseCss() confirms this is by design: it does not even load core.css on AJAX requests.
The symptom appears one navigation away from the cause. Landing on /settings/myapp_privacy directly works and looks correct. Landing on /settings and then clicking your tab in the sidebar renders the same HTML unstyled. Testing only by pasting the URL will never reproduce it.
Fix: put your styles in a CSS file loaded on every page of the app, or scope the tab's presentation to inline attributes and existing framework classes rather than a tab-specific stylesheet. Core's Referrals::getContent() does append styles/referrals.css and has the same limitation.
Any public method on my extension class is callable straight from a URL
The action branch is a dynamic method call driven by unvalidated request input, with no allow-list and no CSRF token check:
if( isset( Request::i()->action ) AND method_exists( $ext, Request::i()->action ) )
{
$method = Request::i()->action;
$output = $ext->$method();
}
Three consequences worth planning for:
- Anything public is web-reachable. A request for
/settings/myapp_privacy?action=deleteAllDatacalls that method as a GET, with no arguments and no CSRF validation. If you add action methods, validate the request inside them —Request::i()->confirmedDelete(), a->csrf()check, or a form — and never assume the extension dispatcher did it for you. method_exists()is TRUE for protected and private methods too. Calling one throws anError, which nothing catches, so it becomes a 500 on the settings page.- Non-string returns are not validated.
$outputhere is untyped. Requesting?action=showWarningassigns a bool:isset( $output )is TRUE so there is no 404, and if it is FALSE the truthiness test skips rendering entirely and the member gets the blank page described above.
The good news is that the action dispatch sits inside the $tabName == $area test, so an extension whose getTab() currently returns NULL is unreachable by this route as well. Gating in getTab() is a real access control, not just a display toggle.
404 "node_error" with code 2C122/2 on a URL that used to work
If no controller method and no extension claims the area, core raises Output::i()->error( 'node_error', '2C122/2', 404 ). On a custom tab that means getTab() returned NULL — or returned a different key — for this particular request.
The subtlety is ordering: getContent() runs before the sidebar is built, so getTab() is evaluated once before your content executes and again afterwards. If getContent() mutates state that getTab() reads — a member setting the tab itself toggles, for instance — the tab can render its content and then vanish from the sidebar in the same response, and the next request 404s. Keep getTab()'s inputs stable across a single request.
Note also that the sidebar test is if( $key = $ext->getTab() ), a truthiness test, while the content test is if( $tabName == $area ), a loose comparison. A tab key of '0' is falsy: it would be reachable by URL but never listed. Another reason to prefix keys with your app directory.
A note on the icon
$icon is declared public static string on the abstract and read as $ext::$icon. Redeclaring it as a non-static property in your subclass is a PHP compile-time fatal, not a silent fallback. The template interpolates it as fa-solid fa-{$icon}, so supply the bare icon name ('key', 'users', 'shield-halved') with no fa- prefix. Core exploits the raw interpolation elsewhere on the same page — the login-method loop sets 'brands fa-' . $icon to reach the brands style — so the same trick is available if you need a brand glyph. If $icon is unset you inherit 'cog'; the template's final fallback, fa-square, is only reached when the key is absent from the array entirely, which cannot happen for extension tabs.
Verified against
Read from an Invision Community 5.0.19 source tree (applications/core/data/versions.json, build 5001908). The files behind every claim above: system/Extensions/AccountSettingsAbstract.php, applications/core/extensions/core/AccountSettings/Referrals.php, applications/core/modules/front/system/settings.php (manage() and _wrapOutputInTemplate()), applications/core/dev/html/front/system/settings.phtml, system/Application/Application.php (allExtensions(), constructExtensionClass(), extensions()), system/Lang/Lang.php (replaceWords()), system/Dispatcher/Front.php (baseCss()) and dev/js/framework/common/ui/ips.ui.tabbar.js. Invision Cloud ships an additional implementation (IPS\cloud\extensions\core\AccountSettings\Expert) which is not present in a self-hosted tree and was not read.
Recommended Comments