A member in Invision Community has one primary group and any number of secondary groups. Every per-group setting therefore has several candidate values at once, and something has to decide which one the member actually gets: five attachments or fifty, a signature or no signature, a thirty-second flood control or none at all. A core/GroupLimits extension is how an application declares that decision for the group columns it owns. It does not add settings and it does not enforce anything — it is a rulebook that says, for each column, whether the biggest number wins, the smallest wins, zero means "unlimited", −1 means "unlimited", or a callback should work it out. Core's own description of the extension, in applications/core/dev/lang.php line 1290, is "Control how group settings should be handled for secondary groups."
If your application adds columns to core_groups through a core/GroupForm extension and does not add a matching core/GroupLimits extension, those columns are still merged — just by the fallback rule, which is "the higher value wins". That is often wrong, and it fails silently.
The contract
The abstract, system/Extensions/GroupLimitsAbstract.php, is among the smallest in the suite — 29 lines, tied with MFAAreaAbstract.php. One abstract method, no properties, no constructor:
namespace IPS\Extensions;
abstract class GroupLimitsAbstract
{
/**
* Get group limits by priority
*
* @return array
*/
abstract public function getLimits(): array;
}
getLimits() is called with no arguments and must return an array. Core reads five keys from it, each one guarded by !empty(), so any key you omit is simply skipped. The default file the Developer Center generates (applications/core/data/defaults/extensions/GroupLimits.txt) returns only four of them — callback is not in the template.
return array(
/* Columns that must NOT be merged at all. The primary group's
value is used verbatim. */
'exclude' => array( 'g_id', 'g_icon' ),
/* The LOWEST value across all the member's groups wins.
For things like flood control where a smaller number is a
weaker restriction. */
'lessIsMore' => array( 'g_search_flood' ),
/* -1 means "unlimited" and beats everything. Otherwise the
higher value wins. */
'neg1IsBest' => array( 'g_attach_max' ),
/* 0 means "unlimited"/"not restricted" and beats everything.
Otherwise the higher value wins. */
'zeroIsBest' => array( 'g_ppd_limit' ),
/* Per-column callables. Anything callable is accepted -
array( $this, '_method' ) or a closure. */
'callback' => array( 'g_edit_cutoff' => array( $this, '_editCutoff' ) )
);
The four list keys are plain numerically-indexed arrays of column names. callback is keyed by column name.
The callback signature
Callbacks are not declared on the abstract and are never type-checked by core. The signature is fixed by the call site in system/Member/Member.php line 1061:
$callbackFunction = $callback[ $k ]; $result = $callbackFunction( $this->_group, $_data, $k, $this->_data );
Every callback in applications/core/extensions/core/GroupLimits/GroupLimits.php declares the same parameter list, character for character — _editCutoff() at line 103 is representative:
public function _editCutoff( array $a, array $b, string $k, array $member ) : int
The return type is not uniform, and nothing requires it to be: core uses : int (_editCutoff, _attachMaxPerPost, _hideOnlineList), : int|string (_perAppSettings), : ?string (_signatureLimits, _photoVars), : string (_clubNodes, _createClub), : array (_displayNameChanges) and : mixed (_displayNameDate, _modPostUnitType, _modPostUnit). Pick whatever your return values actually are.
The four arguments are:
$a— the accumulated group array so far. On the first secondary group this is the primary group's data; on the second it already contains the result of merging the first. It is not "the primary group".$b— the secondary group currently being merged in, as a flat array.$k— the column name the callback was registered for. Use$a[$k]and$b[$k]rather than hard-coding the name, so the same method can serve several columns (core registers_perAppSettingsfor five).$member— the rawcore_membersrow for the member, as an array. Core's_modPostUnit()reads$member['member_posts']and$member['joined']from it. This is member data, not aMemberobject.
Both $a and $b contain the core_groups columns and the unpacked bitoption keys, because core builds each of them by merging the group row with the unpacked bitoptions: $b at line 1014, array_merge( $group->_data, $group->g_bitoptions->asArray() ), and the initial $a at line 956, array_merge( $group->data(), $group->g_bitoptions->asArray() ) — Group::data() is a one-line getter that returns $this->_data, so the two are the same shape. Bitoption values are real booleans — Bitwise::offsetGet() in system/Patterns/Bitwise.php is typed : bool.
What the return value does
Three outcomes, decided at system/Member/Member.php lines 1063–1070:
if( is_array( $result ) )
{
$this->_group = array_merge( $this->_group, $result );
}
else if( $result !== NULL )
{
$this->_group[ $k ] = $result;
}
- An array is merged into the whole group array, so a callback may set columns other than its own. Core uses this for pairs that only make sense together:
_displayNameChanges()returns bothg_dname_dateandg_dname_changes, and_modPostUnit()returns bothg_mod_post_unitandgbw_mod_post_unit_type. An empty array is a no-op, which is how_displayNameChanges()says "keep what you have" at line 455. - NULL means make no change. The accumulated value survives untouched.
- Anything else, including
0,FALSEand'', is assigned to that one column.
Be careful with NULL. Core's _signatureLimits() and _photoVars() both carry the comment /* Set no limits */ above a return NULL (lines 279–280 and 339–340 of applications/core/extensions/core/GroupLimits/GroupLimits.php). Given the call site above, NULL cannot clear a value; it only leaves the accumulated one in place. In _signatureLimits()'s early return at lines 218–220, guarded by if( !$a[ $k ] ), that happens to be the same thing, because the accumulated value was already empty. In the later one it is not. Read the mechanism, not the comment: to clear a column you must return the empty value explicitly.
Who calls it
Exactly one place in the entire suite. IPS\Member::get_group(), the magic getter behind $member->group, at system/Member/Member.php line 969:
foreach (Application::allExtensions( 'core', 'GroupLimits', FALSE, 'core' ) as $key => $extension )
{
$appLimits = $extension->getLimits();
if( !empty( $appLimits[ 'neg1IsBest' ] ) )
{
$neg1IsBest = array_merge( $neg1IsBest, $appLimits[ 'neg1IsBest' ] );
}
...
}
Three things follow from that call.
FALSE is $checkAccess, so your extension runs for every member regardless of application access, and Application::constructExtensionClass() instantiates it with NULL. The abstract declares no constructor, so you normally need none; if you declare one, it must tolerate being handed a single NULL argument.
'core' is $firstApp, which sorts the core application's extension to the front of the list. That only matters for callback: the four list buckets are merged with numeric keys and therefore only ever grow, but array_merge on the string-keyed callback array lets a later entry replace an earlier one. Because core is first, a third-party application can override a core callback by registering its own for the same column name. There is no way to remove a column from one of the list buckets once any application has added it.
And the whole block is inside if ( !empty( $this->_data['mgroup_others'] ) ) at line 959. No secondary groups, no merge, no call to your extension at all.
The merge itself, and why bucket choice is not just style
For each secondary group, core iterates that group's own keys and takes the first matching branch (lines 1016–1080). The order is fixed:
foreach( $_data as $k => $v )
{
if ( ! in_array( $k, $exclude ) )
{
if ( in_array( $k, $zeroIsBest ) ) { ... }
else if( in_array( $k, $neg1IsBest ) ) { ... }
else if ( in_array( $k, $lessIsMore ) ) { ... }
else if ( array_key_exists( $k, $callback ) ){ ... }
else
{
if ( !isset( $this->_group[ $k ] ) OR $v > $this->_group[ $k ] )
{
$this->_group[ $k ] = $v;
}
}
}
}
So exclude beats zeroIsBest beats neg1IsBest beats lessIsMore beats callback beats the default. Listing the same column in two buckets is legal and produces no warning; the earlier branch simply wins. Core does this itself, twice, in applications/core/extensions/core/GroupLimits/GroupLimits.php: g_max_bgimg_upload is in both neg1IsBest (line 43) and zeroIsBest (line 44), so it is treated as zeroIsBest; g_pm_flood_mins is in both lessIsMore (line 42) and neg1IsBest (line 43), so it is treated as neg1IsBest and the lessIsMore entry never applies. Whether those overlaps are deliberate is unverified — the effective behaviour is not.
Note also that exclude, like the other buckets, is global. Excluding a column stops every application from merging it, and the primary group's value is what the member gets.
The default branch is a trap for non-numeric columns
A column you never mention lands on $v > $this->_group[ $k ]. For an integer permission that is usually right — 1 beats 0, so the most permissive group wins. For anything else you get PHP's > on two strings: numeric-looking strings are compared as numbers, everything else byte by byte. That is deterministic, but for a structured value it is meaningless — {"limit_sim":5,...} versus {"limit_sim":50,...} is decided by whichever character differs first. This is exactly why applications/downloads/extensions/core/GroupLimits/Downloads.php puts idm_restrictions, which holds JSON, behind a callback that decodes both sides, takes the lower of limit_sim and min_posts, takes the higher (with 0 meaning unlimited) of the six bandwidth and download counters, and re-encodes. Any column holding JSON, a serialised value, a colon-delimited string such as g_signature_limits, or a comma-separated list, needs a callback or an exclude entry.
Failure modes
Most ways to get this wrong are silent. The loud ones are split between core's file and yours — some surface as an error inside system/Member/Member.php with your extension named only further down the stack trace, others are reported against your own file. Read the whole trace, not the top frame.
The extension is never loaded. Nothing happens, nowhere. IPS\Application::extensions() does not scan the extensions/ directory; it reads applications/<app>/data/extensions.json, and skips any entry whose class fails class_exists() without logging. Add the extension through the Developer Center, or write the JSON entry yourself. The resolved list is then cached in the datastore under the key extensions, so a hand-edited JSON file also needs ACP → Support → Clear Caches before it takes effect.
You are testing with an account that has no secondary group. The merge block is skipped entirely (line 959), so nothing you write here can possibly run. This is the most common reason a correct extension appears to do nothing. Give the test account a secondary group, and remember the merged array is computed lazily per Member object and held in $this->_group — it is never written to the database, so there is no stale copy to clear, but a long-running task holding a Member object will not see a group change mid-run.
You named a column that does not exist. The loop iterates the keys of the secondary group's data. A bucket entry or callback registered against a column that is not in core_groups and is not a bitoption key is simply never reached. No error, no warning. Check the spelling against the column your core/GroupForm extension actually created.
getLimits() is not declared. This one is loud. The class cannot be instantiated, and because class_exists() triggers the autoloader while building the extension list, you get a compile-time fatal the moment the list is built. That list is only built from the one call site above, so the blast radius is precisely the merge's own precondition: every page load, front end and ACP, for any member who has at least one secondary group. Members with none never load your class and see nothing wrong, which is why this can look intermittent. The error is reported against your own file, at the line of the class keyword:
Fatal error: Class IPS\acme\extensions\core\GroupLimits\Acme contains 1 abstract method and must therefore be declared abstract or implement the remaining methods (IPS\Extensions\GroupLimitsAbstract::getLimits)
A bucket holds something that is not an array. array_merge( $neg1IsBest, 'g_foo' ) is a TypeError in PHP 8. It is thrown from one of the five array_merge() calls in system/Member/Member.php at lines 975, 980, 985, 990 and 995, on any page load by any member with a secondary group, with your extension named only deep in the stack trace. The same applies to returning something other than an array from getLimits() — the return type : array turns that into a TypeError in your own file instead.
A callback is not callable, or its parameters are typed wrongly. array( $this, '_typo' ) raises Error: Call to undefined method, reported at system/Member/Member.php line 1061. A parameter typed int $a instead raises a TypeError reported against your file, at the callback's own declaration, with "called in .../system/Member/Member.php on line 1061" appended. Neither is caught anywhere — the try block a few lines above wraps only Group::load() and closes at line 1012, and a TypeError is not an Exception in any case. The member cannot load any page. Note that a closure may declare fewer parameters than core passes — Downloads' callback is function( $a, $b, $k ) with three — because PHP allows surplus arguments to user-defined functions. It may not declare more without defaults.
Your constructor throws RuntimeException or OutOfRangeException. Application::constructExtensionClass() catches exactly those two and returns null, which drops your extension from the list with no log entry. The symptom is that your columns fall back to the default "higher wins" rule for every member on the site.
A callback reads a sibling column and gets a half-merged value. Core's _editCutoff() reads $a['g_edit_posts'], and g_edit_posts has its own callback. Because the outer loop walks the secondary group's keys in array order, whether the sibling has already been merged when your callback runs depends on the position of the columns in the row. Do not rely on it. If two columns must be decided together, decide both in one callback and return an array, as _displayNameChanges() and _modPostUnit() do.
A complete working example
Gallery's is the shortest real implementation in the suite and shows both styles — buckets and a closure. applications/gallery/extensions/core/GroupLimits/Album.php — the code below is verbatim, with only the file-header and class docblocks removed and the indentation reflowed:
<?php
namespace IPS\gallery\extensions\core\GroupLimits;
use IPS\Extensions\GroupLimitsAbstract;
use function count;
use function defined;
use function max;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Album extends GroupLimitsAbstract
{
/**
* Get group limits by priority
*
* @return array
*/
public function getLimits(): array
{
return array (
'exclude' => array(),
'lessIsMore' => array(),
'neg1IsBest' => array(),
'zeroIsBest' => array( 'g_img_album_limit', 'g_max_upload', 'g_max_transfer', 'g_max_views', 'g_movie_size' ),
'callback' => array( 'g_album_limit' => function( $a, $b, $k, $member ) {
/* We only want to use the limit if this group can create albums */
$limits = array();
if ( $a['g_create_albums'] )
{
if ( $a[ $k ] == 0 )
{
return 0;
}
else
{
$limits[] = $a[ $k ];
}
}
if ( $b['g_create_albums'] )
{
if ( $b[ $k ] == 0 )
{
return 0;
}
else
{
$limits[] = $b[ $k ];
}
}
return count( $limits ) ? max( $limits ) : 0;
} )
);
}
}
The closure is the pattern worth copying: a limit that only exists when another column grants the underlying ability must ignore groups that do not grant it. A group that cannot create albums at all still has a value in g_album_limit, and merging it blindly would either cap or uncap the member for no reason. Core does the same thing three times — _editCutoff() gated on g_edit_posts, _attachMaxPerPost() gated on g_attach_max, and _photoVars() gated on g_edit_profile.
The matching entry in applications/gallery/data/extensions.json is the fragment below. The real file has dozens of other extension types alongside GroupLimits under the same core key — add yours, do not replace the file:
{
"core": {
"GroupLimits": {
"Album": "IPS\\gallery\\extensions\\core\\GroupLimits\\Album"
}
}
}
What else ships with one
Only three applications in a full 5.0.19 install register a core/GroupLimits extension: core (GroupLimits), downloads (Downloads) and gallery (Album). Forums, Pages, Calendar, Blog and Commerce have no core.GroupLimits key in their data/extensions.json at all, so every group column those applications own is merged by the default "higher wins" rule. If you are looking for a worked example of a hard case, read core's own file: _signatureLimits() merging a colon-delimited string field by field, and _displayNameChanges() comparing "three changes per year" against "one change per month" by dividing the period by the count.
Verified against
Read from the source of Invision Community 5.0.19. Key files: system/Extensions/GroupLimitsAbstract.php, system/Member/Member.php (get_group(), lines 942–1093), system/Application/Application.php (allExtensions() line 355, extensions() line 911, constructExtensionClass() line 469), system/Member/Group.php (data(), line 277), system/Patterns/Bitwise.php (offsetGet() line 115, asArray() line 163), applications/core/data/defaults/extensions/GroupLimits.txt, applications/core/dev/lang.php, and all three implementations: applications/core/extensions/core/GroupLimits/GroupLimits.php, applications/downloads/extensions/core/GroupLimits/Downloads.php, applications/gallery/extensions/core/GroupLimits/Album.php. Nothing here is inferred from Invision Community 4.
Recommended Comments