Skip to content
View in the app

A better way to browse. Learn more.

ernestdefoe.online

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.
ernestdefoe.online

Extensions, themes & support for Flarum and Invision Community

Vibe coding for the community web. Report a bug, request a feature, or dig into the source — this is where the tools you use get built, in the open.

We do custom Bespoke Invision Community apps. If you have an idea for something you want then use the contact form to get in touch with us.
Knowledge base

Things that cost me a day, so they cost you none

Working notes from building Invision Community and Flarum applications. Mostly the failures that give no error at all — the ones where everything installs cleanly and quietly does the wrong thing.

92 articles

Invision Community 5

86 articles

Extensions and contracts

39

What each extension point is for, what it must declare, and what happens when it is wrong — which is usually nothing visible.

Languages and text

5

The string table, translation, and the places where text does not appear where you expected it to.

Theming, templates and forms

9

Theme hooks, CSS that survives both colour schemes, and building forms that do not throw on render.

Background work and scheduled tasks

5

The queue system, work that has to happen after the response, and jobs that finish without doing anything.

Data, settings and storage

11

The database layer, settings, tags, file storage, and backing up a live site.

AI features and expectations

5

What these features do, what they cost, and what buyers reasonably but wrongly assume they do.

Application structure and releases

11

The JSON files an application is made of, versioning and upgrade steps, and testing from the command line.

Realtime, chat and calls

1

WebSocket gateways, relays and the server-side pieces live features depend on — where "it works when I test it" and "it works for your members" are different claims.

Nothing matches that.

The core/CommunityEnhancements extension in Invision Community 5

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 calls isAvailable() if present, then reads $class->ips, $class->icon, $class->enabled and $class->hasOptions into an array grouped as $rows[ (int) $class->ips ].
  • edit() (lines 98–118) sets Output::i()->title from the language string and calls your edit(). It catches DomainException only, and converts it to Output::i()->error( $e->getMessage(), $e->getCode() ).
  • enableToggle() (lines 125–147) runs a CSRF check, calls your toggle(), writes an admin log entry and redirects back to the list. It catches LogicException only.

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.json entry is missing or the class name is wrong. Application::extensions() does if( !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 the extensions datastore key (Store::i()->extensions, line 442) and only rebuilds when the CommunityEnhancements key is absent from it. It is cleared by Store::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 under RECOVERY_MODE (line 396) and skips any application failing appIsEnabled() (line 401).
  • Your application fails canAccess(). Because $checkAccess is TRUE, line 408 calls $application->canAccess( NULL ). In the AdminCP that returns TRUE immediately if the logged-in admin holds the core / applications / app_manage restriction (Application::canAccess(), line 5308). An admin who has enhancements_manage but not app_manage falls through to the group check, so if your app's disabled_groups is '*', or excludes that admin's group, your card disappears for them and only them.
  • Your isAvailable() returns FALSE.
  • Your constructor threw RuntimeException or OutOfRangeException. constructExtensionClass() catches exactly those two and returns null (line 497), and allExtensions() then drops the extension from the returned array. This is the nastiest of the six, because an OutOfRangeException is what ActiveRecord::load() throws for a missing row — a pattern Zapier's constructor guards against explicitly with its own try/catch around Key::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>. If Theme::resource() cannot find a matching row in core_theme_resources it caches a null entry in the resource map and returns NULL (system/Theme/Theme.php, lines 848–857), so the template emits src=''. 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, uses app="{$data['app']}", where $data['app'] was derived in the controller as mb_substr( $key, 0, mb_strpos( $key, '_' ) ) — the part of the key before the first underscore. So a third-party extension that sets $ips = TRUE will have its logo looked up inside core, where it does not exist. Leave $ips at FALSE; it is there so core can separate its own first-party services (GeoIP and SpamMonitoring are 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.phpedit() does not render a form at all. It runs a connectivity test and then Output::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 empty edit(), and a toggle() 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 guards Key::load() with catch ( OutOfRangeException $e ) {}; its edit() at line 228 does not, which means a deleted API key would throw out of edit() uncaught.
  • applications/nexus/extensions/core/CommunityEnhancements/MaxMind.php — the only implementation outside core, and the reference for how a non-core application registers one, including its own dev/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).


User Feedback

Recommended Comments

There are no comments to display.

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.