The core/ContentRouter extension is how an application tells the rest of Invision Community that a class is a content item. It is not a feature in itself — it is the list that roughly thirty other subsystems read to discover what content exists on the site: search, activity streams, the leaderboard, the member profile "Content" tab, moderator permissions, the Report Center, sitemaps, IP address lookup, achievement rules, follow records, warnings, advertisements, embeds and the REST/GraphQL content endpoints. If a class is not returned by a ContentRouter, none of those subsystems can see it, and no error is raised anywhere.
Core calls it constantly and from very different contexts, sometimes with a member, sometimes with a group, sometimes with nothing at all. That variety is where nearly every trap in this article comes from.
The contract
IPS\Extensions\ContentRouterAbstract is unusually small — it declares two properties and one abstract method, and the abstract method is the constructor.
abstract class ContentRouterAbstract
{
/* Content item classes this app exposes. Each entry must be a
fully-qualified class name that is a subclass of IPS\Content\Item. */
public array $classes = array();
/* Whether these classes may appear in the "Similar Content" widget.
Elasticsearch only. Defaults to FALSE, which actively EXCLUDES them. */
public bool $similarContent = FALSE;
/* You MUST implement this. $member is NULL when the caller does not
want an access check, a Member when it does, or a Group when the
call came from the ACP group form. */
abstract public function __construct( Member|Group $member = NULL );
}
Two further properties are read by core but are not declared on the abstract. Core only ever touches them behind isset(), in IPS\Text\Parser::_findItemFromUrl():
/* Node classes this app owns, for URL->embed resolution. e.g. IPS\blog\Blog, IPS\gallery\Album */ public array $ownedNodes = array(); /* Things that are embeddable but are NOT IPS\Content\Item subclasses. e.g. IPS\Member\Club, IPS\gallery\Album\Item */ public array $embeddableContent = array();
There is one optional method, discovered by method_exists() rather than declared anywhere:
public function customTableHelper( string $className, Url $url, array $where = array() ): ?Content
Every routed class in $classes must, at minimum, declare the statics $application, $module, $title, $databaseTable, $databasePrefix, $databaseColumnId and $databaseColumnMap. $commentClass, $reviewClass, $containerNodeClass and $archiveClass are optional and are always tested with isset().
A minimal example
For an app in applications/acme with a content item IPS\acme\Ticket that lives in the front module tickets. The file is applications/acme/extensions/core/ContentRouter/Tickets.php:
<?php
namespace IPS\acme\extensions\core\ContentRouter;
use IPS\Application\Module;
use IPS\Extensions\ContentRouterAbstract;
use IPS\Member;
use IPS\Member\Group;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Tickets extends ContentRouterAbstract
{
/**
* @brief Allow these items in the Similar Content widget
*/
public bool $similarContent = TRUE;
/**
* @param Member|Group|null $member
*/
public function __construct( Member|Group|null $member = NULL )
{
if ( $member === NULL or $member->canAccessModule( Module::get( 'acme', 'tickets', 'front' ) ) )
{
$this->classes[] = 'IPS\acme\Ticket';
}
}
}
The matching entry in applications/acme/data/extensions.json:
{
"core": {
"ContentRouter": {
"Tickets": "IPS\\acme\\extensions\\core\\ContentRouter\\Tickets"
}
}
}
Fatal error: Class ... contains 1 abstract method and must therefore be declared abstract
The exact text is:
Fatal error: Class IPS\acme\extensions\core\ContentRouter\Tickets contains 1 abstract method and must therefore be declared abstract or implement the remaining methods (IPS\Extensions\ContentRouterAbstract::__construct)
Most IPS extension abstracts declare no abstract members, so it is natural to write a ContentRouter as nothing but a property:
class Tickets extends ContentRouterAbstract
{
public array $classes = array( 'IPS\acme\Ticket' ); /* WRONG - fatal */
}
ContentRouterAbstract::__construct() is abstract. You must declare a constructor even if it does nothing conditional. The fatal is a compile-time error triggered by the autoloader during class_exists() inside IPS\Application::extensions(), which means it fires on essentially every page of the site, front and ACP, not just yours.
The extension file exists but is never called
Nothing happens. No error, no entry in the system log, your class simply does not exist as far as the suite is concerned.
IPS\Application::extensions() does not scan the extensions/ directory. It reads applications/<app>/data/extensions.json and nothing else:
foreach ( $json[ $app ][ $extension ] as $name => $classname )
{
if( !is_string( $classname ) or !class_exists( $classname ) )
{
/* Switching between branches confuses extensions */
continue;
}
...
}
Two ways to hit this. First, you created the PHP file by hand in your editor and never touched extensions.json. That file is only ever rewritten by Application::buildExtensionsJson(), which is called from exactly two places, both in applications/core/modules/admin/developer/extensions.php: the Developer Center's "Create New Extension" and "Delete" actions. Nothing else regenerates it — not the build process, not an upgrade. Either add the extension through the Developer Center or edit the JSON yourself.
Second, the class name inside the file does not match the key/path in the JSON. The JSON value is derived from the file path, so Tickets.php must declare class Tickets. Note that a pure case difference survives, because PHP class names are case-insensitive — core relies on this in applications/gallery/extensions/core/ContentRouter/gallery.php, which declares class Gallery and registers ...\ContentRouter\gallery. A genuinely different name does not survive: class_exists() returns FALSE and the continue above skips it in total silence.
I added the extension but the site behaves as if it is not there
Same symptom as above, different cause, and this one bites even when extensions.json is correct.
IPS\Application::allExtensions() caches the resolved class-name map in the datastore under the key extensions:
try
{
$allExtensions = Store::i()->extensions;
}
catch( OutOfRangeException )
{
$allExtensions = [];
}
if( !array_key_exists( $extension, $allExtensions ) )
{
/* ... walk every application, build the list ... */
Store::i()->extensions = $allExtensions;
}
The cache is keyed by extension type only. Once ContentRouter is in there, the directory walk never runs again. unset( Store::i()->extensions ) happens when you add or remove an extension through the Developer Center, and the extensions key is listed in Application::$caches so it is cleared when an application record is saved (install, enable, disable, uninstall). Dropping a file in over FTP and editing the JSON by hand clears nothing. Use ACP → Support → Clear Caches, or save the application.
My content vanished from search and sitemaps site-wide, and I cannot reproduce it
This is the same cache, but the failure mode is much worse and it is worth understanding precisely because the symptom does not point at the cause.
The datastore-cached list is built after application-level access filtering, and the filtered result is what gets persisted for everyone:
foreach ( $apps as $application )
{
/* Skip third party apps if recovery mode is enabled */
if( RECOVERY_MODE and !in_array( $application->directory, IPS::$ipsApps ) )
{
continue;
}
if ( !static::appIsEnabled( $application->directory ) )
{
continue;
}
if( $checkAccess !== FALSE )
{
if( !$application->canAccess( $checkAccess === TRUE ? NULL : $checkAccess ) )
{
continue;
}
}
/* ... collect this app's extension class names ... */
}
Store::i()->extensions = $allExtensions;
Whoever triggers the rebuild decides the contents of the cache for every subsequent visitor. Two concrete consequences:
- Recovery mode. If the
ContentRouterlist is first built whileRECOVERY_MODEis on, every third-party app is omitted, and that list is written to the datastore. Turning recovery mode back off does not clear it. Your app's content stays missing from search, sitemaps, moderator permissions and the profile Content tab until something else clears the cache. The apparent cause (recovery mode) and the symptom (content missing days later) are far apart. - Group-restricted apps. If your application has
disabled_groupsset and the first request after a cache clear is a guest,canAccess()returns FALSE, your app is skipped, and the resulting cache is served to admins too.
You cannot fix this from inside your extension. It is a reason to treat the constructor's $member check as the real gate (see below) and to clear caches after any recovery-mode session.
The extension silently returns nothing and there is no error at all
Your ContentRouter class is registered, cached, and instantiated — and still no classes reach any consumer. This is the most common ContentRouter bug and it is deliberately invisible.
Application::constructExtensionClass() swallows two exception types from your constructor:
try
{
$obj = new $classToUse( ... );
...
return $obj;
}
catch( RuntimeException | OutOfRangeException $e ){}
return null;
Returning null means the extension is dropped from the returned array entirely. And IPS\Application\Module::get() — which the core template for this extension tells you to call — throws a bare OutOfRangeException when the module cannot be found:
public static function get( string $app, string $key, string $area=NULL ): Module
{
$modules = static::modules();
if ( isset( $modules[ $app ] ) )
{
$area = $area ?: Dispatcher::i()->controllerLocation;
if ( isset( $modules[ $app ][ $area ] ) )
{
if ( isset( $modules[ $app ][ $area ][ $key ] ) )
{
return $modules[ $app ][ $area ][ $key ];
}
}
}
throw new OutOfRangeException;
}
So a typo in the module key, a module that exists in admin but not front, or an app whose modules were never installed, all produce the same result: your whole extension disappears with no log entry. Check the module key against core_modules (sys_module_application, sys_module_key, sys_module_area = 'front').
Always pass the third argument. Module::get( 'acme', 'tickets' ) falls back to Dispatcher::i()->controllerLocation, which is 'admin' inside the ACP and undefined under CLI tasks — so a router that works on the front end will quietly evaporate in the ACP moderator-permission form and in cron.
TypeError when editing a member group in the ACP
You see something like:
TypeError: IPS\acme\extensions\core\ContentRouter\Tickets::__construct(): Argument #1 ($member) must be of type ?IPS\Member, IPS\Member\Group given
on ACP → Members → Groups → Edit — a screen that has nothing to do with your app.
IPS\core\extensions\core\GroupForm\Content passes a Group, not a Member:
foreach ( Application::allExtensions( 'core', 'ContentRouter', $group ) as $ext )
and that value is handed straight to your constructor. A TypeError extends Error, not Exception, so the catch( RuntimeException | OutOfRangeException ) above does not catch it. The group edit form dies outright, and the stack trace names your extension on a page owned by core. Type the parameter as Member|Group|null. Both classes implement canAccessModule( Module $module ): bool, so the body needs no branching.
Error: Access to undeclared static property on the member profile Content tab
Typical text: Error: Access to undeclared static property IPS\acme\Ticket::$application, seen on /profile/1-someone/?do=content, on the Leaderboard, or on every front-end page of your own app.
Consumers read the statics of everything you put in $classes with no guard. In applications/core/modules/front/members/profile.php:
$types[ $class::$application . '_' . $class::$module ][ ... ] = $class;
In system/Dispatcher/Front.php, on every page load of your app:
if ( SearchContent::isSearchable( $class ) and $this->module->key == $class::$module )
None of $application, $module, $title is declared with a default on IPS\Content or IPS\Content\Item, so omitting them in your item class is an Error, not a warning. ($commentClass is declared — public static ?string $commentClass = NULL; — which is why core can safely write isset( $class::$commentClass ) everywhere.)
Two related crashes from the same omission, both outside your app:
IPS\core\extensions\core\ModeratorPermissions\ContentGenerator::getPermissions()callsApplication::load( $class::$application )with no try/catch. A$applicationvalue that is not an installed app directory throwsOutOfRangeExceptionand takes out ACP → Members → Staff → edit moderator.IPS\core\extensions\core\Sitemap\Content::getFilenames()callsModule::get( $class::$application, $class::$module, 'front' )inside atry { } catch ( OutOfRangeException ) { return array(); }. Here the wrong module key does not crash — it silently returns no sitemap files, so your content is simply absent fromsitemap.phpforever.
Class IPS\acme\Ticket not found, thrown from a core file
Core validates the extension class with class_exists(). It never validates the strings inside $this->classes. A renamed or removed content class, or a string with a typo, produces an uncatchable "Class not found" from wherever the list is consumed — system/Content/Search/Mysql/Query.php, system/Member/Member.php::topMembersOptions(), the profile controller — never from your file. Since $classes is a plain array of strings, the only defence is your own:
if ( class_exists( 'IPS\acme\Ticket' ) )
{
$this->classes[] = 'IPS\acme\Ticket';
}
This matters most for apps that build the list dynamically. IPS\cms\extensions\core\ContentRouter\Records does exactly this, wrapping its whole loop:
try
{
foreach ( Databases::databases() as $id => $database )
{
...
$this->classes[] = 'IPS\cms\Records' . $id;
}
}
catch ( Exception $e ) {} // If you have not upgraded pages but it is installed, this throws an error
Raw language keys appear on the Leaderboard and the moderator permission form
You see literal text such as acme_ticket_pl or can_edit_acme_ticket instead of readable names.
$class::$title is a language key prefix, not a title. Consumers append suffixes to it and pass the result to addToStack(), which returns the key itself when the key is missing. From the source, you need at least:
| Key | Used by |
|---|---|
<title> | Achievement rule filters (ContentAchievementActionAbstract::filters()), the mostContributions widget |
<title>_pl | Leaderboard areas (discover/popular.php), sitemap settings, IP address lookup |
<title>_pl_lc | Top Members filters (Member::topMembersOptions()) |
can_edit_<title>, can_delete_<title>, can_hide_<title>, etc. | Generated moderator permissions |
If your item has a $containerNodeClass you additionally need <nodeTitle>_sg and <nodeTitle>_sg_lc. Core's own defensive code confirms these are expected to be missing sometimes — ContentAchievementActionAbstract wraps the lookup:
try
{
$nodeTitle = Member::loggedIn()->language()->get( ($class::$containerNodeClass)::$nodeTitle . '_sg_lc' );
}
catch( UnderflowException $e )
{
$nodeTitle = ($class::$containerNodeClass)::$nodeTitle . '_sg';
}
Only one of my app's two ContentRouter extensions appears in the Report Center filters
If your app registers more than one ContentRouter — as cms does, with Pages and Records — the Report Center's per-app filter only reflects one of them, the last one alphabetically by extension key.
applications/core/extensions/core/ModCp/Reports.php:
$apps = [];
foreach ( Application::allExtensions( 'core', 'ContentRouter') as $app => $classes )
{
$appKey = explode('_', $app)[0];
$apps[ $appKey ] = $classes;
}
allExtensions() keys its return value as <appDirectory>_<extensionKey>, so cms_Pages and cms_Records both reduce to cms and the second assignment overwrites the first. The variable is also misnamed — $classes holds the router object, not an array — and is immediately re-read as $data->classes in the next loop. The practical rule: put everything in one ContentRouter per app unless you have a reason not to.
The same line has a second edge: explode('_', $app)[0] truncates at the first underscore, and the result is then used as Application::applications()[ $appKey ] with no isset(). An application directory containing an underscore will throw an undefined-index error on the shared ModCP Reports screen, for every moderator, caused by an app that is otherwise working fine.
My items never show up in the Similar Content widget
$similarContent defaults to FALSE, and FALSE is not neutral — it is an active exclusion. IPS\Content\Search\Elastic\Query::filterByMoreLikeThis():
foreach ( Application::allExtensions( 'core', 'ContentRouter', FALSE ) as $object )
{
$classes = array_merge( $object->classes, $classes );
if ( empty( $object->similarContent ) )
{
foreach( $object->classes as $class )
{
$noSimilarContentClasses[] = $class;
if ( is_subclass_of( $class, 'IPS\Content\Item' ) and isset( $class::$commentClass ) )
{
$noSimilarContentClasses[] = $class::$commentClass;
}
}
}
}
Those classes end up in a must_not clause. Set public bool $similarContent = TRUE; to opt in.
Note the scope carefully: this property is read in one place in the entire suite, and that place is the Elasticsearch query builder. IPS\Content\Controller::getSimilarContent() only calls filterByMoreLikeThis() when Settings::i()->search_method == 'elastic'. On a MySQL-search site the similar-content widget uses tag matching via core_tags and $item->similarContentFilter() instead, and $similarContent has no effect whatsoever. Testing this on a MySQL site will tell you nothing.
Putting a node or a non-item class in $classes breaks unrelated screens
Every consumer of $classes assumes IPS\Content\Item. Adding a IPS\Node\Model subclass, or any other embeddable object, produces failures on the profile Content tab, the leaderboard and the ACP statistics charts, all of which call $class::supportsComments(), $class::$databaseColumnMap or $class::reactionType().
Core carves these out into the two undeclared properties. IPS\core\extensions\core\ContentRouter\Clubs registers IPS\Member\Club — which is not a content item — and leaves $classes empty:
class Clubs extends ContentRouterAbstract
{
public array $embeddableContent = array();
public function __construct( Member|Group|null $memberOrGroup = NULL )
{
if ( $memberOrGroup === NULL or $memberOrGroup->canAccessModule( Module::get( 'core', 'clubs', 'front' ) ) )
{
$this->embeddableContent[] = 'IPS\Member\Club';
}
}
}
Blog uses $ownedNodes = array( 'IPS\blog\Blog' ); Gallery uses both. Both properties are read only by IPS\Text\Parser when resolving a pasted URL into an embed, and only for classes that implement IPS\Content\Embeddable:
$classes = $extension->classes;
if ( isset( $extension->ownedNodes ) )
{
$classes = array_merge( $classes, $extension->ownedNodes );
}
if ( isset( $extension->embeddableContent ) )
{
$classes = array_merge( $classes, $extension->embeddableContent );
}
$classes = array_filter( $classes, function( $class ) {
return in_array( 'IPS\Content\Embeddable', class_implements( $class ) );
} );
The isset() guards exist precisely because these properties are not on the abstract. Declaring them is optional; declaring them with the wrong contents is not recoverable.
My access check in the constructor is being ignored
You gate $this->classes on canAccessModule(), then find your content classes exposed to members who should not see them — in embed previews, attachment lookups, follow records or GraphQL.
There are two entry points with different defaults, and they are easy to confuse:
| Call | What your constructor receives |
|---|---|
Application::allExtensions( 'core', 'ContentRouter' ) | Member::loggedIn() ($checkAccess defaults to TRUE) |
Application::allExtensions( 'core', 'ContentRouter', FALSE ) | NULL |
$application->extensions( 'core', 'ContentRouter' ) | NULL — $checkAccess defaults to FALSE here |
The per-application form defaults the opposite way to the global one. From Application::extensions():
public function extensions( Application|string $app, string $extension, bool $construct=TRUE, bool|Group|Member|null $checkAccess = FALSE ): array
Core uses that form, with the default, in IPS\Text\Parser (URL embeds), applications/core/modules/front/system/ajax.php (attachment info), IPS\core\Warning, IPS\core\Followed\Follow, IPS\core\Advertisement, system/Dispatcher/Front.php and several GraphQL resolvers. In all of those your constructor is handed NULL and every class you can return is returned.
The consequence: the member check in a ContentRouter constructor is advisory, not a permission boundary. It controls which screens offer your content type; it does not control who can load an object. Real access control belongs in canView() / loadAndCheckPerms() on the content class itself, which is what those call sites go on to invoke.
customTableHelper is called with my comment and review classes too
If you implement customTableHelper(), the member profile controller registers your router as the handler for the item class and its comment and review classes. From applications/core/modules/front/members/profile.php:
if( method_exists( $router, 'customTableHelper' ) )
{
$hasCallback[ ... $class ... ] = $router;
if ( $supportsComments )
{
$hasCallback[ ... $class::$commentClass ... ] = $router;
}
if ( $supportsReviews )
{
$hasCallback[ ... $class::$reviewClass ... ] = $router;
}
}
There is no interface and no abstract declaration — presence of the method is the whole contract, so you get no signature checking either. Your implementation must therefore expect a $className it never registered. Gallery's guard is the pattern to copy:
public function customTableHelper( string $className, Url $url, array $where=array() ): ?Content
{
if( !in_array( $className, $this->classes ) or $className == 'IPS\gallery\Album\Item' )
{
return new Content( $className, $url, $where, null, Filter::FILTER_AUTOMATIC, 'read' );
}
/* ... custom table for the classes we actually own ... */
}
Note that $this->classes may be empty at this point if the current member failed your constructor's access check, in which case the guard sends everything down the generic path — which is the safe outcome.
SQL error on the ACP Community Activity chart, but only on Elasticsearch sites
IPS\core\extensions\core\Statistics\CommunityActivity::getResults() queries core_search_index when the search method is MySQL, but falls back to querying each routed class's own table when it is not:
foreach ( Application::allExtensions( 'core', 'ContentRouter' ) as $contentRouter )
{
foreach ( $contentRouter->classes as $class )
{
if ( isset( $class::$databaseColumnMap['author'] ) )
{
$results = array_replace_recursive( $results, $this->getSqlResults(
$class::$databaseTable,
$class::$databasePrefix . ( $class::$databaseColumnMap['updated'] ?? $class::$databaseColumnMap['date'] ),
$class::$databasePrefix . $class::$databaseColumnMap['author'],
$chart
) );
}
}
}
The ?? only protects the 'updated' lookup. If your $databaseColumnMap has an author entry but neither updated nor date, the second lookup emits an undefined-key notice, resolves to an empty string, and the chart runs a query against a column name that is just your table prefix. If either entry maps to an array — which is legal and common — you get "Array to string conversion" and the same broken column.
Compare IPS\core\extensions\core\MemberACPProfileBlocks\Header, which walks the same class list and handles both cases explicitly:
if ( isset( $class::$databaseColumnMap['date'] ) )
{
$dateColumn = $class::$databaseColumnMap['date'];
}
elseif ( isset( $class::$databaseColumnMap['updated'] ) )
{
$dateColumn = $class::$databaseColumnMap['updated'];
}
else
{
continue;
}
if ( is_array( $dateColumn ) )
{
$dateColumn = array_pop( $dateColumn );
}
The asymmetry is the tell. Give every routed class a scalar date entry in $databaseColumnMap whenever it has an author entry — the failure surfaces on an ACP statistics page nobody associates with your app, and only on sites using Elasticsearch.
What ContentRouter does not do
Two things developers routinely expect from this extension and do not get. Registering a class here does not make it searchable — search indexing is driven by a separate core/SearchContent extension, and SearchContent::searchableClasses() intersects that list with Content::routedClasses( $member ), so you need both. And it does not create a Sitemap, IpAddresses or ModeratorPermissions extension for your app; rather, core's own generated extensions (subclasses of IPS\Content\ExtensionGenerator) iterate Content::routedClasses() and produce one instance per routed class. That is why a broken ContentRouter causes moderator permissions and sitemap entries to disappear rather than to error.
Verified against
Read from the source of Invision Community 5.0.19 (latest upgrade step applications/core/setup/upg_5001908), PHP 8.1–8.3 per applications/core/data/requirements.json. Key files: system/Extensions/ContentRouterAbstract.php, system/Application/Application.php (allExtensions(), extensions(), constructExtensionClass(), buildExtensionsJson()), system/Content/Content.php (routedClasses()), system/Content/ExtensionGenerator.php, system/Text/Parser.php, system/Content/Search/Elastic/Query.php, system/Content/Search/Mysql/Query.php, applications/core/modules/front/members/profile.php, applications/core/extensions/core/ModCp/Reports.php, applications/core/extensions/core/Sitemap/Content.php, applications/core/extensions/core/ModeratorPermissions/ContentGenerator.php, applications/core/extensions/core/GroupForm/Content.php, applications/core/extensions/core/Statistics/CommunityActivity.php, and all eight core implementations under applications/*/extensions/core/ContentRouter/.
Recommended Comments