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

A core/Statistics extension is one graph on the ACP Statistics tab. It is the thing an administrator sees when they open Statistics → Registrations or Statistics → Posts: a chart with a timescale selector, a date range, optional filter tabs, a data table underneath, and a "save this chart" action. Saving is the part that makes the extension worth writing rather than just building a chart inline in your controller. A saved chart is pinned to Statistics → Key Statistics → Saved Reports (app=core&module=overview&controller=mycharts, restriction mycharts_managemenu__core_keystats is "Key Statistics" and menu__core_keystats_mycharts is "Saved Reports") and can be added to a Report, where it is re-rendered alongside charts from other applications and exported to CSV. Core cannot re-render a saved chart from a database row on its own; it needs a class it can find again and ask for the chart definition a second time. That class is your extension.

Note that this extension point does not put anything in the ACP menu. Nothing scans for core/Statistics extensions and builds a page out of them. You still have to write an ACP module controller and register it in your app's acpmenu.json; the extension is what that controller calls, and what the saved-chart machinery calls later.

The contract

The base class is not under system/Extensions/. It is IPS\core\Statistics\Chart, in applications/core/sources/Statistics/Chart.php. (system/Extensions/OverviewStatisticsAbstract.php is a different extension point — core/OverviewStatistics — which produces the small text blocks on the same screens. The two are unrelated apart from sharing some static helpers on this class.)

There are two properties and exactly one abstract method:

abstract class Chart
{
    /* The key written into core_saved_charts.chart_controller when an admin
       saves this chart, and the key used to find this extension again when
       the saved chart is rendered. Note the default is the four-character
       STRING "NULL", not null. */
    public ?string $controller = 'NULL';

    /* When true the chart cannot have per-admin filter tabs, and the save form
       forces "show to all members" on. Core's own docblock says such a chart
       "can only be saved to reports"; the code does not enforce that. */
    public bool $noTabs = false;

    /* You MUST implement this. $url is the URL the chart is being rendered
       on; the chart helper appends its own query strings to it. */
    abstract public function getChart( Url $url ): \IPS\Helpers\Chart;
}

That is the whole contract. $controller is at line 55, $noTabs at line 62, getChart() at line 71.

The class also carries four static helpers you call rather than implement:

public static function loadFromExtension( string $app, string $extension ): static   // line 80
public static function loadFromController( string $controller ): static             // line 98
public static function constructMemberChartFromData( array|int $data, Url $url, Member|bool $check = TRUE ): \IPS\Helpers\Chart   // line 122
public static function getChartsForMember( Url $url, bool $idsOnly = FALSE, ?Member $member = NULL ): array                        // line 220

loadFromExtension() is the one you use from your own controller. $extension is the key from data/extensions.json, not the class name and not prefixed with the app directory.

The remaining statics on the same file are a mixed bag, and only two of them are really about the core/OverviewStatistics blocks. getSavedBlocks() (line 281) iterates Application::allExtensions( 'core', 'OverviewStatistics', TRUE ) at line 296, and getAppsToFilterBy() (line 403) is built on top of it; those two have nothing to do with charts. getBlockDateRangeForm() (line 330) and getChartDateRanges() (line 427) are shared by both — the saved-reports screen renders one date-range form for the whole page, and getChartDateRanges() is what constructMemberChartFromData() calls at line 197 to override every saved chart's start and end. They are all on this class because the saved-reports screen renders both kinds of thing.

What getChart() must return

The declared return type is \IPS\Helpers\Chart, which is a plain concrete class (system/Helpers/Chart/Chart.php, line 58). In practice you must return one of its dynamic subclasses:

  • IPS\Helpers\Chart\Database — one table, one date column, series built from SQL aggregates. This is what almost every core implementation uses.
  • IPS\Helpers\Chart\Callback — you supply a callable that returns the rows. Used by applications/core/extensions/core/Statistics/OnlineUsers.php line 77 when the data cannot be expressed as a single query.

Both extend the abstract IPS\Helpers\Chart\Dynamic. That matters, because setExtension() is declared on Dynamic (system/Helpers/Chart/Dynamic.php, line 793), not on Chart. Almost every shipped implementation calls it immediately after constructing the chart:

$chart = new Database( $url, 'core_members', 'joined', '', array( ... ) );
$chart->setExtension( $this );

Three of the 41 shipped implementations do not call it — applications/core/extensions/core/Statistics/Language.php, RankProgression.php and Theme.php. Those charts therefore fall through to the request-derived controller string described below, which is a real bug in two of the three cases. Call it.

The only thing setExtension() does is store the object and reset the cached tab list. But that stored object is what Dynamic::getController() reads (line 705):

