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

The core/ContactUs extension controls what happens on the site's Contact Us page. A visitor opens app=core&module=contact&controller=contact, types a message, and presses send. What that page looks like, and what actually happens to the message afterwards, is decided entirely by these extensions. Each one contributes one or more choices to the "Contact Us behavior" radio in the ACP (Settings → Contact Us), contributes the configuration fields that go with those choices, may add fields to — or redirect away from — the public form, and may claim a submission and do something with it. Core ships two: one that emails the message, and one that turns the Contact Us link into a redirect to some other URL.

The abstract is system/Extensions/ContactUsAbstract.php. It is small, and every method on it is abstract, which is unusual for an IPS extension point and is the source of its first failure mode.

The contract

All three methods must be declared. There are no optional methods and no properties.

namespace IPS\Extensions;

use IPS\Helpers\Form;

abstract class ContactUsAbstract
{
    /* ACP only. Contribute radio options, toggles and config fields. */
    abstract public function process( Form &$form, array &$formFields, array &$options, array &$toggles, array &$disabled  ) : void;

    /* Front end, before the form is validated. Add fields, or redirect. */
    abstract public function runBeforeFormOutput( Form $form ) : void;

    /* Front end, after a valid submission. TRUE = I handled it, stop. */
    abstract public function handleForm( array $values ) : bool;
}

Copy those signatures exactly. process() takes all five of its arguments by reference — including $form — and the & is part of the signature; dropping one is a fatal error, not a warning. The same skeleton is what the Developer Center writes for you — see applications/core/data/defaults/extensions/ContactUs.txt.

process() returns nothing. You communicate by mutating the arrays:

  • $optionsoptionKey => languageKey for the contact_type radio. Core adds contact_internal, contact_emails and contact_redirect. The chosen key is what gets stored in the contact_type setting.
  • $togglesoptionKey => array of DOM row ids to reveal when that option is selected. These are element ids in the rendered page, not field names — see the toggle failure mode below.
  • $disabled — a flat array of option keys that should render as a disabled radio. Neither core implementation uses it. Radio::html() passes it to the template at system/Helpers/Form/Radio.php:119, which writes the disabled attribute at applications/core/dev/html/global/forms/radio.phtml:18; Radio::getValue() then discards a submitted value that appears in the list and returns the default instead (system/Helpers/Form/Radio.php:154).
  • $formFieldsIPS\Helpers\Form element objects. Core adds these to the form after the radio (applications/core/modules/admin/settings/contactus.php:123-126), which is why they appear underneath it.
  • $form — the ACP settings form itself. You may call $form->add() on it directly, but the extension loop runs at lines 112-115 and the contact_type radio is not added until line 117, so anything you add this way appears above the radio. Neither core implementation does this. Use $formFields unless you specifically want a field above the behaviour picker.

runBeforeFormOutput() receives the front-end form. Form is an object, so $form->add() works even though the parameter is not by reference. It is called before $form->values(), so fields you add here are validated and appear in $values normally. It is also the documented place to abort the page entirely, which is what RedirectForm does.

handleForm() returns bool. TRUE means "I have dealt with this submission" and breaks the loop; FALSE means "not mine, carry on". Nothing gates the call for you — every extension's handleForm() is invoked on every submission until one returns TRUE, so you must test Settings::i()->contact_type yourself, exactly as core's Email extension does.

Who calls it, and when

Three lookups, all in core, all identical — applications/core/modules/admin/settings/contactus.php:110, applications/core/modules/front/contact/contact.php:74 and :196 — feeding four loops that call into the extensions:

$extensions = Application::allExtensions( 'core', 'ContactUs', FALSE, 'core', 'InternalEmail', TRUE );
WhereMethod
applications/core/modules/admin/settings/contactus.php:112-115process(), while building the ACP settings form
applications/core/modules/front/contact/contact.php:104-107runBeforeFormOutput(), on every view of the Contact Us page
applications/core/modules/front/contact/contact.php:136-142handleForm(), after a valid submission
applications/core/modules/front/contact/contact.php:198-204handleForm() again, from confirm(), when the email-verification link is clicked and confirmed

Nothing else in 5.0.19 consumes this extension. There is no REST or GraphQL endpoint for it, and no other shipped application implements it — ContactUs appears only in applications/core/data/extensions.json. The whole page is gated by Member::canUseContactUs() (system/Member/Member.php:4962), checked in contact::execute() at line 57; your extension is not consulted about access.

Ordering, and why core always wins

The third argument to allExtensions() is FALSE, so application access checks are skipped: your extension runs even for a member who has no access to your application. Disabled applications are skipped, and if the site is in recovery mode every third-party application is skipped (system/Application/Application.php:396-404).

