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.

Six ways an Invision Community 5 application fails silently

These six were all found in a single application, over a single week, and they have one thing in common: none of them produces an error that points at the mistake. Four produce no error at all. One produces a blank 500 whose stack trace blames the wrong line. One leaves a page that still looks styled while a rule you wrote is quietly missing.

They are ordered by how hard they are to notice, hardest first. If you are auditing an existing application rather than reading straight through, the last section has two scripts that find the first two mechanically.

1. A <style> tag inside Output::headCss eats exactly one CSS rule

This is the worst of the six, because the page still looks styled. Nothing is blank, nothing errors, and the browser console says nothing. One rule — always the first one in the block — simply does not apply.

The symptom

A page that should be a grid renders as a tall stack of full-width blocks. The rest of the styling — colours, spacing, borders, typography — is all correct, which is exactly what makes the stylesheet the last thing anybody suspects. Inspecting the element shows your class present on the element and the rule absent from the cascade, as though you had never written it.

Why it happens

Core already supplies the element. In applications/core/dev/html/global/global/includeCSS.phtml:

{{if \IPS\Output::i()->headCss}}
    <style id="headCSS">
        {expression="\IPS\Output::i()->headCss" raw="true"}
    </style>
{{endif}}

So if the value you assign already contains <style>, the browser is handed <style><style>.foo{...}. Inside a <style> element the content is CSS, not HTML, so the inner tag is not a tag at all — it is a stray < where the CSS parser expected a selector.

The CSS parser does not discard the whole stylesheet. It recovers, and the way it recovers is to treat the junk as the prelude of a qualified rule and consume up to and including the next { … } block. That block is your first rule. Everything after it parses normally and applies.

So the failure is one rule wide. In the application where this was found it cost three rules across three pages, and each one happened to be the rule that established the layout — a display:grid and two display:flex declarations. Every check that looked at the text of the page passed.

The fix

Return the CSS on its own. Core supplies the element:

/* wrong */
Output::i()->headCss .= '<style>.myapp-grid{display:grid}</style>';

/* right */
Output::i()->headCss .= '.myapp-grid{display:grid}';

A useful sanity check while you are in that template: headCss is emitted inside a {{if \IPS\Dispatcher::i()->controllerLocation == 'front' …}} branch, so anything you put in headCss from the AdminCP is discarded. That is a second silent failure hiding in the same few lines.

One caveat when you go looking for this in your own code: a plain file-level search for <style is useless. Pop-out windows and standalone HTML documents legitimately need a real <style> tag, so a naive search flags correct code, and a check that cries wolf gets switched off. Only the value that reaches headCss matters.

2. use IPS\Settings; in a file that declares class settings

The symptom

One AdminCP page returns a bare 500 with no IPS error screen and no error code. If a stack trace is produced at all, it names the class line of your controller. The use statement that actually caused it is never mentioned.

Cannot declare class IPS\myapp\modules\admin\settings\settings, because the name is already in use

Why it happens

PHP compares class names case-insensitively, and an import occupies a name in the file's symbol table exactly as a declaration does. These two lines therefore claim the same name:

namespace IPS\myapp\modules\admin\settings;

use IPS\Settings;              /* imports the name "Settings" */

