The core/Notifications extension is what puts an entry behind the bell in the site header. It does two separate jobs for one type of member-facing notification. First, it declares the switches a member sees under Account Settings → Notification Settings — the rows that let somebody choose whether "somebody replied to content you follow" arrives in the notifications list, as a web push notification, as an email, or not at all. Second, it turns a stored notification row back into something readable: the title, the link, and the photo shown on /notifications/ and in the header dropdown. Without a registered extension, a notification can still be written to the database, but no member can ever configure it, so in practice it is never delivered, and if a row somehow exists nothing can render it (see the parse_ failure mode below).
This is the member-facing notification system. Administrator notifications — the bell in the AdminCP — are a different extension point, core/AdminNotifications, with a different base class and different tables.
Registration
The class lives at applications/<app>/extensions/core/Notifications/<Name>.php, is namespaced IPS\<app>\extensions\core\Notifications, extends IPS\Extensions\NotificationsAbstract, and must be listed in applications/<app>/data/extensions.json. Nothing scans the directory; the JSON is the only register. See the ContentRouter article for the details of that mechanism and its datastore cache — they are identical here.
Throughout the notification system the extension is identified by the key <appDirectory>_<ClassName>, because that is how Application::allExtensions() keys its return value (system/Application/Application.php line 419). An extension Files in the downloads app is downloads_Files. That key is used as the URL parameter for the settings page, and it is the language key that titles the group:
notifications__<appDirectory>_<ClassName> e.g. notifications__downloads_Files
That string is looked up with Lang::get(), not addToStack(), in the AdminCP list at applications/core/modules/admin/membersettings/notifications.php line 78. That matters: Lang::get() with a single missing key throws UnderflowException (system/Lang/Lang.php line 861) and nothing in that controller catches it, so a missing group title takes out the whole AdminCP → Members → Notification Settings list rather than printing the raw key.
The contract
system/Extensions/NotificationsAbstract.php is short. One abstract method, three optional hooks, no properties:
abstract class NotificationsAbstract
{
/* You MUST implement this. $member is the member whose settings page is
being built, or NULL when the AdminCP is setting site-wide defaults. */
abstract public static function configurationOptions( ?Member $member = NULL ): array;
/* Called when a 'custom' or 'extra' control on the settings form is saved. */
public static function saveExtra( ?Member $member, string $key, bool $value ) : void
/* Called when a member switches off an entire delivery method. */
public static function disableExtra( ?Member $member, string $method ) : void
/* Called when an admin resets every member to the defaults. */
public static function resetExtra() : void
}
Those four are the whole declared contract. Everything else this extension does is discovered by naming convention or by method_exists(), is not declared anywhere, and is therefore not type-checked.
configurationOptions() — the return shape
The method returns an array keyed by an arbitrary option key. Each entry has a type, and the type decides which keys are read. The form builder recognises four: standard, custom, separator and header (system/Notification/Notification.php lines 383–396). Only three of them are actually used anywhere in the shipped suite — grepping every core/Notifications extension turns up 24 standard, 3 custom and 2 separator entries and no header at all, so header is supported but untested by core.
A standard entry is the normal case — an inline/push/email row. From applications/core/extensions/core/Notifications/Profile.php lines 47–55:
$return['core_Profile_follow'] = array(
'type' => 'standard',
'notificationTypes' => array( 'member_follow' ),
'title' => 'notifications__core_Profile_follow',
'showTitle' => TRUE,
'description' => 'notifications__core_Profile_follow_desc',
'default' => array( 'inline', 'push' ),
'disabled' => array(),
);
notificationTypesis the important one. It lists the notification keys — the string passed as the second argument tonew IPS\Notification()— that this single row of switches controls. One row can govern several keys;applications/core/extensions/core/Notifications/Content.phpline 111 putsnew_content,new_commentandnew_reviewbehind one control.defaultanddisabledare arrays drawn from exactly three values:inline,push,email. These are stored inSET('email','inline','push')columns (applications/core/data/schema.json,core_notification_defaults), so no other value survives a write.disabledremoves a method from the form entirely — Moderation's report centre row sets'disabled' => array( 'inline', 'push' )so the option is email-only.titleanddescriptionare language keys.showTitleis a boolean read by the row template as{{if \IPS\Dispatcher::i()->controllerLocation === 'admin' or $details['showTitle']}}— PHP short-circuitsor, so it is only evaluated on the front end, and a missing key warns there but not in the AdminCP. Declare it anyway.adminDescriptionis optional and replacesdescriptionon the AdminCP form only.extrais an optional array of additional checkboxes that are not inline/push/email at all — each needstitle,icon,value, and usuallyadminCanSetDefault. Saving one callssaveExtra().
A custom entry supplies a whole form field of your own under field, plus adminCanSetDefault and optionally admin_lang and adminOnly. A separator entry needs nothing but its type. A header entry needs a header key. Only standard entries create notification preferences; the others exist so that related settings can share the page.
The $member argument is how you hide options a member could never use. Return a smaller array, or an empty one — applications/core/extensions/core/Notifications/Achievements.php lines 50–65 returns [] outright when achievements and donation goals are both off, and Moderation.php line 60 gates its report-centre row on $member->canAccessReportCenter(). Remember that $member is NULL for the AdminCP, so every branch must cope with that.
The undeclared methods: parse_, parse_mobile_, parse_rest_
Nothing in the abstract mentions these, but without them a delivered notification has no content.
parse_<key>( Inline $notification, bool $htmlEscape = TRUE ): array renders the entry in the notifications list. It is an instance method. IPS\Notification\Inline::getData() (system/Notification/Inline.php lines 243–262) builds the method name from the stored key and calls it:
$method = "parse_{$this->notification_key}";
foreach ( $this->notification_app->extensions( 'core', 'Notifications' ) as $class )
{
if ( method_exists( $class, $method ) )
{
$return = $class->$method( $this, $htmlEscape );
if ( !isset( $return['unread'] ) )
{
$return['unread'] = !$this->read_time;
}
return $return;
}
}
throw new RuntimeException;
Note $this->notification_app: only extensions belonging to the application that sent the notification are searched. The returned array uses title (required — the row template skips any notification without one), url, content, author and unread. Throwing OutOfRangeException from inside is the accepted way to say "the thing this was about has been deleted"; core does it in downloads/extensions/core/Notifications/Files.php line 78.
parse_mobile_<key>( Lang $language, ...$emailParams ): array renders the web push notification and must be public static. system/Notification/Notification.php lines 879–884 calls it statically and spreads the notification's email parameters as positional arguments:
$method = "parse_mobile_{$this->key}";
foreach ( $this->app->extensions( 'core', 'Notifications' ) as $class )
{
if ( method_exists( $class, $method ) or $this->key === 'follower_content' )
{
$data = $class::$method( $language, ...$this->emailParams );
So the signature must match, in order and in type, the $emailParams array given to the Notification constructor. The returned array uses title, body (read without isset() in sendPushNotifications(), line 1009) and data, where data['url'] and data['author'] are recognised. Core's implementations also return a channelId; that key is not read anywhere in the self-hosted push path, and appears to be vestigial — unverified whether Cloud or the mobile app consumes it.
parse_rest_<key>( Inline $notification, bool $htmlEscape = TRUE ): array is optional and overrides parse_ for REST API responses only. system/Notification/RestApi.php line 32 looks for it and falls back to parent::getData() when it is absent. Only one implementation exists in the whole suite, MyStuff::parse_rest_new_likes().
Who calls it
The settings side. Most of these walk every extension in the suite with Application::allExtensions( 'core', 'Notifications' ); the two marked below instead use the per-application $app->extensions( 'core', 'Notifications' ):
IPS\Notification::defaultConfiguration(),system/Notification/Notification.phpline 137 — walks every extension, reads everystandardoption, and inserts the missing rows intocore_notification_defaults. This is where notification keys come into existence.IPS\Notification::availableOptions()line 228,membersOptionCategories()line 307 andmembersTypeForm()line 373 — build the member-facing form.applications/core/modules/front/system/notifications.phplines 220 and 301 — Account Settings → Notification Settings, and the "stop sending me anything by email" action, which callsdisableExtra()on every extension (line 328).applications/core/modules/admin/membersettings/notifications.phplines 75, 186 and 395 — the AdminCP list, the per-extension defaults form (which callssaveExtra( NULL, ... )at lines 305 and 367) and the "reset all members" action (which callsresetExtra()).applications/core/extensions/core/MemberACPProfileBlocks/Notifications.phpline 48 — the notification block on an AdminCP member profile.IPS\Member::notificationsConfiguration(),system/Member/Member.phpline 3143 — used to carry a member's saved preference across the other keys in the samenotificationTypesgroup.applications/core/api/GraphQL/Queries/NotificationTypes.phpline 71, andMutations/ChangeNotificationSetting.phpline 87 — the latter is per-application:Application::load( $pieces[0] )->extensions( 'core', 'Notifications' ).applications/core/modules/admin/developer/details.phplines 515, 724 and 1080 — the Developer Centre's translation and email-template checks, which callconfigurationOptions()with no arguments. Also per-application:$this->application->extensions( 'core', 'Notifications', false ).
The delivery side, scoped to one application via $app->extensions( 'core', 'Notifications' ): Notification::send() line 880 for push, and Inline::getData() / RestApi::getData() for display. Inline::getData() is in turn called from system/Notification/Table.php line 121, applications/core/modules/front/system/ajax.php line 578, applications/core/api/GraphQL/Types/MemberType.php line 557 and Mutations/MarkNotificationRead.php line 101.
Two more call sites use the extension but look for a method that no longer exists on the abstract — Application::installExtensions() line 2264 and the uninstall routine at line 5686, both guarded by method_exists( $class, 'getConfiguration' ). See the failure modes below.
Failure mode: the notification is sent and nobody receives anything
This is the one that costs the most time. Your code builds a Notification, attaches recipients, calls send(), and gets an empty result. No exception, no system log entry, no email, no row in core_notifications.
send() decides each delivery method by reading the member's resolved preferences:
if ( isset( $notificationPreferences[ $keyToCheck ] ) AND in_array( 'email', ... ) )
at system/Notification/Notification.php lines 704, 796 and 874. $notificationPreferences comes from Member::notificationsConfiguration(), which is a join of core_notification_defaults against core_notification_preferences. If your notification key has no row in core_notification_defaults, the isset() is false three times over and nothing at all happens.
For a conforming IPS 5 extension the rows are created in exactly one place, Notification::defaultConfiguration() — the two Db::i()->insert() calls at lines 190 and 201 inside the loop at 183–215. That method has only two callers in the whole suite: availableOptions() line 235, which is inside if ( $member ) and so only fires for a real member, and the AdminCP per-extension defaults form, membersettings/notifications.php line 193 in edit(). In other words the rows are created lazily, and send() never triggers it. (Two other places write to core_notification_defaults — Application::installExtensions() line 2283 and the same AdminCP form's Db::i()->replace() at line 358 — but neither reaches a conforming extension before defaultConfiguration() has already run. See below.)
Application install does not help. Application::installExtensions() line 2264 seeds core_notification_defaults only for extensions that implement the legacy method getConfiguration(), which is not on the IPS 5 abstract and survives in exactly one core class, applications/core/extensions/core/Notifications/Moderation.php line 194. A conforming IPS 5 extension seeds nothing at install time.
The practical rule: after installing your app, open a member's Account Settings → Notification Settings once, or in the AdminCP go to Members → Notification Settings and click Edit on your extension's row, before testing delivery. Merely loading the AdminCP list is not enough — manage() only calls allExtensions() and Lang::get(), never defaultConfiguration(). If you want the rows to exist unconditionally, call IPS\Notification::defaultConfiguration() from your app's install or upgrade step. Two further consequences of the same design:
- A notification key that appears in no extension's
notificationTypesarray can never be delivered by any method. There is no error for this; it is simply undeliverable forever. - Uninstalling your app does not remove your rows from
core_notification_defaultsorcore_notification_preferences, because the uninstall routine at line 5686 uses the samegetConfiguration()test. The orphaned rows are harmless but persist.
Failure mode: the notification key is longer than 32 characters
The two tables disagree. core_notification_defaults.notification_key and core_notification_preferences.notification_key are VARCHAR(100); core_notifications.notification_key — the table that holds the actual delivered notifications — is VARCHAR(32). All three are in applications/core/data/schema.json.
So a long key configures perfectly and then fails to store. On MySQL 8's default STRICT_TRANS_TABLES the insert raises a data-too-long error; with strict mode off the key is truncated, and the truncated string will not match parse_<key>, so the notification renders as nothing. Keep notification keys short. Core's longest is account_del_request_rejected at 28 characters (system/Member/PrivacyAction.php line 305) — four characters of headroom, which is how close the suite itself runs to the limit.
Failure mode: a parse_ method is missing
Inline::getData() throws a bare RuntimeException when no extension of the sending application declares parse_<key>. What the member sees depends entirely on which consumer hit it, and the consumers do not agree:
system/Notification/Table.phpline 123 catchesLogicException | RuntimeExceptionand drops the row. The notification silently vanishes from/notifications/.applications/core/modules/front/system/ajax.phpline 580 catches onlyOutOfRangeException. ARuntimeExceptionescapes, and the header's notification poll (do=instantNotifications) errors. That poll is attached infront/global/userBar.phtmland only runs when theauto_polling_enabledsetting is on, so on a site with polling disabled this particular symptom does not appear.applications/core/api/GraphQL/Types/MemberType.phpline 559 catches onlyLogicException.RuntimeExceptionis not one, so the GraphQL notifications query fails.
The tell for this class of bug is a permanently wrong unread count. Member::recountNotifications() (system/Member/Member.php line 4331) is a raw COUNT(*) over core_notifications with no attempt to parse anything, so an unrenderable notification still increments the badge. A bell showing "3" over an empty list means three rows exist that nothing can parse.
The same symptom appears if parse_<key> is in the right shape but in the wrong application. The loop iterates $this->notification_app->extensions(...) only. If you construct new Notification( Application::load('core'), 'my_key', ... ) from your own app, core is searched, your extension is not, and RuntimeException is thrown. Pass your own application.
Failure mode: a standard option is missing 'default' or 'disabled'
This one is loud, and it takes down a page that is not yours. defaultConfiguration() reads both keys with no guard and passes them to implode() (lines 201–205):
Db::i()->insert( 'core_notification_defaults', array(
'notification_key' => $key,
'default' => implode( ',', $data['default'] ),
'disabled' => implode( ',', $data['disabled'] )
) );
A missing key gives an undefined-array-key warning and then implode(): Argument #2 ($array) must be of type array, null given — a TypeError. Because defaultConfiguration() is cached in a static property for the request only, this fires again for every member who opens notification settings and for the AdminCP per-extension defaults form, on a page owned by core, with your class in the trace. Always declare both, even as empty arrays. The same applies to notificationTypes, iterated without a guard at lines 157, 257 and 448.
Failure mode: a standard option is missing 'showTitle' or 'description'
Quieter. applications/core/dev/html/global/members/notificationsSettingsRow.phtml reads $details['showTitle'] and $details['description'] as direct array indexes, not through isset(). Missing keys produce PHP warnings on the settings page and, with display_errors on, visible noise inside the form. Both are cheap to declare; showTitle => FALSE is normal when the extension has only one option and the group title already says it.
Failure mode: an option with no 'type'
availableOptions() line 240 and membersTypeForm() lines 383–396 both branch on $details['type']. An entry with no type, or an unrecognised one, falls through every branch: it is copied into the returned settings array by availableOptions() (line 287) and then matches no if in the form builder. The option simply never appears. An unrecognised type is completely silent — no error, nothing in the logs, the row is just absent from the page. An entry with the type key omitted is not quite silent: $details['type'] is read as a direct index at defaultConfiguration() line 150, availableOptions() line 240, membersOptionCategories() line 332 and membersTypeForm() line 383, so each raises an undefined-array-key warning. On a production site with display_errors off you will still see nothing.
Failure mode: saveExtra() typed as the abstract declares it
The abstract says bool $value. That is correct for an extra checkbox, which is passed a real boolean at system/Notification/Notification.php line 468:
$extension::saveExtra( $member, $extraKey, array_key_exists( $extraKey, $values["notifications_{$key}"] ?? [] ) );
It is wrong for a custom option, which is passed the raw form value at line 461:
$extension::saveExtra( $member, $key, $values[ $key ] );
Core's own implementations widen the parameter to mixed for exactly this reason — Content.php line 155, Moderation.php line 168, Messenger.php line 86 — while BulkMails.php line 75, which only has an extra, keeps bool. PHP permits the widening because parameter types are contravariant.
If you keep bool and use a custom option, two things can happen, and the silent one is worse. A CheckboxSet hands you an array, which cannot be coerced, so you get a TypeError when a member saves the form. A Radio hands you a string; coercion is governed by the file the call is made from, and system/Notification/Notification.php does not declare(strict_types=1) — nothing in IPS's own code does, only some bundled libraries under system/3rd_party/ — so a non-empty string is coerced to TRUE and your setting is silently written as a boolean. Use mixed $value whenever you declare a custom option.
One more asymmetry in the same area: disableExtra() is called from the front end at applications/core/modules/front/system/notifications.php line 328 with Member::loggedIn(), never with NULL, but the signature says ?Member. Content::disableExtra() line 209 still tests $member !== null; BulkMails::disableExtra() line 105 does not and would fatal on a null. Test for null.
Failure mode: parse_mobile_ declared non-static, or with the wrong arguments
Because it is invoked as $class::$method( $language, ...$this->emailParams ), a non-static parse_mobile_ raises "Non-static method cannot be called statically", and a signature that does not match the $emailParams you passed to the Notification constructor raises ArgumentCountError or TypeError. Neither is caught. The fatal happens inside send(), which for most applications runs inside a background queue task, so the visible symptom is a stalled queue rather than an error on the page that triggered it — and only for members who have both enabled push and a registered PWA subscription, since line 874 gates the whole block on webPushEnabled() and count( $member->getPwaAuths() ). It is entirely possible to ship this bug and never see it in testing.
Failure mode: missing language strings and email templates
For a fully working notification type you need, at minimum:
notifications__<app>_<ClassName>— the group title. Fetched withLang::get()in the AdminCP list, which throwsUnderflowExceptionon a missing key, so leaving this out breaks that page for everyone.- The
titleanddescriptionkeys named by each option. notification__<key>or whatever yourparse_method passes toaddToStack(). Missing keys render as the key itself.- If email is not in
disabled: an email template atapplications/<app>/dev/email/notification_<key>.phtml(plus the matching.txt), because as soon as one recipient wants email,send()line 710 callsEmail::buildFromTemplate( $this->app->directory, $this->emailKey, $this->emailParams, Email::TYPE_LIST )with no check that the template exists, andemailKeydefaults to'notification_' . $key(line 557). Its<ips:template parameters=>must match your$emailParams, with$emailappended. - The subject line
mailsub__<app>_notification_<key>indev/lang.php.
The Developer Centre will check most of this for you, but not the last item. applications/core/modules/admin/developer/details.php line 742 tests for the template with a path that is missing a directory separator:
if( file_exists( ROOT_PATH . "/applications/" . $this->application->directory . "dev/email/notification_" . $type . ".phtml" ) )
"downloads" . "dev/email/..." never exists, so the subject-line check inside that if never runs. A missing mailsub__ string is not reported and shows up as a raw language key in the subject of a live email. Check it by hand.
A minimal working example
applications/downloads/extensions/core/Notifications/Files.php is the smallest complete implementation in the suite — one option, one notification key, and both of the parse methods that matter (it has no parse_rest_; nothing outside MyStuff does). Reproduced below with the doc blocks removed and one line simplified: the real title is a ternary that uses notification__new_file_version_with instead when the category has version numbers turned on. Everything else is verbatim.
<?php
namespace IPS\downloads\extensions\core\Notifications;
use IPS\downloads\File;
use IPS\Extensions\NotificationsAbstract;
use IPS\Lang;
use IPS\Member;
use IPS\Notification\Inline;
use OutOfRangeException;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Files extends NotificationsAbstract
{
public static function configurationOptions( Member $member = NULL ): array
{
return array(
'new_file_version' => array(
'type' => 'standard',
'notificationTypes' => array( 'new_file_version' ),
'title' => 'notifications__downloads_Files',
'showTitle' => FALSE,
'description' => 'notifications__downloads_Files_desc',
'default' => array( 'inline', 'push', 'email' ),
'disabled' => array()
)
);
}
public function parse_new_file_version( Inline $notification, bool $htmlEscape=TRUE ): array
{
$item = $notification->item;
if ( !$item )
{
throw new OutOfRangeException;
}
return array(
'title' => Member::loggedIn()->language()->addToStack( 'notification__new_file_version', FALSE, array( 'sprintf' => array( $item->author()->name, $item->mapped('title') ) ) ),
'url' => $notification->item->url(),
'content' => $notification->item->content(),
'author' => $notification->extra ?: $notification->item->author(),
'unread' => (bool) ( $item->unread() )
);
}
public static function parse_mobile_new_file_version( Lang $language, File $file ): array
{
return array(
'title' => $language->addToStack( 'notification__new_file_version_title' ),
'body' => $language->addToStack( 'notification__new_file_version', FALSE, array( 'htmlsprintf' => array(
$file->author()->name,
$file->mapped('title')
) ) ),
'data' => array(
'url' => (string) $file->url(),
'author' => $file->author()
),
'channelId' => 'files',
);
}
}
One thing to change when you copy it: Files.php (and calendar/Events.php, and core/Achievements.php) declare configurationOptions( Member $member = NULL ), an implicit nullable. The abstract declares ?Member $member = NULL and implicit nullable parameter types are deprecated in PHP 8.4, so write the explicit ?Member in your own extension. Most core classes — Profile, Moderation, Content — already do.
The registration in applications/downloads/data/extensions.json:
{
"core": {
"Notifications": {
"Files": "IPS\\downloads\\extensions\\core\\Notifications\\Files"
}
}
}
And the sending side, from applications/downloads/extensions/core/Queue/Notify.php line 78. Note that the second argument matches notificationTypes, and the fourth — array( $file ) — is what both the email template and parse_mobile_new_file_version() receive. Condensed — the real loop also checks $file->container()->can( 'view', $recipientMember ) before attaching, and records the notified rows:
$notification = new Notification( Application::load( 'downloads' ), 'new_file_version', $file, array( $file ) );
foreach( $recipients as $recipient )
{
$notification->recipients->attach( Member::load( $recipient['notify_member_id'] ) );
}
$notification->send();
The supporting language strings in applications/downloads/dev/lang.php are notifications__downloads_Files, notifications__downloads_Files_desc, notification__new_file_version, notification__new_file_version_with, notification__new_file_version_title and mailsub__downloads_notification_new_file_version (lines 648–656 of that file), and the email template is applications/downloads/dev/email/notification_new_file_version.phtml.
What this extension is not for
Most applications never need one. Content-item notifications — replies, mentions, quotes, embeds, follows — are all raised against the core application: system/Content/Content.php lines 1473, 1492 and 1511 pass Application::load( 'core' ) for quote, mention and embed, and system/Content/Followable.php line 442 does the same for new_content. They are handled by applications/core/extensions/core/Notifications/Content.php and MyStuff.php. That is why forums, cms, nexus, gallery and blog ship no core/Notifications extension at all: their content is notified by core's. Only calendar (event reminders and RSVPs) and downloads (new file versions) declare one, because those events are not content posts.
Verified against
Invision Community 5.0.19 (applications/core/data/versions.json, highest entry 5001908 => "5.0.19"); PHP 8.1–8.3 per applications/core/data/requirements.json, with 8.1 listed as deprecated. Files read in full: system/Extensions/NotificationsAbstract.php; all nine implementations under applications/core/extensions/core/Notifications/; applications/downloads/extensions/core/Notifications/Files.php and applications/calendar/extensions/core/Notifications/Events.php. Also inspected: system/Notification/Notification.php (defaultConfiguration(), availableOptions(), membersOptionCategories(), membersTypeForm(), send(), sendPushNotifications()), system/Notification/Inline.php, system/Notification/RestApi.php, system/Notification/Table.php, system/Member/Member.php (notificationsConfiguration(), recountNotifications()), system/Application/Application.php (allExtensions(), extensions(), constructExtensionClass(), installExtensions(), delete()), applications/core/modules/front/system/notifications.php, applications/core/modules/front/system/ajax.php, applications/core/modules/admin/membersettings/notifications.php, applications/core/modules/admin/developer/details.php, applications/core/extensions/core/MemberACPProfileBlocks/Notifications.php, applications/core/api/GraphQL/Queries/NotificationTypes.php, applications/core/api/GraphQL/Types/MemberType.php, applications/core/api/GraphQL/Mutations/MarkNotificationRead.php, system/Lang/Lang.php (get(), addToStack()), system/Content/Content.php and system/Content/Followable.php, system/Member/PrivacyAction.php, the templates global/members/notificationsSettingsRow.phtml, front/system/notificationsRows.phtml and front/global/userBar.phtml, and applications/core/data/schema.json for core_notifications, core_notification_defaults and core_notification_preferences. A grep for channelId across the whole install finds it only in the extensions that produce it and in no consumer, which is the basis for calling it vestigial; whether Invision Community Cloud or the mobile application reads it is not verifiable from the self-hosted source. Likewise the $this->key === 'follower_content' branch at Notification.php line 882: grepping every new Notification( in the suite confirms no notification is ever constructed with that key (follower_content appears only as $keyToCheck, assigned at line 688), so the branch is dead in shipped code — but its intended behaviour is undocumented and untested here.
Recommended Comments