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/ModeratorPermissions extension in Invision Community 5

The core/ModeratorPermissions extension is how an application adds fields to AdminCP → Members → Staff → Moderators → [edit], and how it gets told when a moderator record is created, changed or deleted. Every field you declare becomes one key in a single flat JSON blob stored in core_moderators.perms, which is read back at runtime by IPS\Member::modPermission( 'your_key' ).

The only consumer is applications/core/modules/admin/staff/moderators.php. It calls Application::allExtensions( 'core', 'ModeratorPermissions', FALSE ) six times across three methods: add() (fires onChange), edit() (collects toggles, builds the form, then fires preSave and onChange), and delete() (fires onDelete). Nothing else in the suite calls this extension.

The contract

abstract class IPS\Extensions\ModeratorPermissionsAbstract
{
    /* REQUIRED. Returns the fields for your tab.
       Key   = the permission key, which is ALSO the form field name,
               the language key for the label, and the toggle element id.
       Value = either a type string, or an array:
                 [0] type      - 'YesNo' | 'Number' | 'Node'  (see traps)
                 [1] options   - options array for IPS\Helpers\Form\<type>
                 [2] prefix    - literal string, NOT a language key
                 [3] suffix    - literal string, NOT a language key
       Return [] and no tab is rendered at all. */
    abstract public function getPermissions( array $toggles ): array;

    /* Called once per extension immediately before the values are saved.
       $values is BY REFERENCE - you may add/remove/rewrite keys.
       At this point $values['mod_use_restrictions'] is still present
       ('yes' or 'no'); core unsets it after the preSave loop. */
    public function preSave( array &$values ): void;

    /* Called after core_moderators is written, and also when a moderator
       is first created. $changed is the string '*' when the admin picked
       "All permissions", otherwise an array of only the changed keys.
       DECLARE IT array|string - see the TypeError trap below. */
    public function onChange( array $moderator, array|string $changed ): void;

    /* Called after the core_moderators row is deleted.
       $moderator is the raw pre-delete DB row: id, type, perms, updated,
       show_badge. */
    public function onDelete( array $moderator ): void;

    /* Only reachable via core's ContentGenerator, and only if your app owns
       a routed content ITEM class - see the trap below. The keys are merged
       into that content class's tab instead of your own. */
    public function getContentPermissions( array $toggles ): array;

    /* Called from ContentGenerator::onDelete(). */
    public function onContentDelete( array $moderator ): void;

    /* DEAD CODE. Nothing in IC 5.0.19 calls this. */
    public function onContentChange( array $moderator, array $changed ): void;
}

$toggles is an array keyed by 24 fixed action names (view_future, future_publish, pin, unpin, feature, unfeature, edit, hide, unhide, view_hidden, move, lock, unlock, reply_to_locked, assign, delete, split_merge, feature_comments, unfeature_comments, add_item_message, edit_item_message, delete_item_message, toggle_item_moderation, view_reports). Each value is a list of per-content-class element ids such as can_edit_topic. It exists so core's own Content tab can pass them as togglesOff; third-party extensions normally ignore it.

A minimal example

applications/myapp/data/extensions.json — the extension is only discovered from this file, not by scanning the directory:

{
    "core": {
        "ModeratorPermissions": {
            "Perms": "IPS\\myapp\\extensions\\core\\ModeratorPermissions\\Perms"
        }
    }
}

applications/myapp/extensions/core/ModeratorPermissions/Perms.php:

<?php

namespace IPS\myapp\extensions\core\ModeratorPermissions;

use IPS\Data\Store;
use IPS\Extensions\ModeratorPermissionsAbstract;
use IPS\Member;
use function array_key_exists;
use function defined;
use function is_array;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
    header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
    exit;
}

class Perms extends ModeratorPermissionsAbstract
{
    public function getPermissions( array $toggles ): array
    {
        return array(
            'myapp_can_approve' => 'YesNo',
            'myapp_can_feature' => array( 'YesNo', array( 'togglesOn' => array( 'myapp_feature_limit' ) ) ),
            /* index [1] must exist even when empty - see the null-options trap */
            'myapp_feature_limit' => array(
                'Number',
                array(),
                NULL,
                Member::loggedIn()->language()->addToStack( 'per_day' )
            ),
        );
    }