public function getController() : string
{
    if ( is_string( $this->extension?->controller ) )
    {
        return $this->extension->controller;
    }

    return Request::i()->app . '_' . Request::i()->module . '_' . Request::i()->controller . ( Request::i()->tab ? '_' . Request::i()->tab : '' );
}

and the return value of getController() is written to core_saved_charts.chart_controller when the admin saves the chart (line 620), and used to look the extension up again afterwards.

Who calls it

There are only two entry points into the extension, and they are reached in very different ways. (Two APIs, not two lines of code: loadFromExtension() is called from about forty places in the shipped apps, loadFromController() from exactly one.)

Your own ACP controller, via loadFromExtension(). This is a straight one-liner. applications/core/modules/admin/stats/registrationstats.php line 55:

Output::i()->output = Chart::loadFromExtension( 'core', 'Registrations' )->getChart( Url::internal( 'app=core&module=stats&controller=registrationstats' ) );

Other examples: applications/forums/modules/admin/stats/topics.php lines 84–85, applications/forums/modules/admin/stats/posts.php lines 201 and 205, applications/forums/modules/admin/stats/solved.php lines 109–113, applications/calendar/modules/admin/stats/rsvp.php line 53. It is not only ACP stats controllers: applications/core/extensions/core/Dashboard/Registrations.php line 51 uses the same call to draw the ACP dashboard block, so a core/Statistics extension can be reused anywhere you can pass it a URL. Note that loadFromExtension() goes through Application::load( $app )->extensions( 'core', 'Statistics' ), whose $checkAccess parameter defaults to FALSE (system/Application/Application.php line 911), so your class is constructed with NULL and no application access check is applied.

The saved-chart machinery, via loadFromController(). constructMemberChartFromData() reads a core_saved_charts row and then, at line 161:

$extension = static::loadFromController( $data['chart_controller'] );

loadFromController() walks every registered extension and returns the first whose $controller matches:

foreach( Application::allExtensions( 'core', 'Statistics', FALSE ) AS $extension )
{
    if ( $extension->controller AND $extension->controller === $controller )
    {
        return $extension;
    }
}

throw new OutOfRangeException;

It then calls getChart( $url ) again (line 183), overwrites title, and forces showFilterTabs, showIntervals and showDateRange to FALSE (lines 192–195), because the Key Statistics screen supplies one shared date range for every chart on the page. Whatever you set for those three properties in getChart() is discarded in that context.

This path is reached from applications/core/modules/admin/overview/mycharts.php in two places: line 295 (do=getChart, one chart at a time) and line 624 (via getChartsForMember(), for the CSV export). The grid itself at line 225 calls getChartsForMember( ..., TRUE ) with $idsOnly set, which returns bare IDs and never touches loadFromController() — every tile in the grid is then lazy-loaded over AJAX through line 295. That is why a broken chart shows up as an empty tile rather than as a missing tile.

Failure mode: the extension file exists but nothing appears

Registering the class file is not enough. Application::extensions() reads applications/<app>/data/extensions.json (line 917) and never scans the extensions/ directory. Use the Developer Center rather than hand-editing the JSON. This much is generic to every IPS 5 extension point and is covered in more detail in the ContentRouter article.

What is worth knowing here is that the two entry points cache differently, which produces a confusing half-working state. Application::extensions() re-reads the JSON on every request and caches only in a per-request static (static::$_loadedExtensions), so loadFromExtension() — your own page — picks up a new extension immediately. Application::allExtensions() caches the resolved list of all apps' extensions in the datastore under the key extensions and rebuilds only when that key is missing, so loadFromController() — the saved-chart path — keeps using the stale list. The symptom is a chart that renders perfectly on its own ACP page while saved copies of it vanish from Saved Reports. Clear caches from ACP → Support.

What is specific here is how you find out. loadFromExtension() throws a bare OutOfRangeException (line 88) when the key is not in the list. Nothing catches it, so you get an ACP error page pointing at your own controller — noisy, but at least it is obvious. loadFromController() throws the same exception from line 108, and there it is swallowed: getChartsForMember() catches OutOfRangeException and does continue (lines 265–268). So a saved chart whose extension can no longer be resolved simply disappears from Key Statistics with no error and no log entry, while its row stays in core_saved_charts forever.

Failure mode: fatal error when any statistics screen is opened

If you do not declare getChart(), PHP refuses to load the class:

Fatal error: Class IPS\acme\extensions\core\Statistics\Tickets contains 1 abstract
method and must therefore be declared abstract or implement the remaining methods
(IPS\core\Statistics\Chart::getChart)

