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

Every AdminCP page has a search box in the header. An administrator types three or more characters into it, an overlay drops down, and down the left of that overlay is a list of areas — Settings, Members, Groups, Clubs, Forums, Downloads Categories, and so on — each with its own result count. Clicking an area shows that area's results as a list of links. A core/LiveSearch extension is one of those areas. It decides whether the area is visible to this administrator, whether it should be the pre-selected area on the current page, and — given a search term — it returns already-rendered HTML rows that the browser drops straight into the panel.

It is only ever used in the AdminCP. It has nothing to do with front-end search, the search index, or IPS\Content\Search.

The contract

system/Extensions/LiveSearchAbstract.php is very small. Three abstract methods, no properties, and — importantly — no constructor.

abstract public function getResults( string $searchTerm ): array;
    // Called by the AJAX endpoint, once per keystroke-burst, for YOUR area only.
    // Return an array of HTML strings. Each one becomes one <li> in the panel.
    // Keys are discarded (the controller runs array_values() over it).
    // Return an empty array for "no results" — that is the normal negative case.

abstract public function hasAccess(): bool;
    // Called while rendering the AdminCP global template, on EVERY AdminCP page.
    // FALSE means your tab is not rendered at all. Keep it cheap.
    // It is NOT enforced by the AJAX endpoint — see the failure modes below.

abstract public function isDefault(): bool;
    // Called on every AdminCP page, only if hasAccess() returned TRUE.
    // TRUE marks your tab as the one to open first on this particular page.

All three are abstract, so all three must be declared, with exactly these signatures. There is no optional method and no property to set.

The abstract declares no constructor, but core constructs the object with one argument. Application::constructExtensionClass() at system/Application/Application.php:479 does new $classToUse( $checkAccess === TRUE ? Member::loggedIn() : ( $checkAccess === FALSE ? NULL : $checkAccess ) ). The two call paths disagree about what that argument is: the global template goes through allExtensions( 'core', 'LiveSearch', TRUE, … ) and so gets Member::loggedIn(), while the AJAX endpoint calls $app->extensions( 'core', 'LiveSearch' ), whose $checkAccess parameter defaults to FALSE (Application.php:911), and so gets NULL. PHP allows surplus arguments to a class that declares no constructor, which is why core's own extensions ignore it. If you declare your own __construct(), it must accept one nullable argument — something like __construct( Member|Group|null $member = NULL ) — or the AdminCP page will render while the AJAX request fatals.

Registering it

Three things are needed and none of them is optional.

The class goes in applications/<app>/extensions/core/LiveSearch/<Key>.php and is listed in applications/<app>/data/extensions.json:

{
    "core": {
        "LiveSearch": {
            "Forums": "IPS\\forums\\extensions\\core\\LiveSearch\\Forums"
        }
    }
}

And a language string named acp_search_title_<app>_<Key> supplies the tab label. That naming rule is enforced by the Developer Centre's missing-string scan — applications/core/modules/admin/developer/details.php:575 lists 'LiveSearch' => 'acp_search_title_{app}_{key}'. Core's own four are acp_search_title_core_Members, acp_search_title_core_Groups, acp_search_title_core_Settings and acp_search_title_core_Clubs (applications/core/dev/lang.php:6402-6405); every other application carries its own string in its own dev/lang.php, for example acp_search_title_forums_Forums at applications/forums/dev/lang.php:30.

Who calls it

There are exactly two consumers.

  • The AdminCP global template. applications/core/dev/html/admin/global/globalTemplate.phtml:223-227 runs \IPS\Application::allExtensions( 'core', 'LiveSearch', TRUE, 'core', 'Settings' ), then for each extension calls hasAccess() and, if that is TRUE, method_exists( $extension, 'isDefault' ) and $extension->isDefault(). This block sits outside the livesearch_manage restriction check that wraps the search input itself (line 36), so both methods run on every full AdminCP page render, for every administrator, whether or not they can use search.
  • The AJAX endpoint. applications/core/modules/admin/system/livesearch.php, app=core&module=system&controller=livesearch. Its manage() method (lines 55-74) is the only caller of getResults(). It splits the requested search_key, loads that application, walks its LiveSearch extensions, and calls getResults( urldecode( Request::i()->search_term ) ) on the one whose key matches. The result goes through array_values() into Output::i()->json().