The fourth argument, 'core', sorts the core application to the front of the list, so core's extensions are always offered a submission first. Because Email::handleForm() returns TRUE whenever contact_type is contact_internal or contact_emails, a third-party extension can never intercept a submission while either of those two behaviours is selected. Your handleForm() is only reached with some other contact_type.

The fifth argument, 'InternalEmail', sorts a named extension to the front within each application. The test is array_key_exists( $application->directory . '_' . $firstExtensionKey, $appExtensions ), so it is re-evaluated per application. No shipped application registers a ContactUs extension under the key InternalEmail in 5.0.19 — core registers its two as Email and RedirectForm — so on a stock install the uksort() at system/Application/Application.php:422-436 never fires, and order within core is simply the order in extensions.json: Email, then RedirectForm. Why the argument names a key that does not exist is unverified. If you do name your own extension InternalEmail the uksort() will fire for your application, but it only reorders your own app's extensions; the fourth argument still puts core's ahead of yours, so it buys you nothing.

A minimal example

Core's RedirectForm is the shortest complete implementation in the source. It is worth reading in full because it exercises all three methods, including a real runBeforeFormOutput(). This is applications/core/extensions/core/ContactUs/RedirectForm.php, abridged only in its header comments:

namespace IPS\core\extensions\core\ContactUs;

use IPS\Extensions\ContactUsAbstract;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Url;
use IPS\Output;
use IPS\Settings;
use function defined;

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

class RedirectForm extends ContactUsAbstract
{
    public function process( Form &$form, array &$formFields, array &$options, array &$toggles, array &$disabled  ) : void
    {
        $formFields[] = new Url( 'contact_redirect', Settings::i()->contact_redirect, FALSE, array( ),NULL ,NULL ,NULL, 'contact_redirect' );
        $options['contact_redirect'] = 'contact_redirect';
        $toggles['contact_redirect'] = array( 'contact_redirect' );
    }

    public function runBeforeFormOutput( Form $form ) : void
    {
        if ( Settings::i()->contact_type == 'contact_redirect' AND Settings::i()->contact_redirect != '' )
        {
            Output::i()->redirect( Settings::i()->contact_redirect );
        }
    }

    public function handleForm( array $values ): bool
    {
        return FALSE;
    }
}

Note the eighth constructor argument, 'contact_redirect'. That is $id on IPS\Helpers\Form\FormAbstract (system/Helpers/Form/FormAbstract.php:167) and it is what makes the toggle work. Note also that process() is called on every ACP form build regardless of which behaviour is selected, and that runBeforeFormOutput() gates on the setting itself.

Core's Email extension (applications/core/extensions/core/ContactUs/Email.php) shows the claiming half of the contract:

public function handleForm( array $values ) : bool
{
    if ( Settings::i()->contact_type == 'contact_internal' OR Settings::i()->contact_type == 'contact_emails' )
    {
        $fromName  = ( Member::loggedIn()->member_id ) ? Member::loggedIn()->name  : $values['contact_name'];
        $fromEmail = ( Member::loggedIn()->member_id ) ? Member::loggedIn()->email : $values['email_address'];
        $content   = $values['contact_text'];
        $referrer  = $values['contact_referrer'] ?? NULL;
        …
        return TRUE;
    }
    else
    {
        return FALSE;
    }
}

The keys available in $values come from the form built in contact::manage(): contact_text is always present (an Editor, so an HTML string); contact_name and email_address exist only for guests (lines 95-103), which is why core tests member_id before reading them; contact_referrer arrives from $form->hiddenValues, merged into the values at system/Helpers/Form/Form.php:877-883. Anything you added in runBeforeFormOutput() is in there too.

Registration goes in your own applications/myapp/data/extensions.json:

{
    "core": {
        "ContactUs": {
            "Helpdesk": "IPS\\myapp\\extensions\\core\\ContactUs\\Helpdesk"
        }
    }
}

And any setting you expose from process() must also exist in applications/myapp/data/settings.json, for the reason in the next section but one.

The member is told "Message sent" and nothing was sent

This is the failure mode that matters. The front controller runs the loop and then throws the result away:

foreach ( $extensions as $k => $class )
{
    if ( $handled = $class->handleForm( $values ) )
    {
        break;
    }
}

if( Request::i()->isAjax() )
{
    Output::i()->json( 'OK' );
}

Output::i()->title  = Member::loggedIn()->language()->addToStack( 'message_sent' );
Output::i()->output = Theme::i()->getTemplate( 'system' )->contactDone();

$handled is assigned at applications/core/modules/front/contact/contact.php:138 and never read again. If every extension returns FALSE, the visitor still gets the success page and the AJAX path still returns 'OK'. Nothing is written to core_error_logs, nothing appears in the ACP, and the message is gone. The confirm() loop at lines 198-204 discards its result the same way — it does not even assign it — and then deletes the pending core_contact_verify row at line 206 whether or not anything handled the message.