This is a compile-time error raised by the autoloader inside the class_exists() call in Application::extensions() (line 926). It is a genuine PHP fatal, not an Error object, so none of the catch blocks discussed below can absorb it.

The blast radius is narrower than you might expect, and it is worth being precise about it. loadFromExtension( 'acme', 'Tickets' ) resolves to Application::load( 'acme' )->extensions( ... ), which reads only applications/acme/data/extensions.json — so a broken class of yours kills your own stats page and leaves core's alone. loadFromController() goes through allExtensions(), which walks every enabled app, so it also kills Statistics → Key Statistics → Saved Reports and the CSV export for everybody. Core's own Statistics → Registrations and the rest keep working.

The Developer Center skeleton (applications/core/data/defaults/extensions/Statistics.txt) generates getChart() with an empty body and a ChartClass return type. That compiles, but returns null, which is a TypeError at runtime. Fill the body in before you wire the extension to a controller.

Failure mode: the chart is blank on Key Statistics but fine on its own page

This is the one that costs the most time. mycharts::getChart() wraps the whole render in catch( Throwable ) (lines 290–310; the catch itself is line 300):

try
{
    $report = $this->_getReport();
    Chart::getBlockDateRangeForm( $report ? $report['id'] : null );
    $chart = Chart::constructMemberChartFromData( ... );
    $chart->options['height'] = '300px';
    Output::i()->output = (string) $chart;
}
catch( Throwable )
{
    if ( Request::i()->isAjax() )
    {
        Output::i()->output = '';
    }
    else
    {
        Output::i()->redirect( Url::internal( "app=core&module=overview&controller=mycharts" ) );
    }
}

Throwable catches Error as well as Exception, so a TypeError, an undefined method, a SQL error or a missing language class inside your getChart() all produce the same thing: an empty AJAX response, an empty tile in the grid, and nothing in the system log. The same code on your own ACP controller is not wrapped, so the identical bug throws a visible ACP error there. If a chart works on Statistics → Your Page and renders as an empty box on Key Statistics, reproduce it by requesting app=core&module=overview&controller=mycharts&do=getChart&chartId=<id> directly and temporarily commenting out the catch.

Two things differ between the two contexts and are the usual culprits. First, Request::i()->app, module and controller are core/overview/mycharts, not yours — anything in getChart() that reads the request to decide what to draw will behave differently. applications/nexus/extensions/core/Statistics/Income.php line 72 does exactly this (Request::i()->tab == 'members' ? 'PieChart' : 'AreaChart') and therefore silently falls back to the area chart when re-rendered. Second, the date range comes from getChartDateRanges() and is assigned to $chart->start and $chart->end after your method returns, so any date defaults you set are overridden.

Failure mode: a saved chart renders somebody else's data

This is the trap that is unique to this extension point, and it is caused by the base class default.

public ?string $controller = 'NULL';

That is the literal four-character string NULL, not the null value. If you never override $controller but do call setExtension(), Dynamic::getController() sees is_string( 'NULL' ) === true and returns 'NULL'. Every chart the admin saves from your extension is stored with chart_controller = 'NULL'. So is every chart saved from any other third-party extension that made the same omission. loadFromController() returns the first match in allExtensions() order, so from then on all of those saved charts render whichever extension happens to sort first — with the saved title still attached, because the title is applied afterwards at line 192. The admin sees a chart labelled "New registrations" showing sales figures, and there is no error anywhere.

Core demonstrates the first-match-wins behaviour with duplicates of its own. applications/core/extensions/core/Statistics/Registration.php and Registrations.php are byte-identical apart from the class name, and both declare $controller = 'core_stats_registrationstats'. Only Registrations is ever loaded by registrationstats.php, but because Registration sorts first in applications/core/data/extensions.json, saved charts resolve to the unused duplicate. Here it makes no difference because the two produce the same chart. Reactions and ReactionsApp also share core_activitystats_reactions_type, and Language and Theme share core_stats_preferences_theme; those pairs do not produce the same chart, and Reactions and Language respectively sort first.

The Language/Theme pair is worth reading in full, because it is the whole failure mode in one place. Neither extension calls setExtension(), so getController() ignores their declared $controller and builds the string from the request instead. Both are rendered by applications/core/modules/admin/stats/preferences.php (lines 73 and 78) under tab=theme and tab=lang. So a chart saved from the Themes tab is stored as core_stats_preferences_theme, which loadFromController() resolves to Language — the wrong chart, under the saved title. A chart saved from the Languages tab is stored as core_stats_preferences_lang, which matches no extension at all and is silently dropped. Language.php line 38 declaring $controller = 'core_stats_preferences_theme' looks like a copy-paste slip; either way, the resolution rule is not in doubt.