The browser side is dev/js/admin/controllers/core/ips.core.liveSearch.js. Two details there matter to you: nothing is requested until the term is at least three characters long (line 95) and requests are debounced by 700ms (line 214); and the controller fires one request per tab in parallel (lines 178-213), so every registered area is queried on every search, not just the visible one.

Because the response passes through Output::i()->json(), and that method calls Member::loggedIn()->language()->parseOutputForDisplay( $data ) first (system/Output/Output.php:1432-1436), language placeholders inside your rendered HTML are resolved correctly. You can use {lang="..."} in the templates you return.

The endpoint's own permission gate is a single check in execute(): Dispatcher::i()->checkAcpPermission( 'livesearch_manage', 'core', 'overview' ) (line 46). That really is the whole of it — the AdminCP dispatcher's normal per-module restriction check names livesearch in its exemption list (system/Dispatcher/Admin.php:248), and because the controller declares public static bool $csrfProtected = TRUE; the dispatcher also skips its automatic csrfCheck() for it (Admin.php:227-230), so no CSRF key is needed either.

A minimal example

applications/forums/extensions/core/LiveSearch/Forums.php is one of the shortest complete implementations in the suite and shows the expected shape of all three methods. It is reproduced verbatim below apart from the doc-blocks.

<?php

namespace IPS\forums\extensions\core\LiveSearch;

use IPS\Db;
use IPS\Dispatcher;
use IPS\Extensions\LiveSearchAbstract;
use IPS\forums\Forum;
use IPS\Member;
use IPS\Theme;
use function defined;

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

class Forums extends LiveSearchAbstract
{
    public function hasAccess(): bool
    {
        /* Check Permissions */
        return Member::loggedIn()->hasAcpRestriction( 'forums', 'forums', 'forums_manage' );
    }

    public function getResults( string $searchTerm ): array
    {
        /* Init */
        $results = array();
        $searchTerm = mb_strtolower( $searchTerm );

        /* Start with categories */
        if( $this->hasAccess() )
        {
            /* Perform the search */
            $forums = Db::i()->select(
                            "*",
                            'forums_forums',
                            array( "club_id IS NULL AND word_custom LIKE CONCAT( '%', ?, '%' ) AND lang_id=?", $searchTerm, Member::loggedIn()->language()->id ),
                            NULL,
                            NULL
                    )->join(
                            'core_sys_lang_words',
                            "word_key=CONCAT( 'forums_forum_', id )"
                        );

            /* Format results */
            foreach ( $forums as $forum )
            {
                $forum = Forum::constructFromData( $forum );

                $results[] = Theme::i()->getTemplate( 'livesearch', 'forums', 'admin' )->forum( $forum );
            }
        }

        return $results;
    }

    public function isDefault(): bool
    {
        return Dispatcher::i()->application->directory == 'forums';
    }
}

The example is only complete if the template exists: Theme::i()->getTemplate( 'livesearch', 'forums', 'admin' )->forum( $forum ) resolves to applications/forums/dev/html/admin/livesearch/forum.phtml, the method name being the file name. The template it returns is a single list item. Core's applications/core/dev/html/admin/livesearch/club.phtml is representative:

<ips:template parameters="$club" />
<li data-role='result'>
    <a href='{url="app=core&module=clubs&controller=clubs&do=edit&id="}{$club->id}'>{$club->name}</a>
</li>

Keep the data-role='result' attribute. The JavaScript selects [data-role="result"] to hide the rows and fade them in one at a time (ips.core.liveSearch.js:275-293), and the ACP stylesheet hangs the row's padding, block link and hover state off the same selector (applications/core/dev/css/admin/core/livesearch.css, .cAcpSearch_results [data-role="result"]:not([class]) a). Omitting it does not hide your row: .hide() only matches elements that carry the attribute, and the list is shown unconditionally straight afterwards (line 278). What you lose is the staggered fade-in and all of core's row styling, so the row appears instantly and unstyled.

hasAccess() is not a permission check

This is the trap with real consequences. hasAccess() is called by the template, to decide whether to draw a tab. The AJAX endpoint never calls it. Look at livesearch.php:59-71 — it loads the app, matches the key, and calls getResults(). There is no access test between the livesearch_manage check in execute() and your query.

So any administrator who can use live search at all — a junior moderator with almost no other restrictions — can hand-craft ?app=core&module=system&controller=livesearch&search_key=myapp_Things&search_term=x and receive whatever getResults() returns, tab or no tab. If your only guard is hasAccess(), you have an ACP information disclosure and nothing anywhere will warn you: the tab is correctly hidden in the interface, so it looks like it works.