    public function preSave( array &$values ): void
    {
        if ( $values['mod_use_restrictions'] != 'no' and !$values['myapp_can_approve'] )
        {
            $values['myapp_can_feature'] = FALSE;
        }
    }

    public function onChange( array $moderator, array|string $changed ): void
    {
        if ( $changed === '*' or ( is_array( $changed ) and array_key_exists( 'myapp_can_approve', $changed ) ) )
        {
            unset( Store::i()->myapp_approvers );
        }
    }

    public function onDelete( array $moderator ): void
    {
        unset( Store::i()->myapp_approvers );
    }
}

applications/myapp/dev/lang.php — four keys are mandatory, one per field plus the tab:

'modperms__myapp_Perms'      => "My App",
'myapp_can_approve'          => "Can approve submissions?",
'myapp_can_approve_desc'     => "Optional. Rendered under the field.",
'myapp_can_feature'          => "Can feature submissions?",
'myapp_feature_limit'        => "Features allowed per day",

Checking it at runtime:

if ( Member::loggedIn()->modPermission( 'myapp_can_approve' ) )
{
    ...
}

The tab is labelled "modperms__myapp_Perms" and every checkbox is labelled with its own key

The ACP shows the literal strings modperms__myapp_Perms, myapp_can_approve and so on instead of readable text.

Three separate language keys are derived from names you never explicitly write down. The tab key is 'modperms__' . $k where $k is the allExtensions() key, i.e. <app directory>_<extension name from extensions.json> — so an extension registered as "Perms" in app myapp needs modperms__myapp_Perms. The field label is the permission key itself, because FormAbstract::rowHtml() falls back to addToStack( $this->name ). The optional description is <key>_desc and is requested with returnBlank, so it is safe to omit.

When a key is missing, Lang::replaceWords() hits its else branch and substitutes $values['key'] — the raw key — rather than throwing. That is why the failure is silent and only visible on screen.

Two things that are not language keys: the prefix and suffix entries at indexes [2] and [3]. They are handed straight to the row template as literal output. Core's Warnings extension therefore writes Member::loggedIn()->language()->addToStack('per_day') for its suffix rather than 'per_day'.

The whole Moderator Permissions screen dies with an error from an app that isn't yours

An admin opens one moderator for editing and gets a fatal error or a blank page mentioning a class in a completely unrelated application.

This error surfaces somewhere other than where it was caused. The edit form is a single shared screen built from every installed application's extension in one uninterrupted loop:

foreach( $extensions as $k => $ext )
{
    $form->addTab( 'modperms__' . $k );
    foreach ( $ext->getPermissions( $toggles ) as $name => $data )
    ...

There is no try/catch anywhere in that loop, and no per-extension isolation. Any exception, TypeError or SQL failure thrown from any app's getPermissions() takes down the entire moderator editor for the whole suite, and the traceback names the offending app, not the moderator controller.

This is not hypothetical: core's own IPS\forums\extensions\core\ModeratorPermissions\Topic::getContentPermissions() runs three raw counts against forums_forums and forums_topic_mmod every time the form is drawn. Keep getPermissions() cheap and defensive — wrap your own queries, and never let a missing table or setting escape:

public function getPermissions( array $toggles ): array
{
    $return = array( 'myapp_can_approve' => 'YesNo' );

    try
    {
        if ( Db::i()->select( 'COUNT(*)', 'myapp_queues' )->first() )
        {
            $return['myapp_can_flush_queue'] = 'YesNo';
        }
    }
    catch ( Exception $e ) {}

    return $return;
}

Your tab silently never appears, and nothing is logged

The extension file exists, it is in extensions.json, and there is no error — the tab is simply absent.

There are two distinct causes, both silent by design.

First, an empty return. Form::addTab() writes to $form->tabs, but the moderator template iterates $form->elements (passed in as $tabs by customTemplate()), and elements[$tab] is only created by _insert() when a field is actually added. So returning array() from getPermissions() produces no tab at all. This is deliberate in core: blog\Blogs, downloads\Files and forums\Topic all return [] so their keys land on the Content tab instead. But if all your keys are behind a settings check (Settings::i()->clubs, as in core's Clubs extension), your tab vanishes when the setting is off.

Second, a throwing constructor. Application::constructExtensionClass() does:

try
{
    $obj = new $classToUse( ... );
    ...
    return $obj;
}
catch( RuntimeException | OutOfRangeException $e ){}

return null;

A RuntimeException or OutOfRangeException thrown from your __construct() makes the object null, and the caller just skips it. No log entry, no error, no tab. The same happens if class_exists() is false, which catches namespace typos and stale extensions.json entries.

Note also that this extension is constructed with $checkAccess = FALSE, which means your constructor is called with a single argument of NULL. If you declare __construct( Member $member ) without a nullable type you get a TypeError, which is not in the catch list and will fatal the whole screen.

"Argument #4 ($options) must be of type array, null given"

A fatal TypeError pointing at IPS\Helpers\Form\FormAbstract::__construct(), usually preceded by Warning: Undefined array key 1.

You used the array form of a field but only supplied the type:

/* WRONG */
return array( 'myapp_can_approve' => array( 'YesNo' ) );

The controller does $options = is_array( $data ) ? $data[1] : array(); with no isset() guard. Index 1 is missing, $options becomes NULL, and FormAbstract::__construct() declares array $options=array(), so PHP refuses it. Either use the plain string form, or always supply index 1:

/* Both fine */
return array(
    'myapp_can_approve'   => 'YesNo',
    'myapp_feature_limit' => array( 'Number', array() ),
);

Existing moderators are denied a permission you just added

You ship an update with a new key. Admins see the new checkbox and it is off, and modPermission() returns FALSE for every existing restricted moderator until somebody re-saves each one by hand.

Permissions are stored as a snapshot, not a schema. edit() writes json_encode( $values ) of whatever fields were on the form at that moment, and Member::modPermission() ends with:

return $permissions[$key] ?? false;

An unknown key is false. There is no upgrade hook and no default. The one exception is a moderator whose perms column is the literal string *: modPermission() short-circuits with if ( $permissions === '*' or $key === NULL ) return TRUE;, so unrestricted moderators get every future permission automatically.

The symptom appears in the front end, weeks after the install, with nothing in the moderator record to explain it. If your permission must default to granted for existing restricted moderators, write an upgrade step that decodes core_moderators.perms, inserts your key, re-encodes it, and then unset( Store::i()->moderators ).

A field comes up pre-filled with the previous field's value, or "Undefined variable $currentValue"

Only when the moderator is set to "All permissions". A CheckboxSet, Select, Text or any other type renders with a nonsense default, and in a dev install PHP emits Warning: Undefined variable $currentValue.

The controller computes the "everything is allowed" default with an unguarded switch that has no default branch and never initialises the variable:

if ( $currentPermissions === '*' )
{
    switch ( $type )
    {
        case 'YesNo':  $currentValue = TRUE;  break;
        case 'Number': $currentValue = -1;    break;
        case 'Node':   $currentValue = 0;     break;
    }
}

$currentValue lives in the enclosing foreach scope, so for any other type it silently keeps the value left over from the previous field, or is undefined if yours is the first. Core walks into this itself: core\Content::getPermissions() declares can_recognize_content_options => 'CheckboxSet' directly after a YesNo, so on an unrestricted moderator that checkbox set is constructed with a default value of TRUE.

Restrict yourself to YesNo, Number and Node. Note also that for Number the controller force-injects $options['unlimited'] = -1 after your options, so you cannot choose your own unlimited sentinel.

onContentChange() is never called

You implement onContentChange(), the admin saves the form, and your code never runs. No error.

It is dead code in IC 5.0.19. The only place that could call it is core\ContentGenerator, and its onChange() dispatches to the wrong method:

/* ContentGenerator::onChange() */
foreach ( Application::load( $class::$application )->extensions( 'core', 'ModeratorPermissions' ) as $ext )
{
    $ext->onChange( $moderator, $changed );   /* not onContentChange() */
}

/* ContentGenerator::onDelete() */
foreach ( Application::load( $class::$application )->extensions( 'core', 'ModeratorPermissions' ) as $ext )
{
    $ext->onContentDelete( $moderator );      /* this one IS the content variant */
}

Core is affected by its own bug: forums\Topic::onContentChange() exists to rebuild the search index when can_read_all_topics changes, and it never fires. Put your logic in onChange() instead — content permission keys arrive there too, because they are merged into the same flat $values array. onContentDelete() does work and can be used as documented.

onChange() throws a TypeError, or $changed contains an IPS\Member object

Either TypeError: ...onChange(): Argument #2 ($changed) must be of type array, string given, or your code reads $changed and finds keys named moderators_type, moderators_group and moderators_member that no permission extension ever declared.

onChange() is called from two controller methods with three entirely different payloads.

Caller$moderator$changed
edit(), admin picked "Restricted"the pre-save DB rowarray of only the keys whose value differs from the stored JSON
edit(), admin picked "All permissions"the pre-save DB rowthe string '*'
add(), moderator first createdthe row just inserted, perms is '*'the add form's values: moderators_type, moderators_group, and moderators_member as an IPS\Member object

The abstract's signature is array|string $changed, but the older docblocks scattered through core (including forums\Topic) still say @param array $changed. Copy the signature, not the docblock, and guard both shapes:

public function onChange( array $moderator, array|string $changed ): void
{
    if ( $changed === '*' )
    {
        /* switched to unrestricted - assume everything changed */
    }
    elseif ( is_array( $changed ) and array_key_exists( 'myapp_can_approve', $changed ) )
    {
        ...
    }
}

Also note that edit() skips the onChange loop entirely when the moderator was already unrestricted and stays unrestricted (if( !( $currentPermissions == '*' AND $changed == '*' ) )), so a no-op save on an unrestricted moderator fires nothing.

preSave() edits are silently discarded

You modify $values inside preSave(), and sometimes the change sticks and sometimes it does not.

The abstract declares preSave( array &$values ) and the controller calls it by reference, so the direct call works. But core\ContentGenerator — which extends IPS\Content\ExtensionGenerator, not the abstract, so PHP never checks the signature — re-dispatches with the reference dropped:

/* ContentGenerator::preSave() */
public function preSave( array $changed ): void   /* no ampersand */
{
    foreach ( Application::load( $class::$application )->extensions( 'core', 'ModeratorPermissions' ) as $ext )
    {
        $ext->preSave( $changed );
    }
}

Your preSave() therefore runs once effectively (from the controller) and once per content item class in your app with the result thrown away. Make preSave() idempotent and free of side effects other than rewriting $values.

One more detail: $values['mod_use_restrictions'] is still set during preSave() and only unset afterwards, which is what core's Warnings::preSave() relies on. Everything that survives preSave() is JSON-encoded into perms, including mod_show_badge, so modPermission( 'mod_show_badge' ) is a real (if unintended) lookup.

onChange() and onDelete() run more than once per save

Your cache-clear or queue-task in onChange() executes two or three times for a single click of Save.

Same root cause as above. The controller loops over all extensions, which includes both your own myapp_Perms entry and one core_Content_<YourItemClass> entry generated by ContentGenerator::generate() for every routed content item class in the suite. Each of those generated instances then calls back into your app's extensions. For an app with two content item classes, onChange() fires three times: once directly, twice via the generator.

Never queue a task or write a log row unconditionally from onChange(). Guard on the keys you care about, and prefer idempotent operations such as unset( Store::i()->yourKey ).

Two apps define the same permission key, and the field appears twice

The same checkbox is rendered on two different tabs, and toggling one has no visible effect on the other.

Permission keys are not namespaced. The only thing that carries the application key is the tab language key, modperms__<app>_<Extension>. The field name, the language key, the toggle id and the JSON key in core_moderators.perms are all the bare string you returned. Form::_insert() stores elements as $this->elements[ $tab ][ $elementKey ], so an identical key in two tabs is not deduplicated — both are rendered, both read the same POST field, and both write the same single JSON entry.

Core's own keys are unprefixed and generic — can_edit_tags, can_manage_alerts, can_manage_sidebar, can_see_emails, can_mod_blogs, can_use_saved_actions — so anything obvious is likely already taken. Prefix every third-party key with your application directory (myapp_can_approve). The same applies to keys returned from getContentPermissions(), which land in the same flat namespace.

getContentPermissions() is never called for your app

You implement getContentPermissions() expecting your keys to appear on a content tab, and nothing appears.

It is not reached from the moderator controller at all. The only caller is core\ContentGenerator::getPermissions(), at the very end of the method, and it is gated three ways:

  1. Your application must own at least one content item class registered through a core/ContentRouter extension. ContentGenerator sets protected static bool $contentItemsOnly = TRUE;, so comment and review classes do not generate a tab.
  2. That class must have $canBeModeratedFromFrontend = TRUE. ContentGenerator::getPermissions() returns array() before it ever reaches the getContentPermissions() loop otherwise. IPS\cms\Pages\PageItem and IPS\nexus\Package\Item both set it to FALSE.
  3. It looks up extensions with Application::load( $class::$application )->extensions( 'core', 'ModeratorPermissions' ), so the extension must be registered in the extensions.json of the app that owns the content class.

If your app has more than one qualifying item class, getContentPermissions() is called once per class and the same keys are added to each of those tabs — producing the duplicate-field behaviour described above. In that situation, return the keys from getPermissions() and accept your own tab instead.

A Node permission is stored as -1 and modPermission() returns TRUE, not an array

You save a Node field with the "all" checkbox ticked, then read it back and find -1 rather than 0, or TRUE rather than a list of ids.

The controller records which fields were of type Node, and on save rewrites a submitted 0 (the zeroVal "all" checkbox) to -1, with the comment "so mod permissions can merge properly". On the next render it translates -1 back to 0. The -1 is what makes Member::modPermissions() group merging treat "all" as beating any explicit list of node ids.

At runtime a node permission has three possible shapes, and every core consumer tests all three:

  • TRUE — the moderator's perms is '*', so modPermission() short-circuits before it looks at your key at all
  • -1 — restricted, but explicitly granted all nodes
  • an array of node ids — restricted to those containers
$allowed = $member->modPermission( $containerClass::$modPerm );

if ( $allowed === TRUE or $allowed === -1 )
{
    /* everything */
}
elseif ( is_array( $allowed ) and in_array( $container->_id, $allowed ) )
{
    /* this container only */
}

Checking only is_array() is the usual mistake and it locks out every unrestricted moderator.

A new extension does not show up until the datastore is cleared, and the tab order is not what you asked for

You add the extension to extensions.json by hand and it does not appear; or your tab lands in a position you did not expect.

Application::allExtensions() caches the resolved class list in Store::i()->extensions, keyed by extension name only. Installing or uninstalling an application through the ACP calls Store::i()->clearAll(), so a normal install is fine, but editing data/extensions.json directly in a dev install is not — the Developer Center's extension controller is what calls unset( Store::i()->extensions ). Clear the datastore after a manual edit.

The same cache is why ordering is unreliable. allExtensions() accepts a $firstApp argument, and the moderator controller passes 'core':

foreach ( Application::allExtensions( 'core', 'ModeratorPermissions', FALSE, 'core' ) as $k => $ext )

but that sort only happens on the branch that builds the cache, and by that point in edit() the cache has already been built by an earlier call on line 330 that passed no $firstApp. The argument is effectively a no-op here — which is exactly why the very next lines re-sort core_General to the front in PHP:

if ( isset( $extensions['core_General'] ) )
{
    $meFirst = array( 'core_General' => $extensions['core_General'] );
    unset( $extensions['core_General'] );
    $extensions = $meFirst + $extensions;
}

There is no supported way for a third-party extension to control its tab position. Everything after the General tab follows application load order.

Verified against

Invision Community 5.0.19, read from source: system/Extensions/ModeratorPermissionsAbstract.php, applications/core/modules/admin/staff/moderators.php, system/Application/Application.php (allExtensions(), extensions(), constructExtensionClass()), system/Member/Member.php (modPermissions(), modPermission()), system/Content/ExtensionGenerator.php, system/Helpers/Form/Form.php, applications/core/dev/html/admin/members/moderatorPermissions.phtml, and every implementation present under applications/*/extensions/core/ModeratorPermissions/ (core: Clubs, Content, ContentGenerator, General, Members, Warnings; forums: Topic; blog: Blogs; cms: Databases; downloads: Files).


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.