This is not hypothetical. It happens whenever contact_type holds a value whose owning extension is no longer there — the application was disabled or uninstalled, or the site is in recovery mode — because the setting keeps its old value and nothing re-validates it. It also happens on a stock site: select the Redirect behaviour but leave the URL blank (the Url field is declared FALSE for required, so a blank saves), and RedirectForm::runBeforeFormOutput() declines to redirect, the form renders, Email::handleForm() returns FALSE because contact_type is neither contact_internal nor contact_emails, and RedirectForm::handleForm() unconditionally returns FALSE.

The only way a developer notices is that mail stops arriving. Defend against it from your side: return TRUE only when you really did something, log your own failures, and if your behaviour is the site's selected one, consider having your runBeforeFormOutput() refuse to render a form you cannot process.

My ACP field saves, then comes back empty

contactus::manage() calls $form->saveAsSettings( $values ), which is Settings::i()->changeValues(). That method builds a list of valid keys from core_sys_conf_settings and then, at system/Settings/Settings.php:295-303:

/* Make sure the key is valid */
if( !in_array( $k, $validKeys ) )
{
    if (IN_DEV)
    {
        throw new InvalidArgumentException( 'unknown_setting: ' . $k );
    }
    continue;
}

So a field you added through $formFields whose name is not a registered setting is silently discarded on a production site — the ACP redirects with "Saved" and the value is gone when the page reloads. On a developer install the same mistake throws InvalidArgumentException with the offending key in the message, which is the only place it is ever reported. Declare every field name in your app's data/settings.json with a key, a default and a report value, the way core declares contact_type, contact_emails and contact_redirect in applications/core/data/settings.json.

The corollary is that this extension point has no storage of its own. Everything process() collects goes into the global settings table, so name your fields with your app prefix to avoid collisions.

Fatal error: class contains abstract methods

Because all three methods are abstract, an extension that omits one — or that changes a signature, including dropping a & from process() — is a PHP compile error, raised when the class is autoloaded. Application::extensions() autoloads it via class_exists( $classname ) at system/Application/Application.php:926, so the fatal happens the first time anything asks for the ContactUs extension list.

Unlike most other extension points this is loud, and that is a good thing: you will see it immediately on the ACP Contact Us settings page and on the front-end Contact Us page. It is a PHP fatal error rather than a thrown exception, so no catch anywhere can suppress it — and in any case constructExtensionClass() only catches RuntimeException and OutOfRangeException. Note the blast radius is limited to those two pages — nothing else builds this list.

TypeError when the Contact Us page loads

ContactUsAbstract declares no constructor, but core instantiates every extension with one argument (system/Application/Application.php:479):

$obj = new $classToUse( $checkAccess === TRUE ? Member::loggedIn() : ( $checkAccess === FALSE ? NULL : $checkAccess ) );

All three ContactUs call sites pass FALSE, so your class is constructed as new Helpdesk( NULL ). If you copy a constructor from another extension type and write public function __construct( Member $member ), that is an uncaught TypeError and a hard error page. Declare no constructor, or accept ?Member $member = NULL.

My toggle never shows or hides the field

$toggles values are rendered straight into data-toggles on the radio input (applications/core/dev/html/global/forms/radio.phtml:18), and the JavaScript matches them against DOM element ids. The id of a form row is set in applications/core/dev/html/admin/forms/row.phtml:2 as id="{$id}", where $id comes from system/Helpers/Form/FormAbstract.php:342:

$this->htmlId ?: ( $form ? "{$form->id}_{$this->name}" : NULL )

So the toggle target is the eighth constructor argument of your form element if you supplied one, and {formId}_{fieldName} if you did not. The ACP settings form is constructed as a bare new Form, so the fallback prefix is form_. Core sidesteps the ambiguity by passing an explicit id equal to the field name. If you get this wrong there is no error at all — the field simply stays visible, or stays hidden, whichever it started as. Also note htmlId is run through preg_replace( "/[^a-zA-Z0-9\-_]/", "_", $id ), so backslashes and colons in an id become underscores and will not match the string you put in $toggles.

handleForm is not called when a guest submits

If contact_email_verify is on (it defaults to 1) and the submitter is not logged in, contact::manage() takes a completely different path at lines 114-134. It writes the values into core_contact_verify as JSON, sends a verification email, renders the "check your email" template and returns. No handleForm() runs. Your extension is only called later, from confirm(), after the guest clicks the link.

