In the AdminCP, every member's profile page carries a box headed "Warnings & Restrictions". It lists, in red, the things this particular account is currently not allowed to do — "Restricted from submitting files", "Restricted from using tags", "Restricted from accessing Gallery" — and it has an Edit link that opens a dialog where an administrator can turn those restrictions on and off for that one member. A core/MemberRestrictions extension is how an application adds its own rows to that list and its own fields to that dialog, and how it explains the resulting change in the member's History tab afterwards. Core's own one-line description, in applications/core/dev/lang.php line 1296, is "Show content and add fields to the associated form for the Warnings & Restrictions block within the AdminCP member view."
It is worth being clear about what the extension does not do. It enforces nothing. Downloads' extension writes the idm_block_submissions column; the actual blocking happens in applications/downloads/sources/Category/Category.php line 757 and applications/downloads/sources/File/File.php line 1082, which have no knowledge of the extension at all. Gallery is the same — the extension writes a bitoption, and applications/gallery/Application.php line 62 acts on it. The extension is the administrative surface for a per-member flag you already store and already honour somewhere else. If you write only the extension, an administrator can toggle a setting that does nothing.
The contract
This extension point does not use system/Extensions/ like most of the others. The base class is applications/core/sources/MemberACPProfile/Restriction.php, class IPS\core\MemberACPProfile\Restriction, and it is a concrete class, not an abstract. Every method has a working default, so an empty subclass is legal and simply contributes nothing. The Developer Center skeleton at applications/core/data/defaults/extensions/MemberRestrictions.txt generates exactly that — a class body with nothing in it.
The whole base class, lines 28–87, with the property and method docblocks stripped out. Every signature below is character-for-character as it appears in the source, including the inconsistent spacing before the return types:
class Restriction
{
/**
* @brief Member
*/
protected ?Member $member = null;
public function __construct( Member $member )
{
$this->member = $member;
}
public function enabled() : bool
{
return TRUE;
}
public function form( Form $form ): void
{
}
public function save( array $values ) : array
{
return $values;
}
public function activeRestrictions() : array
{
return array();
}
}
There is a fifth method that is not declared on the base class at all, but which core calls statically on every registered extension. All four shipped implementations declare it:
public static function changesForHistory( array $changes, array $row ): array
Taking them one at a time.
__construct( Member $member ) receives the member whose profile is being viewed — not the logged-in administrator. Store it, or leave the inherited constructor alone and use $this->member. Do not widen or narrow the type; see the failure modes below.
enabled() : bool decides whether your section appears at all, for this member, right now. It is a per-member check, called after the object is constructed, so $this->member is available. Core's Tags extension uses it to hide itself when tagging is switched off site-wide, or when the member's group has both tagging and prefixes disabled — return Settings::i()->tags_enabled and ( !$this->member->group['gbw_disable_tagging'] or !$this->member->group['gbw_disable_prefixes'] ); (applications/core/extensions/core/MemberRestrictions/Tags.php lines 39–42). Returning FALSE suppresses both the list entry and the form fields. It does not suppress changesForHistory().
form( Form $form ) : void adds your fields to a form that is shared with every other extension and with core's own warning-level field. Add fields; do not call $form->values() yourself. Because the form is shared, field names live in a single global namespace — Form::add() keys elements by $input->name (system/Helpers/Form/Form.php line 459), and the resulting $values array is flat. Prefix your field names with something specific to your application.
save( array $values ) : array receives the entire form's values, including member_warnings and every other extension's fields, not just yours. Its job is to write your columns onto $this->member — but not to call save() on the member; core does that once, for everybody. The return value is a changelog, and its shape is fixed by what core does with it. Each entry must be keyed by a name of your choosing and hold an array with an old and a new key:
return array(
'idm_block_submissions' => array( 'old' => 0, 'new' => 1 )
);
Return an empty array when nothing changed. Everything you return is merged into one array, JSON-encoded, and written to core_member_history, so the values must survive json_encode() and come back out as scalars. Objects will not.
activeRestrictions() : array returns a plain numerically-indexed array of language keys — not translated text. The template does {lang="$restriction"} on each one (applications/core/dev/html/admin/memberprofile/warnings.phtml line 25). Downloads returns array( 'restriction_no_downloads' ) and defines that key in applications/downloads/dev/lang.php line 59 as "Restricted from submitting files". Return an empty array when the member is unrestricted; if every extension does, the box shows "No restrictions applied".
changesForHistory( array $changes, array $row ) : array is static, and is handed the whole merged changes array from that history record — not just the keys you produced. It must return an array of already-translated strings (core passes them straight to Language::formatList()), or an empty array if none of the keys in $changes are yours. $row is the raw core_member_history database row; core's Content extension uses $row['log_date'] to turn a stored timestamp into a human interval. Guard every lookup with isset(): this method runs for history records written by things that have nothing to do with your application, including the ACP ban form (applications/core/modules/admin/members/members.php lines 3130 and 3199) and the messenger toggle (applications/core/extensions/core/MemberACPProfileBlocks/Quotas.php line 109).
Who calls it, and when
Three call sites in the whole suite. All three use the same arguments:
Application::allExtensions( 'core', 'MemberRestrictions', TRUE, 'core', 'Content', FALSE )
The final FALSE is $construct, so this returns class name strings, not objects. Core instantiates them itself where it needs to. That matters: Application::constructExtensionClass(), which swallows RuntimeException and OutOfRangeException for most other extension points, is never involved here.
applications/core/extensions/core/MemberACPProfileBlocks/Warnings.phpline 49, inoutput()— rendering the box. Constructs each class with the member, callsenabled(), then collectsactiveRestrictions().- The same file, line 104, in
edit()— the Edit dialog. Constructs each class, callsenabled(), thenform()to build the form andsave()to process it. Reached fromapp=core&module=members&controller=members&do=editBlock&block=IPS\core\extensions\core\MemberACPProfileBlocks\Warnings&id=<member_id>, and only for an administrator holding thecore/members/member_editACP restriction — plusmember_edit_adminif the member being edited is themselves an administrator (members.phplines 1442–1446). applications/core/extensions/core/MemberHistory/Core.phpline 589, insideparseLogData(), in thecase 'warning'branch, when the decoded log data contains arestrictionskey. Calls$class::changesForHistory()statically without constructing anything.
The 'core', 'Content' arguments are $firstApp and $firstExtensionKey, which sort core's Content extension to the top of the list. That is purely cosmetic here — it puts the "Content" section, with the post restriction and moderation queue dates, at the top of the form. There is no override mechanism; ordering is the only effect.
The third argument, TRUE, is $checkAccess, which drops extensions belonging to applications the viewer cannot access (system/Application/Application.php lines 406–412). In the AdminCP an administrator with the core/applications/app_manage restriction passes automatically (canAccess(), line 5308). Note that the resolved list is cached in the datastore under the key extensions (line 442), and the access filtering happens while the list is being built. Whether that means the first caller's access rights are baked into the cache for everyone else is unverified — it reads that way in the source, but it is not something this article confirmed by observation.
How the header and the extension key are derived
Each section in the Edit dialog gets a header, and the language key for it is assembled from your class's namespace at Warnings.php lines 111–112:
$exploded = explode( '\\', $class ); $extensions[ $exploded[1] . '_' . $exploded[5] ] = $ext;
…then at line 127:
$form->addHeader( 'member_restrictions__' . $key );
Segment 1 is the application directory and segment 5 is the class name, so IPS\downloads\extensions\core\MemberRestrictions\Downloads gives downloads_Downloads and the key you must define is member_restrictions__downloads_Downloads (applications/downloads/dev/lang.php line 58). The indexes are hard-coded, so the six-segment namespace is not negotiable.
The inverted-checkbox convention
Every shipped implementation displays its restriction as a permission and stores it as a prohibition, and the resulting code looks wrong until you notice. Downloads' whole save() is:
if ( $this->member->idm_block_submissions == $values['idm_block_submissions'] )
{
$return['idm_block_submissions'] = array( 'old' => $this->member->idm_block_submissions, 'new' => !$values['idm_block_submissions'] );
$this->member->idm_block_submissions = !$values['idm_block_submissions'];
}
Testing for equality to detect a change is correct here, because form() populated the field with !$this->member->idm_block_submissions and the field is labelled "Allowed to submit files?" (applications/downloads/dev/lang.php line 511). The column means blocked; the field means allowed. Equal values therefore mean the administrator flipped it. Gallery, Tags and Content all follow the same pattern. Copy the convention or invert it consistently, but do not copy the == without also copying the ! in form() — on its own it inverts the meaning of the whole dialog, silently.
Failure modes
The three call sites have three different error-handling policies, so the same mistake behaves differently depending on which page the administrator is looking at.
A missing changesForHistory() is caught, logged, and shows raw JSON. The base class does not declare this method, so nothing forces you to write one, and applications/core/extensions/core/MemberHistory/Core.php line 591 calls it unconditionally on every registered extension. Omitting it raises Error: Call to undefined method. That escapes parseLogData() and is caught in system/Member/History.php lines 166–181 by catch( Throwable $e ), which writes the exception to the system log under the key member_history and returns the unformatted value. The administrator sees a JSON blob in the History tab's Data column instead of a sentence, on every restrictions record for that member — including ones your application had nothing to do with. Check ACP → Support → System Logs, filtered to member_history. Declare the method and return an empty array if you have nothing to say.
Almost anything else that raises an Error is a fatal on the member profile page. The two loops in Warnings.php are wrapped in catch ( Exception $e ) { } (lines 62 and 115). Error does not extend Exception, so it is not caught. A class that does not extend Restriction and does not declare enabled(), a constructor typed to something other than Member (a TypeError), a typo'd method name — all of these take down app=core&module=members&controller=members&do=view entirely, for every member, not just yours. The upside is that this class of mistake is loud and immediate.
An Exception thrown from your constructor or from enabled() silently removes your section. The empty catch discards it with no log entry. The box renders, your restrictions are absent from the list, your fields are absent from the Edit dialog, and nothing anywhere says why. If your section has vanished and the page still loads, this is the first thing to check.
Not overriding save() corrupts the history record. The inherited default is return $values; — the entire form's values, unchanged. Core merges that straight into $changes (Warnings.php lines 144–150) and JSON-encodes it into core_member_history. Every key then looks like a change to every changesForHistory() implementation in the suite, but none of them has the old/new shape those implementations expect. The exact symptom depends on what the field values encode to; the reliable statement is that the history record is wrong and stays wrong, because it is already on disk. If you declare form(), declare save().
Adding fields conditionally without guarding save(). If form() may skip a field, save() must not assume the key exists. Core's Tags extension is the only shipped implementation with a conditional form, and it is the only one that uses array_key_exists( 'bw_disable_tagging', $values ) before reading (Tags.php lines 72 and 80). Without the guard you get an undefined-key warning and, worse, a write of the null-coerced value.
The extension is never loaded. IPS\Application::extensions() does not scan the extensions/ directory. It reads applications/<app>/data/extensions.json, and any entry whose class fails class_exists() is dropped without a log entry. Register the extension through the Developer Center, or write the JSON yourself and then clear caches — the resolved list is stored in the datastore under extensions, and the Developer Center's own extension screens clear it with unset( Store::i()->extensions ) (applications/core/modules/admin/developer/extensions.php lines 313 and 342).
Missing language strings show as the raw key. Forgetting member_restrictions__<app>_<Class> leaves the section header showing that string literally, and a language key returned from activeRestrictions() that you never defined appears as the key itself in the red list. Nothing errors.
A note on core's own dead branch. applications/core/extensions/core/MemberRestrictions/Content.php line 107 maps 'banned' => 'temp_ban' and so looks for a temp_ban key in $changes, but no code in 5.0.19 that this article could find writes temp_ban into a restrictions array — the ban controller writes ban, which MemberHistory/Core.php line 567 handles itself. Whether that branch is legacy or reachable by some path not found here is unverified. Do not use it as evidence that changesForHistory() receives keys other extensions produced; it does receive them, but that particular key does not appear to be one of them.
A complete working example
Downloads' extension is the shortest real one and exercises the whole contract. applications/downloads/extensions/core/MemberRestrictions/Downloads.php, verbatim apart from the copyright docblock:
<?php
namespace IPS\downloads\extensions\core\MemberRestrictions;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\core\MemberACPProfile\Restriction;
use IPS\Helpers\Form;
use IPS\Helpers\Form\YesNo;
use IPS\Member;
use function defined;
use function intval;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
/**
* @brief Member Restrictions: Downloads
*/
class Downloads extends Restriction
{
/**
* Modify Edit Restrictions form
*
* @param Form $form The form
* @return void
*/
public function form( Form $form ) : void
{
$form->add( new YesNo( 'idm_block_submissions', !$this->member->idm_block_submissions ) );
}
/**
* Save Form
*
* @param array $values Values from form
* @return array
*/
public function save( array $values ): array
{
$return = array();
if ( $this->member->idm_block_submissions == $values['idm_block_submissions'] )
{
$return['idm_block_submissions'] = array( 'old' => $this->member->idm_block_submissions, 'new' => !$values['idm_block_submissions'] );
$this->member->idm_block_submissions = !$values['idm_block_submissions'];
}
return $return;
}
/**
* What restrictions are active on the account?
*
* @return array
*/
public function activeRestrictions(): array
{
$return = array();
if ( $this->member->idm_block_submissions )
{
$return[] = 'restriction_no_downloads';
}
return $return;
}
/**
* Get details of a change to show on history
*
* @param array $changes Changes as set in save()
* @param array $row Row of data from member history table.
* @return array
*/
public static function changesForHistory( array $changes, array $row ): array
{
if ( isset( $changes['idm_block_submissions'] ) )
{
return array( Member::loggedIn()->language()->addToStack( 'history_restrictions_downloads_' . intval( $changes['idm_block_submissions']['new'] ) ) );
}
return array();
}
}
One detail is worth reading carefully. Line 56 records 'new' => !$values['idm_block_submissions'] and line 57 assigns the same expression to the column, so the new value written to history is the value the column is about to hold, not the value the administrator ticked. That is what changesForHistory() later reads back through intval() to choose between the _0 and _1 language strings. Record the stored value, not the submitted one, or your history entries will read backwards.
The three supporting pieces. In applications/downloads/data/extensions.json lines 47–49, nested inside the file's top-level "core" key:
{
"core": {
"MemberRestrictions": {
"Downloads": "IPS\\downloads\\extensions\\core\\MemberRestrictions\\Downloads"
}
}
}
In applications/downloads/dev/lang.php, four keys — the section header, the field label, and one history string per state:
'member_restrictions__downloads_Downloads' => "Downloads", 'restriction_no_downloads' => "Restricted from submitting files", 'idm_block_submissions' => "Allowed to submit files?", 'history_restrictions_downloads_1' => "disabled ability to submit files", 'history_restrictions_downloads_0' => "restored ability to submit files",
And, separately from the extension entirely, the enforcement: Category::canOnAny() (Category.php line 752, the check at line 757) and File::canCreate() (File.php line 1080, the check at line 1082) both test $member->idm_block_submissions directly. There is no canSubmit() method on the Downloads category class.
What else ships with one
Only three applications in a full 5.0.19 install register a core/MemberRestrictions extension, for four extensions in total: core (Content and Tags), downloads (Downloads) and gallery (Gallery). Forums, Pages, Calendar, Blog and Commerce have no core.MemberRestrictions key in their data/extensions.json at all. If you want a harder example than Downloads, read applications/core/extensions/core/MemberRestrictions/Content.php — it uses Date fields with 'unlimited' => -1, has to cope with save() receiving either a DateTime object or a scalar, and its changesForHistory() renders a timestamp as an interval relative to $row['log_date'].
Verified against
Read from the source of Invision Community 5.0.19. Key files: applications/core/sources/MemberACPProfile/Restriction.php, applications/core/extensions/core/MemberACPProfileBlocks/Warnings.php, applications/core/extensions/core/MemberHistory/Core.php (lines 480–600), system/Member/History.php (lines 150–185), system/Member/Member.php (logHistory(), lines 5757–5777), system/Application/Application.php (allExtensions(), constructExtensionClass(), canAccess()), system/Helpers/Form/Form.php (add()), applications/core/dev/html/admin/memberprofile/warnings.phtml, applications/core/data/defaults/extensions/MemberRestrictions.txt, and all four implementations in applications/core, applications/downloads and applications/gallery. Nothing here is inferred from Invision Community 4.
Recommended Comments