Always set $controller, and make it globally unique. Core's convention is <app>_<module>_<controller>[_<tab>] matching the URL the chart lives on — forums_stats_posts_byforum for app=forums&module=stats&controller=posts&tab=byforum — which is the same string getController() would have built from the request. Following it means saved charts resolve identically whether or not setExtension() was called.

Failure mode: charts save but never come back

The opposite mistake. The Developer Center skeleton ships:

public ?string $controller = NULL;

with a real null. Now is_string( null ) is false, so getController() falls back to the request-derived string and the row is saved with, say, acme_stats_tickets. When Key Statistics later calls loadFromController( 'acme_stats_tickets' ), the guard is:

if ( $extension->controller AND $extension->controller === $controller )

null is falsy, so your extension is skipped, no other extension matches, OutOfRangeException is thrown, and getChartsForMember() quietly drops the row. The admin clicks "save", gets a success response, and the chart never appears on Key Statistics. Nothing is logged. The same happens if you forget setExtension() and your $controller string does not exactly equal the app/module/controller/tab string for the page — the row is saved under one key and looked up under another.

Failure mode: the chart is missing from the CSV export

The Reports CSV builder in mycharts::_compileCharts() skips silently in two cases (lines 626–638):

$chart = $chartData['chart'];
if ( !( $chart instanceof Dynamic ) )
{
    continue;
}

...

// Cannot add pie charts
if ( $chart->type === 'PieChart' or $chart->type === 'GeoChart' )
{
    continue; // todo this should probably be indicated to the user if the chart cannot be included in the CSV
}

Returning a bare IPS\Helpers\Chart rather than a Database or Callback therefore excludes you from CSV export — and, more importantly, a bare Chart has no setExtension() at all, so calling it is a fatal "call to undefined method". If your chart's resolved type is PieChart or GeoChart it is dropped from the CSV too; the todo comment in core confirms this is not surfaced to the admin.

Failure mode: TypeError from a constructor you added

IPS\core\Statistics\Chart declares no constructor, but Application::constructExtensionClass() instantiates every extension with one argument (system/Application/Application.php, line 469 onwards) — NULL in both call paths here, since neither passes a member. PHP tolerates the extra argument on a class with no constructor. If you add a constructor with a required, non-nullable parameter you get a TypeError, and the surrounding catch( RuntimeException | OutOfRangeException $e ){} at line 497 does not catch Error subclasses. Declare it as public function __construct( $member = NULL ) or not at all.

The $url argument, and why it matters

getChart() is handed the URL the chart is being rendered on, and the chart helper appends its own query strings to that URL for the timescale, type, date range and filter controls. Passing a different URL breaks those controls when the chart is rendered anywhere other than its own page. applications/gallery/extensions/core/Statistics/Bandwidth.php line 50 ignores the argument and hard-codes Url::internal( "app=gallery&module=stats&controller=bandwidth" ); the effect of that on the Key Statistics grid is not something we have measured, so treat it as a pattern to avoid rather than to copy. Use $url.

A minimal, complete example

This is applications/core/extensions/core/Statistics/Registration.php verbatim, minus the licence header and with one over-long inline comment trimmed. It is the shortest complete implementation in the suite and shows every part of the contract.

<?php

namespace IPS\core\extensions\core\Statistics;

use IPS\Helpers\Chart;
use IPS\Helpers\Chart\Database;
use IPS\Http\Url;
use IPS\Member;
use function defined;

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

class Registration extends \IPS\core\Statistics\Chart
{
    /**
     * @brief   Controller
     */
    public ?string $controller = 'core_stats_registrationstats';

    /**
     * Render Chart
     *
     * @param   Url $url    URL the chart is being shown on.
     * @return Chart
     */
    public function getChart( Url $url ): Chart
    {
        $chart  = new Database( $url, 'core_members', 'joined', '', array(
            'isStacked' => FALSE,
            'backgroundColor'   => '#ffffff',
            'colors'            => array( '#10967e' ),
            'hAxis'             => array( 'gridlines' => array( 'color' => '#f5f5f5' ) ),
            'lineWidth'         => 1,
            'areaOpacity'       => 0.4
         ) );
        $chart->addSeries( Member::loggedIn()->language()->addToStack('stats_new_registrations'), 'number', 'COUNT(*)', FALSE );
        $chart->title = Member::loggedIn()->language()->addToStack('stats_registrations_title');
        $chart->availableTypes = array( 'AreaChart', 'ColumnChart', 'BarChart' );
        $chart->setExtension( $this );

        /* fetch only successful registered members */
        $chart->where[] = array( 'completed=?', true );

        return $chart;
    }
}