Two consequences. First, in the confirm() path $values is json_decode( $verify['contact_data'], true ) — a plain nested array. Anything that was an object in the original $values did not survive json_encode() intact. Editor content is a string and is fine; if you add a field whose value helper returns an object, verify what you actually get on the far side. (Which specific form helpers return objects here is unverified — the shipped extensions only read strings.) Second, confirm() is a different request, quite possibly from a different browser session than the one that filled the form in, so do not rely on session state — and Member::loggedIn() there is whoever clicked the link, which confirm() never checks against the submitter. It is usually a guest, but nothing enforces that: if a logged-in member opens the verification link, core's Email::handleForm() takes the member_id branch and mails the message under that member's name and address, ignoring the contact_name and email_address the guest typed. If your handleForm() needs the submitter, read it out of $values, not out of Member::loggedIn().

File::claimAttachments() is called at line 112, before the verification branch, so attachments are claimed at submit time either way.

A pending guest message disappears before it is verified

The insert at applications/core/modules/front/contact/contact.php:117-121 writes email_address, contact_data and verify_key only. The table has a fourth column, verify_time, whose default is 0 (applications/core/data/schema.json), and nothing in the source sets it. The daily cleanup task then runs:

/* Delete Contact Us Verifications older than a month */
Db::i()->delete( 'core_contact_verify', array( "verify_time<?", DateTime::create()->sub( new DateInterval( 'P30D' ) )->getTimestamp() ) );

That is applications/core/tasks/cleanup.php:369, on a P0Y0M1DT0H0M0S schedule. Since verify_time is always 0, every pending row matches the condition, so unverified contact messages are pruned on the next daily run rather than after thirty days. The guest then follows the link, confirm() finds no row, and core raises node_error with code 2C435/1 (404). Your handleForm() never runs and nothing records that a message was lost.

The primary key is email_address alone, and the insert passes TRUE as the third argument to Db::insert(). That argument is $odkUpdate, not "replace": the statement becomes INSERT … ON DUPLICATE KEY UPDATE (system/Db/Db.php:996-1004). The practical effect is the same here — one pending message per email address, and a second submission overwrites the contact_data and verify_key of the first, invalidating the earlier link. You cannot fix any of this from an extension, but if your behaviour depends on guest submissions arriving, be aware the delivery window is under a day.

My extension is never constructed and nothing is logged

The usual IPS extension-loading silence applies. Application::extensions() skips any entry where !is_string( $classname ) or !class_exists( $classname ) with a bare continue (system/Application/Application.php:926-930), and constructExtensionClass() returns null if construction throws RuntimeException or OutOfRangeException. Nothing is logged in either path.

So a missing data/extensions.json entry, a namespace that does not match the directory, or a typo in the class name all present the same way: your radio option simply is not on the ACP settings page. Check that page first — if your option is absent, the class was never loaded.

The resolved list is also cached. allExtensions() stores it in Store::i()->extensions keyed by extension name (system/Application/Application.php:359, 442) and only rebuilds when the ContactUs key is missing. After adding an extension to an already-installed app, clear the datastore or you will keep getting the old list.

My redirect took the Contact Us page away from everyone

runBeforeFormOutput() is called on every extension on every view of the page, whatever contact_type is set to. Output::i()->redirect() ends the request, so if you redirect unconditionally the page becomes unreachable for every visitor, and extensions after yours in the list never run — including their chance to add fields. Gate on Settings::i()->contact_type the way RedirectForm does. The same applies to anything else that terminates the request from that method.

The reverse mistake is cheaper but still worth avoiding: adding your custom fields unconditionally means they render, and are validated, even when a different behaviour is selected and nobody will ever read them.

Verified against

Read from Invision Community 5.0.19 source: system/Extensions/ContactUsAbstract.php; both shipped implementations, applications/core/extensions/core/ContactUs/Email.php and applications/core/extensions/core/ContactUs/RedirectForm.php; applications/core/modules/front/contact/contact.php (execute(), manage(), confirm()); applications/core/modules/admin/settings/contactus.php (manage(), _getConfigForm()); system/Application/Application.php (applications(), extensions(), allExtensions(), constructExtensionClass()); system/Settings/Settings.php (changeValues()); system/Helpers/Form/Form.php (values(), saveAsSettings()); system/Helpers/Form/FormAbstract.php and Radio.php; system/Db/Db.php (insert()); system/Member/Member.php (canUseContactUs()); applications/core/tasks/cleanup.php; the form templates applications/core/dev/html/global/forms/radio.phtml and applications/core/dev/html/admin/forms/row.phtml, and the toggle implementation in static/js/global/root_framework.js; applications/core/data/settings.json, schema.json, tasks.json, extensions.json and defaults/extensions/ContactUs.txt.

Claims not confirmed from source, stated as such above: why allExtensions() is passed the extension key InternalEmail when no such extension exists in this version; and which form helpers, if any, return values that fail to round-trip through the core_contact_verify JSON on the guest verification path. Nothing here was tested against a running site — it is all read from the 5.0.19 tree.


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.