Every core implementation defends itself inside getResults(). Two patterns are used. An early return, as in applications/core/extensions/core/LiveSearch/Members.php:58-62 and applications/downloads/extensions/core/LiveSearch/Downloads.php:54-57:

if( !$this->hasAccess() )
{
    return array();
}

Or wrapping the query, as Forums and Clubs.php:58 do. Where an area covers several permissions, the check is repeated per section — applications/nexus/extensions/core/LiveSearch/Nexus.php re-tests hasAcpRestriction() separately before each of invoices, transactions, purchases, customers and licence keys, because its hasAccess() is an or of all five.

Note also that Settings.php:71 filters per row rather than per extension: it returns TRUE from hasAccess() unconditionally, then checks Member::loggedIn()->hasAcpRestriction( ... ) against each indexed page's stored restriction before including it.

The tab appears, but it always reports 0 results

Check whether your extension key contains an underscore. The endpoint parses the requested key like this (livesearch.php:61-69):

$exploded = explode( '_', Request::i()->search_key );
$app = Application::load( $exploded[0] );
foreach ( $app->extensions( 'core', 'LiveSearch' ) as $k => $extension )
{
    if ( $k === $exploded[1] )
    {
        $results = $extension->getResults( urldecode( Request::i()->search_term ) );
    }
}

The key the JavaScript sends is <appdirectory>_<ExtensionKey>, built by allExtensions() at system/Application/Application.php:419. Splitting it on every underscore and comparing only $exploded[1] means an extension key of My_Things is compared as My, never matches, and $results stays as the empty array it was initialised to. The same happens if your application directory contains an underscore: Application::load( $exploded[0] ) throws OutOfRangeException, which line 71 swallows.

The symptom is identical in both cases and it is completely silent — HTTP 200, body [], count 0, tab greyed out, nothing in any log. Use a single-word extension key with no underscores.

The result count spins forever and never resolves

Something inside getResults() threw. The controller's try block catches OutOfRangeException only. A TypeError (including the one PHP raises if you return something that is not an array, because the abstract declares : array), an IPS\Db\Exception, an undefined method — all propagate out of manage() and the request fails.

The browser then does nothing at all, deliberately (ips.core.liveSearch.js:210-212):

.fail( function (err) {
    // fail gets called when it's aborted, so deliberately do nothing here
});

Because the controller aborts in-flight requests on every new keystroke, a genuine failure is indistinguishable from an abort as far as the interface is concerned. The count badge keeps its loading animation and the administrator sees a tab that never finishes. Diagnose it from the browser's network tab (look at the response body for that one controller=livesearch request) or from the AdminCP system logs. Whether an error row is written depends on the site's error handling configuration, so treat the network tab as the reliable route.

The practical rule: getResults() receives an arbitrary user-supplied string with no length limit imposed server-side, and is expected to return an empty array rather than throw. Wrap any ::load() call. Nexus does this around all five of its lookups.

Every AdminCP page breaks after I add the extension

If your class extends LiveSearchAbstract but does not declare all three methods, PHP raises a fatal error when the class is declared — "contains abstract methods and must therefore be declared abstract". This is a compile-time fatal, not a catchable exception, and it fires as soon as the class is autoloaded.

