A core/FrontNavigation extension defines one type of item that an administrator can place in the front-end navigation bar via AdminCP → System → Menu Manager. The extension class is not the menu item itself: each row in the core_menu table names an app and an extension key, and core instantiates your class once per row, passing that row's stored configuration.
Core builds the menu in IPS\core\FrontNavigation::roots() and ::subBars() (applications/core/sources/FrontNavigation/FrontNavigation.php), and renders it from the global front templates navBar, navBarItems, navBarChildren, navColumn*, mobileFooterBar and mobileNavigationChildren. That means your methods run on every front-end page request. The only place Application::allExtensions( 'core', 'FrontNavigation', ... ) is called is applications/core/modules/admin/applications/menu.php line 328 — the AdminCP Menu Manager — where it is used purely to build the "Type" dropdown on the add/edit form.
The contract
Your class extends IPS\core\FrontNavigation\FrontNavigationAbstract (applications/core/sources/FrontNavigation/FrontNavigationAbstract.php). There is no system/Extensions/FrontNavigationAbstract.php — unlike most IC5 extension points, this one lives inside the core application.
/* Required — declared abstract */
abstract public function title() : string; // shown as the tab label
abstract public function link() : Url|string|null; // NULL => rendered as href="#"
abstract public static function typeTitle() : string; // label in the ACP "Type" dropdown
abstract public function active() : bool; // is the current page this item?
/* Optional — the abstract supplies these defaults */
public string $defaultIcon; // FA codepoint escape, e.g. '\f019'
public static function allowMultiple() : bool // default FALSE
public static function permissionsCanInherit() : bool // default TRUE
public static function isEnabled() : bool // default TRUE
public function canAccessContent() : bool // default TRUE
public function canView() : bool // default: isEnabled() + permissions
public function children( bool $noStore=FALSE ) : ?array // default NULL (no dropdown)
public function attributes() : string // default '' — echoed RAW into <li>
public function isAvailableFor( string $type ) : bool // header|sidebar|smallscreen
public static function configuration( array $existingConfiguration, ?int $id=NULL ) : array
public static function parseConfiguration( array $configuration, int $id ) : array
/* Duck-typed, NOT declared on the abstract */
public function target() : string // templates use method_exists()
/* Constructor signature core calls with */
public function __construct(
array $configuration, // json_decode( core_menu.config, TRUE )
int $id, // core_menu.id
string|null $permissions, // '*', 'group,ids', or NULL to inherit
string $menuTypes, // '*' or a JSON array of header/sidebar/smallscreen
array|null $icon, // json_decode( core_menu.icon, TRUE )
int|null $parent = 0 // core_menu.parent
)
The core_menu table (applications/core/data/schema.json) is the storage: id, app, extension, config (TEXT, JSON), position, parent (nullable), permissions (nullable TEXT), is_menu_child, menu_types (NOT NULL, default *), icon (MEDIUMTEXT, nullable).
A minimal example
Three files. First, the extension class at applications/edmyapp/extensions/core/FrontNavigation/Directory.php:
<?php
namespace IPS\edmyapp\extensions\core\FrontNavigation;
use IPS\Application\Module;
use IPS\core\FrontNavigation\FrontNavigationAbstract;
use IPS\Dispatcher;
use IPS\Http\Url;
use IPS\Member;
use OutOfRangeException;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Directory extends FrontNavigationAbstract
{
/* A Font Awesome *codepoint*, not a class name. See the icon trap below. */
public string $defaultIcon = '\f02d';
public static function typeTitle(): string
{
return Member::loggedIn()->language()->addToStack( 'frontnavigation_edmyapp_directory' );
}
public function canAccessContent(): bool
{
try
{
/* Pass 'front' explicitly — Module::get() otherwise resolves the area
from Dispatcher::i()->controllerLocation, which is 'admin' in the ACP. */
return Member::loggedIn()->canAccessModule( Module::get( 'edmyapp', 'directory', 'front' ) );
}
catch ( OutOfRangeException )
{
return FALSE;
}
}
public function title(): string
{
return Member::loggedIn()->language()->addToStack( 'frontnavigation_edmyapp_directory' );
}
public function link(): Url|string|null
{
return Url::internal( 'app=edmyapp&module=directory&controller=browse', 'front', 'edmyapp_directory' );
}
public function active(): bool
{
return Dispatcher::i()->application?->directory === 'edmyapp'
and Dispatcher::i()->module?->key === 'directory';
}
}
Second, register it in applications/edmyapp/data/extensions.json. The outer key is core (the app that owns the extension point), not your app:
{
"core": {
"FrontNavigation": {
"Directory": "IPS\\edmyapp\\extensions\\core\\FrontNavigation\\Directory"
}
}
}
Third, to have the item placed automatically on install, override defaultFrontNavigation() in applications/edmyapp/Application.php:
public function defaultFrontNavigation(): array
{
return array(
'rootTabs' => array(),
'browseTabs' => array( array( 'key' => 'Directory' ) ),
'browseTabsEnd' => array(),
'activityTabs' => array()
);
}
And add 'frontnavigation_edmyapp_directory' => "Directory" to applications/edmyapp/dev/lang.php.
My new navigation type does not appear in the Menu Manager's "Type" dropdown
Two separate causes, and they look identical.
First, the class file on disk is not enough. Application::getExtensionClass() (system/Application/Application.php line 548) does nothing but read applications/{app}/data/extensions.json and look up $content['core']['FrontNavigation'][$key]; if the key is absent it throws OutOfRangeException. Autoloading is irrelevant — the JSON file is the registry.
Second, Application::allExtensions() caches the entire map in the datastore under a single key: Store::i()->extensions, and the rebuild condition is if( !array_key_exists( $extension, $allExtensions ) ). Once the FrontNavigation key exists in that array it is never refreshed, so adding an entry to extensions.json on a site that has already rendered a menu changes nothing. Clear it — AdminCP → Support → "Clear caches", or in code unset( \IPS\Data\Store::i()->extensions );.
Note the asymmetry: getExtensionClass() reads the JSON file every call with no cache, while the ACP dropdown reads the datastore. So it is entirely possible for an existing menu item of your type to render correctly on the front end while your type is still missing from the ACP dropdown.
I added the item in the Menu Manager but the front end still shows the old menu
This is by design and catches everyone once. FrontNavigation::frontNavigation() caches the whole core_menu table in Store::i()->frontNavigation and only rebuilds when that key is unset. The Menu Manager's add, edit, delete and reorder actions all write to core_menu without clearing it. Only the publish() action does unset( Store::i()->frontNavigation ). That is what the yellow banner at the top of the Menu Manager is for — menu_manager_publish_desc, "Publish your menu once you've finished making changes".
Note that "Restore default menu" has the same behaviour: restore() calls buildDefaultFrontNavigation(), which deletes and rebuilds every row but never unsets the store, then calls frontNavigation(), which happily returns the stale cached copy.
If your own code inserts or changes a core_menu row, you must clear it yourself:
\IPS\Db::i()->insert( 'core_menu', $row ); unset( \IPS\Data\Store::i()->frontNavigation );
Every page on the site is a 500 error, including pages that have nothing to do with my extension
This is the single most important thing to know about this extension point. There is no try/catch anywhere in the navigation render path.
roots(), subBars(), FrontNavigationAbstract::subItems() and Menu::children() each wrap only the new $class( ... ) call, and each catches only OutOfRangeException. Once the object exists, the templates call canView(), isAvailableFor(), activeOrChildActive(), children(), getIconData(), getDefaultIcon(), title(), link() and attributes() completely unguarded — see applications/core/dev/html/front/global/navBarItems.phtml.
The navigation bar is part of the global wrapper, so an exception thrown from any of those methods takes down every page of the community, not the page your item links to. The visitor sees the generic error page; the real message goes only to AdminCP → System Logs under the uncaught_exception type (IPS\IPS::exceptionHandler, init.php line 817). Because the log entry names your class, developers usually chase the wrong page for an hour first.
The two calls that most often throw are:
Module::get( $app, $key )throwsOutOfRangeExceptionwhen the module does not exist (system/Application/Module.phpline 184). Worse, its third parameter defaults via$area = $area ?: Dispatcher::i()->controllerLocation, soModule::get( 'edmyapp', 'directory' )looks for an admin module of that key whenever it is evaluated inside the AdminCP.Url::internal( $qs, 'front', $seoTemplate )throwsIPS\Http\Url\Exceptionwith the messageINVALID_SEO_TEMPLATEwhen the FURL key is not in adata/furl.json. Notesystem/Http/Url/Friendly.phpline 225: outsideIN_DEVthis is swallowed and degraded to a plainindex.php?...URL, but inIN_DEVit rethrows. A typo'd FURL key therefore produces a working production site and a completely dead development site.
Wrap anything that can fail:
public function canAccessContent(): bool
{
try
{
return Member::loggedIn()->canAccessModule( Module::get( 'edmyapp', 'directory', 'front' ) );
}
catch ( OutOfRangeException )
{
return FALSE;
}
}
Fatal "Call to a member function url() on null" on every page after an admin deletes some content
A related but distinct hazard: active() is not always guarded by canView().
FrontNavigationAbstract::activeOrChildActive() does check — if ( $this->isAvailableFor( $menuType ) and $this->active() ), and isAvailableFor() starts with if( !$this->canView() ) return false;. But IPS\core\extensions\core\FrontNavigation\Menu::active() does not:
public function active(): bool
{
foreach ( $this->children() as $child )
{
if ( $child->active() ) // no canView() / canAccessContent() check
{
return TRUE;
}
}
return FALSE;
}
So any item placed inside a dropdown has active() called on it regardless of whether it can be viewed. Core's own Tags extension demonstrates the consequence. Its title() and link() use the null-safe operator ($this->tag?->text, $this->tag?->url()) because the tag may have been deleted, but active() does not:
public function active(): bool
{
return stristr( (string) Request::i()->url(), (string) $this->tag->url() );
}
Put a Tags item inside a dropdown menu, delete the tag, and every page fatals. Menu::permissionsCanInherit() returns FALSE, so dropdowns are saved with an explicit * permission and Menu::canView() returns TRUE, which is what makes the path reachable. Write active() so it is safe when the thing it points at no longer exists, exactly as you would title() and link().
My item vanished from the front end, and its row in the Menu Manager is blank
When Application::getExtensionClass() throws OutOfRangeException, roots(), subBars() and subItems() all catch it and simply skip the item. There is no log entry and no admin warning. It throws when the app is disabled, when RECOVERY_MODE is on and the app is not one of IPS::$ipsApps (so every third-party navigation item disappears in recovery mode), or when the extension key is missing from extensions.json.
In the AdminCP the same failure surfaces differently: menu.php::_getClassFromRow() returns null and _getMenuRow() then returns an empty string, so the tree renders an empty row with no title and no edit or delete buttons.
There is a live example of this in the shipped source. applications/nexus/Application.php's defaultFrontNavigation() includes array( 'key' => 'Referrals' ), but there is no Referrals entry in applications/nexus/data/extensions.json and no Referrals.php under applications/nexus/extensions/core/FrontNavigation/. The row is inserted into core_menu on install and is then permanently invisible and unremovable through the UI.
The tab is labelled "menu_item_57" instead of its name
The 'title' element of a defaultFrontNavigation() entry must be a language key, not a translated string. FrontNavigation::insertMenuItem() does:
Lang::copyCustom( $config['real_app'], $config['title'], "menu_item_{$insertedId}" );
and Lang::copyCustom() (system/Lang/Lang.php line 437) selects from core_sys_lang_words with word_app=? AND word_key=?. If no row matches, it writes nothing at all — no error, no fallback — and the item ends up with no menu_item_X string, so any title() that returns addToStack( "menu_item_{$this->id}" ) renders the raw key.
Core gets this wrong itself. applications/nexus/Application.php passes 'title' => Member::loggedIn()->language()->get( 'module__nexus_store' ) for its root Store dropdown — the resolved English text, so copyCustom looks for a word key literally named Store and finds nothing. The nested entry in the same array gets it right: 'title' => 'default_menu_item_my_details'.
Also note the key must already exist in core_sys_lang_words at the moment the menu item is inserted, i.e. your dev/lang.php strings must have been installed first.
Adding a completely different menu item type throws an error from my extension
The Menu Manager builds one form containing the configuration fields of every registered FrontNavigation extension, hidden and shown by JavaScript toggles keyed on the "Type" dropdown. menu.php line 350:
foreach ( $class::configuration(
$existing ? json_decode( $existing['config'], TRUE ) : array(),
$existing ? $existing['id'] : NULL
) as $field )
This runs inside a loop over all extensions, with no try/catch. Three consequences:
- If your
configuration()throws, the entire add/edit dialog dies — for every item type, not just yours. An admin trying to add a Custom Item sees an error caused by an unrelated third-party app. - It runs on every render of that dialog, so keep it cheap. Core's own are not:
cms\Pages::configuration()selects every row ofcms_pages, andcore\Node::configuration()walks every routed content class. $existingConfigurationis the configuration of whatever item is being edited, which is almost never one of yours. Edit a Tags item and yourconfiguration()is handedarray( 'id' => 7 ). Likewise$idis that other item's ID, so aTranslatablefield built with'key' => "menu_item_{$id}"points at someone else's language string.
Every core implementation therefore uses null-coalescing access and never assumes a key is present:
new Select( 'edmyapp_menu_mode', $existingConfiguration['edmyapp_menu_mode'] ?? NULL, NULL, ... )
Required fields are safe: Form::values() (system/Helpers/Form/Form.php line 691 onwards) computes $htmlIdsToIgnore from the toggle map and substitutes the default value for hidden fields rather than validating them. menu.php guarantees every field is in that map by assigning $field->htmlId = md5( mt_rand() ) to any field that does not already have one. Do not set an htmlId that collides with the four literals the form itself uses: menu_manager_access_type, menu_manager_access, menu_manager_menutype, menu_manager_icon.
Some of my configuration values are missing after saving
Only values whose keys were returned by your configuration() are kept. menu.php line 423:
if ( isset( $fieldNames[ $values['menu_manager_extension'] ] ) )
{
foreach ( $values as $k => $v )
{
if ( in_array( $k, $fieldNames[ $values['menu_manager_extension'] ] ) )
{
$config[ $k ] = $v;
}
}
}
You cannot smuggle a value in through $form->hiddenValues or a field added elsewhere. Everything you want persisted must come from a field object returned by configuration(), or be added by parseConfiguration().
Because all extensions share one form, field names must be unique across the entire suite. Two extensions using the same field name collide on the same form element. This is why every core field name is prefixed by its owner: menu_custom_item_url, menu_custom_menu_title, menu_content_page, menu_stream_id, menu_node_content_classes.
Everything breaks if my app directory or extension key contains an underscore
The extension identity is the string "{appDirectory}_{ExtensionKey}", built in Application::allExtensions() as $appExtensions[ $application->directory . '_' . $key ]. The Menu Manager splits it back apart with a bare explode(), twice (menu.php lines 346 and 418):
$exploded = explode( '_', $key ); // $exploded[0] is treated as the app, $exploded[1] as the extension key
There is no limit argument and no rejoin. An app directory of my_app yields app my; an extension file named Live_Topics.php yields extension key Live. The row is written to core_menu with the truncated values, then getExtensionClass() throws OutOfRangeException for it forever after — which, per the trap above, means the item silently never appears and leaves a blank row in the Menu Manager. Keep both the app directory and the extension class name free of underscores.
My default menu items come out in a random order
On a fresh install and on "Restore default menu", FrontNavigation::buildDefaultFrontNavigation() sets $position = 1, increments it twice for the built-in Browse and Activity tabs, and then calls:
static::insertMenuItem( $parent ?? null, $config, $position );
$position is passed by value and insertMenuItem() never returns or increments it, so every item contributed by every application is inserted with position = 4. frontNavigation() orders by position, so the relative order of default items is whatever MySQL returns for a tie.
The other code path behaves differently. When an app is installed after the community, Application::installOther() (line ~2299) re-queries MAX(position) for each item, so positions there do increment properly. Do not rely on ordering from defaultFrontNavigation(); the admin is expected to drag items into place.
My browseTabs items ended up as top-level tabs
Two different mechanisms consume defaultFrontNavigation() and they do not agree.
buildDefaultFrontNavigation() maps rootTabs to parent = NULL, browseTabs to parent = 1 and activityTabs to parent = 2, and defers browseTabsEnd to a second pass under parent 1. But Application::installOther() — the path taken when your app is installed onto a running community — iterates the same array and calls FrontNavigation::insertMenuItem( NULL, $config, ... ) unconditionally. The parent is always NULL there, because the Browse and Activity tabs are just ordinary rows with IDs 1 and 2 that the admin may already have deleted or renumbered. The docblock on Application::defaultFrontNavigation() states this outcome ("or in the top row if installing the app later").
A second, undocumented detail: the switch ( $type ) in buildDefaultFrontNavigation() has cases only for rootTabs, browseTabs and activityTabs, and $parent is declared outside the loop. Any other key in your returned array falls through the switch and silently inherits $parent from the previous iteration — or becomes a root tab via $parent ?? null if it is first. Return exactly the four documented keys.
The config I set in defaultFrontNavigation() is not the shape my extension reads
parseConfiguration() is called only from the AdminCP form handler. insertMenuItem() does 'config' => json_encode( $config['config'] ?? array() ) and stores it verbatim. So the array you supply under 'config' must already be in post-parse shape — the shape your constructor and link() expect, not the shape your form fields produce.
Compare core\Node: its form field is menu_node_content_classes, but parseConfiguration() rewrites that into array( 'nodeClass' => ..., 'selected' => ..., 'id' => ... ), and that is what the constructor reads. A defaultFrontNavigation() entry supplying menu_node_content_classes would produce a permanently broken item.
The 'icon' element has the same problem in reverse: insertMenuItem() writes 'icon' => $config['icon'] ?? null straight into the MEDIUMTEXT column with no encoding, so it must be a JSON string you encode yourself. applications/nexus/Application.php shows the required shape:
'icon' => json_encode( [ [
'key' => 'cart-shopping:fas',
'type' => 'fa',
'raw' => '<i class="fa-solid fa-cart-shopping"></i>',
'title' => 'cart-shopping',
'html' => '...'
] ] )
The key must contain a colon. menu.php::_getMenuRow() does $bits = explode( ":", $iconData[0]['key'] ) and then switches on $bits[1] with no isset() guard, so a key without a colon produces an undefined-array-key notice in the Menu Manager tree.
My default icon renders as a blank square
$defaultIcon is not a Font Awesome class name. Look at how the templates use it:
<i class="fa-solid" style="--icon:'{$item->getDefaultIcon()|raw}'" aria-hidden="true"></i>
It is injected as a CSS content value, so it must be a unicode escape, and the family class is hardcoded to fa-solid. Every core value follows this: '\f019' (downloads), '\f075' (forums), '\f87c' (gallery), '\f133' (calendar), '\e494' (followed content). The abstract's fallback is '\f1c5'. Write it in single-quoted PHP so the backslash survives. A regular or brands glyph will not render, because the class is not yours to set.
The admin-chosen icon is separate: getIconData() returns $this->icon[0]['raw'], the stored <i> markup from the icon picker, and the template prefers it when non-empty. Note that getIconDataForAttribute() exists on the abstract but is not called from anywhere in the shipped source.
My dropdown does not drop down
There are two child mechanisms and they are not the same thing.
children()is what the templates render as a dropdown. It returnsNULLon the abstract, so by default your item is a plain link. Onlycore\Menuoverrides it to read thecore_menurows beneath itself.subItems()is concrete on the abstract, always reads thecore_menurows beneath this item, and is used only byactiveOrChildActive().
The templates test {{if $children = $item->children()}}, so returning an empty array is indistinguishable from returning NULL — the item renders as a leaf link. core\Menu exploits this deliberately: for a root-level menu it returns [] and lets the separate subBars() array drive the second-level bar instead.
If you return objects from children(), each one must implement at minimum canView(), isAvailableFor( string ), children() and title() — and active() too, since Menu::active() will call it. MenuHeader, MenuSeparator and MenuButton in IPS\core\extensions\core\FrontNavigation are helper objects for exactly this purpose. They do not extend FrontNavigationAbstract, they have different constructor signatures, and the templates identify them with instanceof. They appear in core's extensions.json but are excluded from the ACP dropdown by if ( method_exists( $class, 'typeTitle' ) ) at menu.php line 344 — that guard exists solely because those three classes do not implement it.
An admin can only add my item once
allowMultiple() defaults to FALSE, and the ACP counts existing rows across the whole menu:
if ( $class::allowMultiple()
or !isset( $current[ $exploded[0] ][ $exploded[1] ] )
or ( $existing and $existing['app'] == $exploded[0] and $existing['extension'] == $exploded[1] ) )
So if defaultFrontNavigation() already placed one on install, your type is absent from the "Add" dropdown from day one and only reappears after the admin deletes it. Return TRUE from allowMultiple() for any item that carries configuration (a page, a node, a tag, a custom URL); the core types that do are CustomItem, Menu, Node, Tags, Pages and YourActivityStreamsItem.
Fatal "Attempt to read property 'key' on null" from active()
system/Dispatcher/Standard.php declares both properties as nullable:
public ?Application $application = NULL; public ?Module $module = NULL; public ?string $controller = NULL;
The nexus extensions defend against this and the core ones do not. nexus\Store::active():
return Dispatcher::i()->application->directory === 'nexus'
and Dispatcher::i()->module and Dispatcher::i()->module->key === 'store' and ...
whereas core\Search::active() dereferences Dispatcher::i()->module->key directly. Since active() runs on every page including error and offline pages, use ?-> throughout:
return Dispatcher::i()->application?->directory === 'edmyapp'
and Dispatcher::i()->module?->key === 'directory';
Related: if you override the constructor, take all six parameters. Core's Node, Tags and YourActivityStreamsItem declare only five and omit $parent. PHP silently discards extra arguments to userland functions, so this does not error — it just leaves $this->parent at 0 forever, which makes isSideBarItemCollapsed() return FALSE and changes the default expanded state in the sidebar layout.
Deleted menu items leave language strings behind
menu.php::_remove() deletes the row, recurses into children, and then calls exactly one cleanup:
Lang::deleteCustom( 'core', "menu_item_{$id}" );
Any other key your parseConfiguration() saved is orphaned in core_sys_lang_words. Core leaks two of its own this way: cms\Pages writes cms_menu_title_{$id} under app cms, and YourActivityStreamsItem writes menu_stream_title_{$id}. Neither is ever deleted.
There is a matching app mismatch on the install path: Lang::copyCustom( $config['real_app'], $config['title'], "menu_item_{$insertedId}" ) writes the row with word_app set to your application, but _remove() only ever deletes with word_app = 'core'. Lookups by addToStack() are by key alone so the item displays correctly; the row simply never goes away until the app is uninstalled. Prefer the standard menu_item_{$id} key under core if you want your strings cleaned up.
Two things worth knowing that are not quite traps
title() runs in the AdminCP too. menu.php::_getMenuRow() calls $menuItem->title() for every row in the Menu Manager tree, with no try/catch and with Dispatcher::i()->controllerLocation === 'admin'. A title() that loads a record which may have been deleted — cms\Pages::title() calls Page::load() unguarded — will 500 the Menu Manager rather than the front end.
canView() is evaluated twice per item per render. The templates test $item->canView() and $item->isAvailableFor( ... ), and isAvailableFor() itself begins with if( !$this->canView() ) return false;. If canAccessContent() issues a query, that query runs twice for every item on every page. Cache the result in a property.
Verified against
Read from the source of Invision Community 5.0.19 (highest entry in applications/core/data/versions.json is 5001908 => 5.0.19). Specifically: applications/core/sources/FrontNavigation/FrontNavigationAbstract.php, applications/core/sources/FrontNavigation/FrontNavigation.php, applications/core/modules/admin/applications/menu.php, all 35 core implementations under applications/*/extensions/core/FrontNavigation/, the front templates in applications/core/dev/html/front/global/, and the supporting code in system/Application/Application.php, system/Application/Module.php, system/Helpers/Form/Form.php, system/Http/Url/Friendly.php and system/Lang/Lang.php.
Recommended Comments