The Database constructor signature (system/Helpers/Chart/Database.php, line 117) is:

public function __construct( Url $url, string $table, string $dateField, string $title='', array $options=array(), string $defaultType='AreaChart', string $defaultTimescale='monthly', array $defaultTimes=array( 'start' => 0, 'end' => 0 ), array $tableInclude=array(), string $identifier='', DateTime $minimumDate=null )

Give $identifier a distinct value if you render more than one chart on the same page; it is folded into the query-string keys for that chart's controls.

The matching entry in applications/acme/data/extensions.json:

{
    "core": {
        "Statistics": {
            "Tickets": "IPS\\acme\\extensions\\core\\Statistics\\Tickets"
        }
    }
}

and the ACP controller that renders it:

protected function manage() : void
{
    Dispatcher::i()->checkAcpPermission( 'tickets_manage', 'acme', 'stats' );

    Output::i()->title = Member::loggedIn()->language()->addToStack( 'menu__acme_stats_tickets' );
    Output::i()->output = Chart::loadFromExtension( 'acme', 'Tickets' )->getChart( Url::internal( 'app=acme&module=stats&controller=tickets' ) );
}

With $controller = 'acme_stats_tickets' on the extension, the string core derives from that URL and the string you declared agree, and saved charts resolve correctly whichever route they came in by.

Custom filters

If your chart needs its own filter form, set $chart->customFiltersForm to an array with form, where, groupBy, series and defaultSeries keys. applications/core/extensions/core/Statistics/ModeratorsByAction.php lines 103–171 is the reference implementation. The saved values come back to you as $chart->savedCustomFilters. It is filled in Dynamic::__construct() from two sources: any customform_* request parameters (line 424), and the chart_configuration JSON of a saved chart being viewed as a tab (line 406). When the chart is re-rendered from Saved Reports, the row's configuration gets into the constructor by a documented hack — constructMemberChartFromData() stashes it on Request::i()->storedFilters (line 180) and Database::__construct() line 129 moves it into Request::i()->filters. That transfer exists only in Database, not in Callback, so a Callback chart with custom filters will not see saved values in time to seed its form. Because the read happens after the chart object is constructed, the pattern in core is to construct the chart, call setExtension(), then read $chart->savedCustomFilters to seed the form defaults (line 88):

$customActionValues = ( isset( $chart->savedCustomFilters['chart_actions'] ) ) ? array_values( explode( ',', $chart->savedCustomFilters['chart_actions'] ) ) : -1;

Values arrive as comma-joined strings when they came from a saved row and as arrays when they came from the live form, which is why core's where callbacks all begin with an is_array() test.

$noTabs

No shipped extension sets $noTabs; every implementation in applications/*/extensions/core/Statistics/ leaves the inherited false. When it is true, Dynamic::loadAvailableChartTabs() short-circuits to an empty array (test at line 758, assignment at line 760) and the "show to all members" toggle on the save form is defaulted on and disabled (lines 558–559), with the submitted value forced to true again at line 613 so a tampered POST cannot get round it. The saved row therefore always has chart_member = 0.

Core's docblock on the property says such a chart "can only be saved to reports". Nothing in Dynamic.php implements that: statsreports_include_in_report is a separate, independent toggle, and a $noTabs chart saved without it lands with chart_report_id = NULL and shows up on Saved Reports for every admin. Read $noTabs as "no per-admin tabs, always shared", not as "reports only". Use it for charts that are meaningless per-admin.

Verified against

Read from Invision Community 5.0.19 source (applications/core/data/versions.json long version 5001908): applications/core/sources/Statistics/Chart.php, system/Helpers/Chart/Chart.php, system/Helpers/Chart/Dynamic.php, system/Helpers/Chart/Database.php, system/Helpers/Chart/Callback.php, system/Application/Application.php, system/Extensions/OverviewStatisticsAbstract.php, applications/core/modules/admin/overview/mycharts.php, applications/core/modules/admin/stats/registrationstats.php, applications/core/modules/admin/stats/preferences.php, applications/core/data/defaults/extensions/Statistics.txt, applications/core/data/extensions.json, applications/core/data/acpmenu.json, applications/core/data/acprestrictions.json, applications/core/dev/lang/stats.php, all 27 implementations in applications/core/extensions/core/Statistics/, and the implementations in applications/forums, applications/calendar, applications/downloads, applications/gallery and applications/nexus. Line numbers are from that source tree. Nothing here is carried over from IPS 4.x.



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.