An administrator opening AdminCP → Site Features → Integrations sees a grid of cards, one per third-party service: Google Analytics, Mailchimp, Giphy, Zapier, Mapbox, Postmark, MaxMind and so on. Each card carries a title, a short description, a logo, an Enable or Disable button, and — once the service is on — a Configure Service button that opens a settings form. The core/CommunityEnhancements extension is what puts one of those cards on that page. That is the whole of its job. It does not make the integration work, it does not register settings, it does not add hooks or tasks. It is the on/off switch and the configuration screen for an integration whose actual behaviour lives somewhere else in your application.
The page is app=core&module=applications&controller=enhancements, its menu string is menu__core_applications_enhancements ("Integrations"), and it is gated behind the enhancements_manage AdminCP restriction (applications/core/modules/admin/applications/enhancements.php, line 56).
The contract
IPS\Extensions\CommunityEnhancementsAbstract (system/Extensions/CommunityEnhancementsAbstract.php) is short. Four public properties with defaults, and two abstract methods you are obliged to implement:
namespace IPS\Extensions;
abstract class CommunityEnhancementsAbstract
{
/* Is the integration currently switched on? Core NEVER writes this.
You compute it in your constructor from wherever you stored it. */
public bool $enabled = FALSE;
/* TRUE puts the card in the "Invision Community Integrations"
group instead of "All Integrations". Leave it FALSE - see below. */
public bool $ips = FALSE;
/* Does the card get a "Configure Service" button? Display only. */
public bool $hasOptions = TRUE;
/* Logo filename, relative to your app's
dev/resources/admin/enhancements/ directory. "" means no logo. */
public string $icon = "";
/**
* Edit
*
* @return void
*/
abstract public function edit() : void;
/**
* Enable/Disable
*
* @param $enabled bool Enable/Disable
* @return void
* @throws LogicException
*/
abstract public function toggle( bool $enabled ) : void;
}
Neither abstract method returns anything. edit() is expected to produce output — normally a Form wrapped in the global block template — and toggle() is expected to persist the new state itself, usually via Settings::i()->changeValues().
The important consequence of $enabled being a plain property is that there is no storage behind it. Core reads it once, off a freshly constructed object, to decide whether to draw the tick badge and the Configure button. Your constructor is what makes it true. Every shipped implementation does the same thing — Matomo::__construct() is $this->enabled = ( Settings::i()->matomo_enabled and Settings::i()->matomo_code ); (line 68). If your toggle() writes a setting your constructor does not read, the card will flip back to "Enable" on the next page load and no error will be raised anywhere — worse, enableToggle() still writes a successful acplog__enhancements_enable entry to the admin log (line 132), so the log says the integration was enabled while the page says it is off.
The two undeclared members
Two things core uses are not on the abstract at all.
public static function isAvailable() : bool is discovered with method_exists() and is entirely optional. If you declare it and it returns FALSE, your card is omitted from the page and do=edit on it returns a 404 with code 2C115/1. Core uses it for exactly one thing: SendGrid::isAvailable() (applications/core/extensions/core/CommunityEnhancements/SendGrid.php, lines 69–76) returns FALSE when the sendgrid_deprecated setting is set, which hides SendGrid on installs that never used it. Zapier::isAvailable() (line 200) is a stub that returns TRUE.
The constructor. The abstract declares none, and every shipped implementation writes public function __construct() with no parameters. Core, however, always instantiates extensions with one argument (Application::constructExtensionClass(), system/Application/Application.php line 479):
$obj = new $classToUse( $checkAccess === TRUE ? Member::loggedIn() : ( $checkAccess === FALSE ? NULL : $checkAccess ) );
The Integrations controller calls Application::allExtensions( 'core', 'CommunityEnhancements' ) without a third argument, so $checkAccess is TRUE and your class is constructed as new YourClass( Member::loggedIn() ). PHP silently discards extra arguments to userland functions, which is why the zero-parameter constructors work. Declare no parameters at all, or accept ?Member $member = NULL. A parameter typed as anything the argument cannot satisfy — a string, an array, your own class — is a TypeError the moment the extension is constructed, and constructExtensionClass() does not catch TypeError.
Registration
The file goes in applications/<yourapp>/extensions/core/CommunityEnhancements/<Name>.php and must be listed in applications/<yourapp>/data/extensions.json. Application::extensions() reads that JSON file and nothing else — it never scans the directory (system/Application/Application.php, lines 917–950).
{
"core": {
"CommunityEnhancements": {
"Matomo": "IPS\\myapp\\extensions\\core\\CommunityEnhancements\\Matomo"
}
}
}
Your extension's key on the Integrations page is <appdirectory>_<NameInJson> — built at Application::allExtensions() line 419 as $application->directory . '_' . $key. That key is what appears in the id= URL parameter and what all the language strings are named after.
Language strings
Two are required, and both are AdminCP strings in dev/lang.php:
enhancements__<appdirectory>_<Name>— the card heading, also used as the page title on the configuration screen and in the AdminCP log entry written after a toggle.enhancements__<appdirectory>_<Name>_desc— the paragraph under the heading.
The Developer Centre's missing-strings scan only knows about the first one. applications/core/modules/admin/developer/details.php line 568 maps this extension type to enhancements__{app}_{key} and there is no entry for the _desc variant, so a missing description is never reported.
Who calls it
Only one consumer exists in the whole suite. applications/core/modules/admin/applications/enhancements.php loads every extension once in execute() (line 57) and three actions use them:
manage()(lines 66–90) builds the card list. It callsisAvailable()if present, then reads$class->ips,$class->icon,$class->enabledand$class->hasOptionsinto an array grouped as$rows[ (int) $class->ips ].edit()(lines 98–118) setsOutput::i()->titlefrom the language string and calls youredit(). It catchesDomainExceptiononly, and converts it toOutput::i()->error( $e->getMessage(), $e->getCode() ).enableToggle()(lines 125–147) runs a CSRF check, calls yourtoggle(), writes an admin log entry and redirects back to the list. It catchesLogicExceptiononly.
The template is applications/core/dev/html/admin/applications/enhancements.phtml. It renders $rows[1] under the heading enhancements_ips ("Invision Community Integrations") and $rows[0] under enhancements_thirdparty ("All Integrations").
Nothing else in core iterates this extension point. Where core needs to know whether an integration is on, it constructs the specific class by name — system/Email/Outgoing/SendGrid.php line 145 and system/Email/Outgoing/Postmark.php line 90 both do ( new SendGridIntegration() )->enabled. There is no dispatcher, no event, and no way to have core call your integration for you.
My extension does not appear on the Integrations page
Nothing is logged in any of these cases. Work down the list:
- The
data/extensions.jsonentry is missing or the class name is wrong.Application::extensions()doesif( !is_string( $classname ) or !class_exists( $classname ) ) { continue; }(line 926) and moves on without comment. - The list is cached.
allExtensions()stores the full class-name map in theextensionsdatastore key (Store::i()->extensions, line 442) and only rebuilds when theCommunityEnhancementskey is absent from it. It is cleared byStore::i()->clearAll(), which runs when an application is enabled or disabled (Application::set__enabled(), line 1287) and from the AdminCP's cache-clearing tool. In development the Developer Centre's Extensions screen unsets it explicitly (applications/core/modules/admin/developer/extensions.php, lines 313 and 342). - Your application is not enabled, or the site is in recovery mode.
allExtensions()skips non-IPS applications entirely underRECOVERY_MODE(line 396) and skips any application failingappIsEnabled()(line 401). - Your application fails
canAccess(). Because$checkAccessisTRUE, line 408 calls$application->canAccess( NULL ). In the AdminCP that returnsTRUEimmediately if the logged-in admin holds thecore / applications / app_managerestriction (Application::canAccess(), line 5308). An admin who hasenhancements_managebut notapp_managefalls through to the group check, so if your app'sdisabled_groupsis'*', or excludes that admin's group, your card disappears for them and only them. - Your
isAvailable()returnsFALSE. - Your constructor threw
RuntimeExceptionorOutOfRangeException.constructExtensionClass()catches exactly those two and returnsnull(line 497), andallExtensions()then drops the extension from the returned array. This is the nastiest of the six, because anOutOfRangeExceptionis whatActiveRecord::load()throws for a missing row — a pattern Zapier's constructor guards against explicitly with its owntry/catcharoundKey::load()(constructor at lines 210–221,catch ( OutOfRangeException $e ) {}at line 219). If you load a record in your constructor and it is gone, your integration silently ceases to exist in the AdminCP.
Note that a missing setting is not one of these cases. Settings::__get() returns NULL for an unknown key rather than throwing, so a typo in a setting name gives you a card that is permanently disabled instead of an error.
Fatal error: contains 2 abstract methods and must therefore be declared abstract
edit() and toggle() are both abstract. Unlike most IPS extension abstracts, you cannot write a properties-only class. Omitting either produces a compile-time fatal when the autoloader runs class_exists() — inside Application::extensions() (line 926) while the JSON list is built, and again inside constructExtensionClass() (line 472) every time the objects are built. Both only iterate the extension type that was asked for, and CommunityEnhancements is asked for in exactly one place, so the fatal is confined to the Integrations page (any action on that controller, because the load happens in execute()) and to the Developer Centre's Extensions screen. It will not take down the front end.
If you genuinely have nothing to configure, declare an empty edit() and set $hasOptions = FALSE. That is what IndexNow does (applications/core/extensions/core/CommunityEnhancements/IndexNow.php, $hasOptions at line 46, an empty edit() at lines 92–95).
Error: Non-static method ... cannot be called statically
manage() and edit() both test with method_exists( $class, 'isAvailable' ) and then invoke it as $class::isAvailable() — a static call on an object variable. method_exists() returns TRUE for instance methods too, so declaring
public function isAvailable() : bool /* WRONG - missing static */
passes the guard and then throws Error: Non-static method ... cannot be called statically on PHP 8. Error does not extend Exception, so nothing in the controller catches it: the entire Integrations page dies, taking every other application's card with it. Declare it exactly as public static function isAvailable() : bool, or do not declare it.
The card heading reads "enhancements__myapp_MyThing"
That is the missing-language-string fallback, not a bug in your extension. When a key has no row in core_sys_lang_words, Lang::replaceWords() substitutes the key itself (system/Lang/Lang.php, around line 2132: $replacement = $values['key'];). The description paragraph is rendered unconditionally — the template's {{if $data['description']}} tests a string that is always non-empty — so a missing _desc string shows the raw key as body text on the card. Add both strings to dev/lang.php and rebuild.
The logo is a broken image
The icon is resolved as a theme resource in the enhancements/ subdirectory. Two failure modes:
- The file is not in your app's resources. It must live at
applications/<yourapp>/dev/resources/admin/enhancements/<icon>. IfTheme::resource()cannot find a matching row incore_theme_resourcesit caches anullentry in the resource map and returnsNULL(system/Theme/Theme.php, lines 848–857), so the template emitssrc=''. Because the null is cached in the theme's resource map, adding the file later is not enough on its own. - You set
$ips = TRUE. The template's IPS section hard-codes the application: line 14 is{resource="enhancements/{$data['icon']}" app="core" location="admin"}. The third-party section, line 50, usesapp="{$data['app']}", where$data['app']was derived in the controller asmb_substr( $key, 0, mb_strpos( $key, '_' ) )— the part of the key before the first underscore. So a third-party extension that sets$ips = TRUEwill have its logo looked up insidecore, where it does not exist. Leave$ipsatFALSE; it is there so core can separate its own first-party services (GeoIPandSpamMonitoringare the only two shipped with it set) from everything else.
An unverified corollary of that mb_strpos call: an application directory containing an underscore would truncate at the wrong point. No IPS application has one, and I have not tested it.
There is no Configure Service button
The template shows it only when both conditions hold (lines 29 and 65):
{{if $data['config'] && $data['enabled'] == 1}}
So the admin cannot reach your configuration form until the integration is already enabled. That ordering is the reason the shipped implementations behave the way they do — see the next section. $hasOptions is a display flag and nothing more: GeoIP sets $hasOptions = FALSE (line 49) yet implements a full settings form in edit() (lines 71–83), which is simply unreachable from the UI. Conversely, do=edit&id=... remains routable whatever $hasOptions says, so an empty edit() renders a page containing only the title.
Clicking Enable just takes me back to the settings form
This is the designed flow, not a failure. Because Configure is hidden until the service is enabled, an integration that needs an API key has nowhere to collect one. The shipped pattern is for toggle( TRUE ) to throw when the required settings are absent:
public function toggle( bool $enabled ) : void
{
if ( $enabled )
{
if ( Settings::i()->matomo_code )
{
Settings::i()->changeValues( array( 'matomo_enabled' => 1 ) );
}
else
{
throw new DomainException;
}
}
else
{
Settings::i()->changeValues( array( 'matomo_enabled' => 0 ) );
}
}
DomainException extends LogicException, so enableToggle() catches it and, for a normal non-AJAX request, redirects to do=edit&id=<key> (line 144). The admin lands on your form, fills in the key, and the form's own saveAsSettings() sets the enabled flag. Your constructor then reports $enabled = TRUE and the card shows a tick.
The failure case here is the one where you throw something that is not a LogicException. enableToggle() catches nothing else, so a RuntimeException, an Exception, or a PHP Error escapes the controller and produces a hard AdminCP error page. If your toggle talks to a remote API, wrap the call and rethrow as a DomainException carrying a language key.
Enable produces an error box with no text
When the request is AJAX, the same catch does Output::i()->error( $e->getMessage(), $e->getCode() ) (line 140). throw new DomainException; — exactly what Matomo, GoogleAnalytics and others do — has an empty message and a code of 0, so the admin gets an error with no explanation and no traceable code. Always throw with a language key and an error code of your own: throw new DomainException( 'myapp_key_required', '1M100/1' );. Matomo::edit() does this correctly inside its form validator (line 82) but not in toggle().
A stale bookmark to enableToggle causes a fatal error
edit() guards its array access with isset( $this->enhancements[ Request::i()->id ] ) and falls back to a 404. enableToggle() has no such guard (line 131):
$this->enhancements[ Request::i()->id ]->toggle( Request::i()->status );
If the id does not exist — because the application was disabled, because your extension was removed, or because the constructor threw one of the two swallowed exceptions above — this is a method call on null, which raises Error. Error is not a LogicException, so the catch below it does not apply and the admin gets an uncaught fatal. There is nothing you can do about this from inside the extension; it matters mainly because it is how a silently-dropped extension announces itself, and because it makes "the page worked yesterday" a plausible bug report.
Note also that enableToggle() does not consult isAvailable() at all — only manage() and edit() do. An unavailable integration can still be toggled by anyone who kept the URL.
My integration is not listed in the privacy policy
Core's privacy-policy sub-processor list does not read this extension point. applications/core/Application.php::privacyPolicyThirdParties() (from line 446) hard-codes each service: it constructs FacebookPixel, Postmark and SendGrid by class name and checks their $enabled property, and tests settings directly for Google Analytics, Matomo and the spam service. The base method every application inherits is Application::privacyPolicyThirdParties() (system/Application/Application.php line 6006), which just returns an empty array for apps to overload; the aggregation is a foreach over Application::enabledApplications() in applications/core/modules/front/system/privacy.php line 66 and applications/core/modules/front/system/register.php line 1328.
If your integration sends member data to a third party, override privacyPolicyThirdParties() on your own Application class and return the entry there. Registering a CommunityEnhancements extension does not do it for you.
A complete example
This is applications/core/extensions/core/CommunityEnhancements/Matomo.php, which is the smallest shipped implementation that does everything: a settings form, an enabled state derived from settings, and a toggle that refuses to enable an unconfigured service. Only the namespace and the language keys need changing for your own app.
<?php
namespace IPS\myapp\extensions\core\CommunityEnhancements;
use DomainException;
use IPS\Extensions\CommunityEnhancementsAbstract;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Codemirror;
use IPS\Helpers\Form\YesNo;
use IPS\Http\Url;
use IPS\Member;
use IPS\Output;
use IPS\Request;
use IPS\Settings;
use IPS\Theme;
use LogicException;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Matomo extends CommunityEnhancementsAbstract
{
/**
* @brief Enhancement is enabled?
*/
public bool $enabled = FALSE;
/**
* @brief IPS-provided enhancement?
*/
public bool $ips = FALSE;
/**
* @brief Enhancement has configuration options?
*/
public bool $hasOptions = TRUE;
/**
* @brief Icon data
*/
public string $icon = "matomo.png";
/**
* Constructor - core never stores $enabled, so derive it here
*/
public function __construct()
{
$this->enabled = ( Settings::i()->matomo_enabled and Settings::i()->matomo_code );
}
/**
* Edit
*
* @return void
*/
public function edit() : void
{
$validation = function( $val ) {
if ( $val and !Request::i()->matomo_code )
{
throw new DomainException('matomo_code_required');
}
};
$form = new Form;
$form->add( new YesNo( 'matomo_enabled', Settings::i()->matomo_enabled, FALSE, array(), $validation ) );
$form->add( new Codemirror( 'matomo_code', Settings::i()->matomo_code, FALSE, array( 'height' => 150, 'codeModeAllowedLanguages' => [ 'html' ] ), NULL, NULL, NULL, 'matomo_code' ) );
if ( $form->values() )
{
try
{
$form->saveAsSettings();
Output::i()->inlineMessage = Member::loggedIn()->language()->addToStack('saved');
}
catch ( LogicException $e )
{
$form->error = $e->getMessage();
}
}
Output::i()->sidebar['actions'] = array(
'help' => array(
'title' => 'learn_more',
'icon' => 'question-circle',
'link' => Url::ips( 'docs/matomo' ),
'target' => '_blank'
),
);
Output::i()->output = Theme::i()->getTemplate( 'global' )->block( 'enhancements__core_Matomo', $form );
}
/**
* Enable/Disable
*
* @param $enabled bool Enable/Disable
* @return void
* @throws LogicException
*/
public function toggle( bool $enabled ) : void
{
if ( $enabled )
{
if ( Settings::i()->matomo_code )
{
Settings::i()->changeValues( array( 'matomo_enabled' => 1 ) );
}
else
{
throw new DomainException;
}
}
else
{
Settings::i()->changeValues( array( 'matomo_enabled' => 0 ) );
}
}
}
Three supporting pieces are needed for it to appear:
/* applications/myapp/data/extensions.json */
{
"core": {
"CommunityEnhancements": {
"Matomo": "IPS\\myapp\\extensions\\core\\CommunityEnhancements\\Matomo"
}
}
}
/* applications/myapp/dev/lang.php */
'enhancements__myapp_Matomo' => "Matomo",
'enhancements__myapp_Matomo_desc' => "Self-hosted web analytics.",
/* the logo */
applications/myapp/dev/resources/admin/enhancements/matomo.png
The settings matomo_enabled and matomo_code must exist in your application's settings, since saveAsSettings() writes to core_sys_conf_settings by form field name.
Other implementations worth reading
applications/core/extensions/core/CommunityEnhancements/SpamMonitoring.php—edit()does not render a form at all. It runs a connectivity test and thenOutput::i()->redirect()s to an unrelated AdminCP screen (lines 81–97).edit()is free to do anything that produces output.applications/core/extensions/core/CommunityEnhancements/IndexNow.php— no options at all:$hasOptions = FALSE, an emptyedit(), and atoggle()that generates and stores a random key on enable and clears it on disable.applications/core/extensions/core/CommunityEnhancements/Zapier.php— the largest one, and the only shipped example of an integration that owns a record (a REST API key) rather than a pair of settings. Its constructor guardsKey::load()withcatch ( OutOfRangeException $e ) {}; itsedit()at line 228 does not, which means a deleted API key would throw out ofedit()uncaught.applications/nexus/extensions/core/CommunityEnhancements/MaxMind.php— the only implementation outsidecore, and the reference for how a non-core application registers one, including its owndev/resources/admin/enhancements/maxmind.png.
Verified against
Read from Invision Community 5.0.19 source: system/Extensions/CommunityEnhancementsAbstract.php; all sixteen implementations in applications/core/extensions/core/CommunityEnhancements/ plus applications/nexus/extensions/core/CommunityEnhancements/MaxMind.php; applications/core/modules/admin/applications/enhancements.php; applications/core/dev/html/admin/applications/enhancements.phtml; system/Application/Application.php (allExtensions(), extensions(), constructExtensionClass(), canAccess(), set__enabled(), privacyPolicyThirdParties()); system/Lang/Lang.php (get(), addToStack(), replaceWords()); system/Theme/Theme.php (resource()); system/Settings/Settings.php (__get()); applications/core/modules/admin/developer/details.php; applications/core/data/acpmenu.json and acprestrictions.json.
Not verified: the behaviour of an application directory containing an underscore against the controller's mb_strpos-based app derivation, and the rendering of $rows[1] on a hypothetical install where no extension sets $ips = TRUE (core always ships two that do, so it does not arise in practice).
Recommended Comments