The global template loads and constructs every LiveSearch extension in the suite on every AdminCP page, so a single missing method takes the entire AdminCP down, not just the search overlay. What you actually see (a white page, or IPS's error screen) depends on your PHP display-error settings. The same applies to a signature mismatch: declaring getResults( $searchTerm ) without the string type or without : array is a fatal "Declaration must be compatible with" error, with the same blast radius.

The tab label reads acp_search_title_myapp_Things

The language string is missing. The template does {lang="acp_search_title_{$key}"} where $key is already <app>_<Key>. When Lang cannot find a word it substitutes the key itself — system/Lang/Lang.php:2131-2134 falls through to $replacement = $values['key'];. There is no error and no log entry; the raw key is simply printed in the tab. In dev mode the Developer Centre's "Missing Language Strings" scan lists it under livesearch.

The extension is registered but the tab never appears

Work through these in order.

  • hasAccess() returned FALSE for the administrator you are testing with. This is the common one — the template skips the tab entirely.
  • The class name in data/extensions.json does not resolve. Application::extensions() filters with if( !is_string( $classname ) or !class_exists( $classname ) ) { continue; } (system/Application/Application.php:926-930) — a typo'd namespace means the extension is dropped from the list with no error at all. Remember the JSON needs doubled backslashes.
  • The extension map is cached. allExtensions() reads Store::i()->extensions and only rebuilds when the extension type is absent from it (Application.php:357-368). The Developer Centre unsets it when you add or remove an extension through the UI (applications/core/modules/admin/developer/extensions.php, lines 313 and 342); otherwise it goes only when the whole datastore is flushed, which enabling or disabling an application does (Application.php:1287-1288), as does a theme install (Application.php:2629-2630). Editing extensions.json by hand clears nothing, so clear the system cache before concluding anything.
  • Your application is disabled, or the administrator cannot access it. allExtensions() skips disabled applications and, because the template passes TRUE as the third argument, also calls $application->canAccess(). That method short-circuits to TRUE for administrators holding the core/applications/app_manage restriction (Application.php:5308), but for an administrator without it, an application restricted from their group takes its LiveSearch tab with it. This is unverified in the sense that we have not reproduced it on a live site; it follows from reading canAccess().

Notes on isDefault() and ordering

isDefault() only adds data-role="defaultTab" to the tab. The JavaScript reads it once during setup (ips.core.liveSearch.js:34) and, when results arrive for that key and it is the currently active panel, clicks the tab to reveal them (lines 206-208). Core's implementations are all simple Dispatcher tests — Dispatcher::i()->application->directory == 'forums', or in Members.php:89 a three-part test on application, module and controller. Nothing prevents two extensions returning TRUE on the same page; the template marks both and the JavaScript's .attr() call takes the first in document order.

Tab order is set by allExtensions( 'core', 'LiveSearch', TRUE, 'core', 'Settings' ): the core application is sorted to the front and, within it, the Settings key is sorted to the front (Application.php:374-436). Everything after that follows the application order, and that order is core_applications.app_positionApplication::getStore() selects with 'app_position' as the sort (Application.php:249) — so it is whatever order the administrator has dragged the applications into in the AdminCP. The extension itself offers no ordering hook.

One consequence of the JavaScript building the list: results are inserted with panelList.prepend( val ) (line 271), so the panel displays your array in reverse order. If order matters, reverse it yourself before returning.

Finally, the values in your returned array are JSON-encoded as-is and the JavaScript passes each one straight to prepend(). Return rendered HTML strings. Anything that does not arrive in the browser as a string — an object, a nested array — is handed to jQuery unchanged and will not render as a row, with no error raised anywhere; the count badge disagrees with what the panel shows. Exactly what jQuery does with each non-string shape has not been traced, so treat this as "return strings" rather than as documented behaviour.

You may not need this extension at all

If all you want is for your application's ACP settings pages to be findable by keyword, do not write a LiveSearch extension. Add applications/<app>/data/acpsearch.json instead:

{
    "app=myapp&module=settings&controller=settings": {
        "lang_key": "menu__myapp_settings_settings",
        "restriction": "settings_manage",
        "keywords": [ "widgets", "options" ]
    }
}

Application::installSearchKeywords() (system/Application/Application.php:2986-3020) writes those rows into core_acp_search_index tagged with your application directory, and core's own Settings extension queries that table across all applications (applications/core/extensions/core/LiveSearch/Settings.php:55), skipping disabled apps and honouring the per-row restriction. Your pages then appear under the existing "Settings" tab with no PHP at all. Write a LiveSearch extension only when you need to search your own data — categories, records, licence keys — rather than your settings pages.

Verified against

Read from Invision Community 5.0.19 source (applications/core/data/versions.json, long version 5001908): system/Extensions/LiveSearchAbstract.php, system/Application/Application.php, system/Dispatcher/Admin.php, system/Lang/Lang.php, system/Output/Output.php, applications/core/modules/admin/system/livesearch.php, applications/core/modules/admin/developer/details.php, applications/core/dev/html/admin/global/globalTemplate.phtml, applications/core/dev/html/admin/livesearch/*.phtml, applications/core/dev/css/admin/core/livesearch.css, dev/js/admin/controllers/core/ips.core.liveSearch.js, and all ten shipped implementations: Clubs, Groups, Members and Settings in applications/core/extensions/core/LiveSearch/, plus forums/Forums, downloads/Downloads, gallery/Categories, blog/Blogs, calendar/Calendars and nexus/Nexus. Pages (cms) ships no LiveSearch extension. Nothing here is inferred from IPS 4.x behaviour.



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.