class settings extends Controller    /* declares the name "settings" */
{

It is an E_COMPILE_ERROR, which means it happens before any of your code runs. No try/catch reaches it, IPS's error handler never gets the chance to render its screen, and the shutdown handler is all that is left — which is why the trace is so unhelpful.

It bites in Invision Community specifically because a controller is named after its module, so the class name is lowercase and matches an ordinary IPS class. Any application with a modules/admin/settings/settings.php is one use IPS\Settings; away from it, and Settings is about the most natural thing to want in a settings controller. The same trap is waiting in a members/members.php, a forums/forums.php, and so on.

The fix

Drop the import and write the class out in full at the point of use:

/* no import for Settings */

class settings extends Controller
{
    public function manage(): void
    {
        $value = \IPS\Settings::i()->myapp_thing;
    }
}

Aliasing (use IPS\Settings as SettingsClass;) also works, because the imported name becomes the alias. Writing it in full is clearer about why the import is missing.

Not to be confused with a trait

The same keyword means something else inside a class body, and there it is legal even when the names match exactly:

class Xenforo extends Software
{
    use \IPS\convert\Tools\Xenforo;   /* composes a trait — fine */

That is real core code, in applications/convert/sources/Software/Forums/Xenforo.php, and it ships and works. A trait use composes behaviour into a class; it does not import a name into the file. If you write a checker for this, cut the file at the class keyword before you look for imports, or your very first run will report core as broken.

3. Application::init() runs for one application per request

The symptom

Something registered in your application's init() — a listener, a hook, a global asset, a navigation item — works when you are inside your own application's pages and is absent everywhere else on the site. Nothing errors; the code simply never ran.

Why it happens

init() is called by the dispatcher, on the one application that is handling the request, in system/Dispatcher/Standard.php (around line 230):

if ( method_exists( $this->application, 'init' ) )
{
    $this->application->init();
}

$this->application is singular by design — it is whichever application owns the URL being served. Viewing a forum topic initialises forums, and your application's init() is not called. It is an application initialiser, not a site initialiser, and the name invites the wrong assumption.

The fix

Anything site-wide belongs in a core/Loader extension, which core iterates over every request regardless of which application is dispatching:

foreach( Application::allExtensions( 'core', 'Loader' ) as $loader )
{
    foreach( $loader->js() as $js ) { … }
    foreach( $loader->css() as $css ) { … }
}

…in both system/Dispatcher/Front.php and system/Dispatcher/Admin.php.

🚨 That last part is the follow-on trap. The Loader also runs in the AdminCP, so moving your front-end assets there will start injecting them into every AdminCP page. Gate on the location:

if ( Dispatcher::i()->controllerLocation !== 'front' )
{
    return array();
}

4. A content item is never indexed without a core/SearchContent extension

The symptom

Your content items exist, render, and are reachable by URL, but searching for their titles returns nothing. core_search_index holds no rows for your class. No exception is thrown at any point, and rebuilding the search index does not help.

Why it happens

Getting the content behaviours right is not enough. A complete set of traits, a correct ContentRouter extension, working permissions — none of it registers your class with search. Only a core/SearchContent extension naming the class in its supportedClasses() does that, and everything downstream checks membership of that list before doing anything:

/* system/Content/Search/SearchContent.php */
public static function isSearchable( string|object $object ) : bool
{
    $class = is_string( $object ) ? $object : get_class( $object );
    return in_array( $class, static::searchableClasses() );
}

The consumers all return rather than throw, which is where the silence comes from. In system/Content/Search/Index.php, indexData() returns NULL and indexSingleItem() returns early:

if( !SearchContent::isSearchable( $object ) )
{
    return;
}

So you can call the indexer as often as you like on an unregistered class and get a clean no-op every time.

The second half: save() does not index

Even with the extension in place, a hand-built item stays unindexed. Item::save() does not touch the search index. The call lives in processAfterCreate() (system/Content/Item.php, around line 1019):

protected function processAfterCreate( Comment|null $comment, array $values ): void
{
    if ( Bridge::i()->checkItemForSpam( $this ) ) { return; }

    /* Add to search index */
    if( Content\Search\SearchContent::isSearchable( $this ) )
    {
        Index::i()->index( $this );
    }

…and processAfterCreate() is reached through createFromForm() (same file, around line 568). Content created by a form is indexed. Content you build in code, in an importer, in a task, or in a seeding script is not, because you never went through createFromForm(). Index it explicitly:

$item->save();
Index::i()->index( $item );

Note the spam check ahead of the indexing call: an item flagged as spam returns early and is correctly never indexed. If one specific item is missing from search while its neighbours are fine, look there before you look at your extension.

5. Unprefixed column names against a prefixed table, inside a try/catch

The symptom

A log table, a statistics table or an audit trail is empty. Not sparse — empty, for the entire life of the feature. Nothing in the error log, and the feature it belongs to works perfectly.

Why it happens

Two ordinary decisions combine into an invisible one. IPS convention prefixes columns with the table's short name, so a table called airplay has airplay_title, not title. And code that writes a log is quite reasonably wrapped so that a broken log never costs somebody their page view:

try
{
    Db::i()->insert( 'myapp_airplay', array(
        'played' => time(),      /* column is airplay_played */
        'title'  => $title,      /* column is airplay_title  */
    ) );
}
catch ( Exception $e ) {}

Every insert throws "Unknown column", and every one of them is swallowed by the catch that exists to protect the visitor. The guard is right; it is the total absence of any other signal that is wrong.

The fix

Keep the catch — do not trade a silent log for a broken page — but make it say something:

catch ( Exception $e )
{
    Log::log( $e, 'myapp_airplay' );
}

Then verify the table is actually receiving rows before you call the feature done. A SELECT COUNT(*) against every table your application writes to, as part of your own post-release check, catches this class of bug in seconds. An empty table is the assertion; the code reading correctly is not.

The general form is worth stating plainly, because it is not limited to logs: a catch with an empty body converts a fatal error into a missing feature, and a missing feature is much harder to notice than a crash.

6. Generated FileStorage extension stubs left unfilled

The symptom

Everything works until an administrator moves file storage to S3 or another configuration, at which point the move throws. Separately, and before that, the orphaned-file sweep reports every file your application owns as unclaimed and offers to delete them.

Why it happens

The generated skeleton is deliberately non-functional, and the placeholders are strings, so nothing complains at build time. From applications/core/data/defaults/extensions/FileStorage.txt:

public function count(): int
{
    return Db::i()->select( 'COUNT(*)', '...', '...' )->first();
}

public function move( int $offset, int $storageConfiguration, int $oldConfiguration=NULL ) : void
{
    SystemFile::get( $oldConfiguration ?: '{app}_{class}', '...' )->move( $storageConfiguration );
}

public function isValidFile( SystemFile|string $file ): bool
{
    return FALSE;
}

public function delete() : void
{
}

Note isValidFile() returning FALSE. That is the answer to "is this file one of mine?", so an unfilled stub tells the orphaned-file sweep that none of your files are claimed. And delete() is an empty body, so uninstalling your application leaves every file it ever stored on disk.

The fix

Fill in all four with the real table and column, and treat the presence of '...' anywhere under extensions/core/FileStorage/ as a release blocker:

grep -rn "'\.\.\.'" applications/myapp/extensions/core/FileStorage/

An empty delete() is harder to catch that way, since an empty body is also what a genuinely file-less extension would have. If your application stores files at all, that method has a body.

Three smaller ones worth knowing

A node with no permission row is invisible, and so is its content

Node permissions live in a separate table, core_permission_index, and are not created by saving the node. Node\Model::roots() filters with a subquery (system/Node/Model.php, around line 544):

$where[] = array(
    '(' . static::$databaseTable . '.' . … . ' IN( '
        . Db::i()->select( 'perm_type_id', 'core_permission_index', $permQueryWhere )->returnFullQuery()
    . ') )',
);

A node with no row is simply not in that set, so it does not appear — with no error, and while looking perfectly correct in the database. Call setPermissions() after creating a node in code.

The same fact reaches search from the other side. The MySQL indexer removes, rather than skips, anything whose permissions come out empty (system/Content/Search/Mysql/Index.php, around line 85):

/* If nobody has permission to access it, just remove it */
if ( !$indexData['index_permissions'] )
{
    $this->removeFromSearchIndex( $object );
}

So a permissions mistake does not leave stale rows you can find and puzzle over. It deletes the evidence.

core_search_index belongs to the MySQL driver only

If you write a test that asserts on core_search_index, it will pass or fail for the wrong reason on an Elasticsearch site. The driver is chosen at runtime in system/Content/Search/Index.php:

if ( Settings::i()->search_method == 'elastic' )
{
    static::$instance = new Elastic\Index( Elastic\Index::elasticBaseUrl() );
}
else
{
    static::$instance = new Mysql\Index;
}

Ask get_class( Index::i() ) first and skip the table assertion when it is not IPS\Content\Search\Mysql\Index. A test that silently checks an unused table is worse than no test.

IPS refuses to render a 200 at a URL carrying a CSRF key

In developer mode, Output::sendOutput() raises E_USER_ERROR rather than send the page:

An 200 response is being sent however the CSRF key is present in the requested URL. CSRF keys should be sent via POST or the request should be redirected to a URL not containing a CSRF key once finished.

The check is IN_DEV-only (system/Output/Output.php, around line 1079) and excludes AJAX requests and file downloads. So an action that renders a confirmation page directly instead of redirecting will look fine on a production site and hard-fail on the developer's machine — or the reverse, if you only ever test in developer mode and never see it because you always redirect. Redirect after the action; do not render.

Two checks you can reuse

The first two are mechanical, and both are worth running over an application you did not write. Neither needs IPS to be bootstrapped — they read source, so you can run them on a build directory or in CI.

Import/declaration collisions. Find every class whose file imports its own name. The rule is a case-insensitive comparison between the declared name and each imported name (the alias, where there is one) — and, critically, only over the file header, cut at the class keyword, so that trait composition inside the class body is not reported:

$header = substr( $source, 0, $classDeclarationOffset );

preg_match_all( '/^\s*use\s+(?!function\s|const\s)([\w\\\\]+)(?:\s+as\s+(\w+))?\s*;/mi', $header, $uses, PREG_SET_ORDER );

foreach ( $uses as $use )
{
    $imported = ltrim( $use[1], '\\' );
    $name     = $use[2] ?? substr( strrchr( '\\' . $imported, '\\' ), 1 );

    if ( strtolower( $name ) === strtolower( $declaredClassName ) )
    {
        /* collision */
    }
}

Run against a full core tree this reports zero — which is the point. The first version of it did not cut at the class keyword, reported core's XenForo forums converter, and would have been ignored from then on.

<style> tags reaching headCss. This one cannot be a file-level search, for the reason given in section 1. Find the methods whose return value is concatenated onto headCss

preg_match_all( '/headCss\s*=.*?(?:static|self|\$this)\s*(?:::|->)\s*(\w+)\s*\(/s', $source, $m );

…then read just those method bodies (token_get_all(), counting braces, skipping comments — a regex will not survive a nested closure) and flag <style inside them. Handle the direct case too, where headCss is assigned a literal rather than a method call. Scoping it this way keeps pop-out windows and standalone documents, which need a real <style> tag, out of the results.

The pattern behind all six

Five of the six are a registration that was never made, or a guard doing its job too well:

  • Not in supportedClasses() → the indexer returns early.
  • No core_permission_index row → the node is filtered out of a subquery.
  • Not in a Loader extension → the dispatcher never reaches your code.
  • isValidFile() still returning FALSE → the sweep believes you.
  • An empty catch → the exception that would have told you is discarded.

None of those is a bug in core. Each is core correctly doing nothing with something you did not tell it about, and "correctly doing nothing" has no error message. The practical consequence is that reading the code proves less than you think: all six of these read correctly. What found them was looking at rendered output and counting rows in tables — asking what should be there, rather than what the code says it should do.

So the check that catches this class of bug is not a code review. It is a list of the observable things your application is supposed to produce — rows in these tables, this rule in the cascade, this item in search — verified one at a time against a running site.

Verified against

Invision Community 5.0.19, by reading source. Every file path and line number above was checked against that tree, and the two checks were run over the full applications/ directory. Line numbers are approximate and will drift between releases; the file paths and method names are the durable part.

One claim was checked and withdrawn: core's XenForo converter looks like an import collision and is not one. It is described in section 2 as the false positive it is, because a checker that reports it is a checker nobody will keep running.


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.