A core/GroupForm extension adds a tab of fields to the member group form in the ACP (Members → Groups → edit), writes those values back onto the group when it is saved, and gets a say in what happens when a group is copied or deleted. It is the supported way for an application to attach its own per-group settings.
Core calls it from exactly four places: IPS\core\modules\admin\members\groups::form() (build and save), and three methods on IPS\Member\Group — __clone(), delete() and canDelete(). Every one of them fetches the extensions with the same call:
Application::allExtensions( 'core', 'GroupForm', FALSE, 'core', 'GroupSettings', TRUE );
The FALSE is $checkAccess — your extension runs regardless of whether the acting admin can access your application. The 'core', 'GroupSettings' arguments force core's own tab to sort first, so it is always the default active tab; everything else follows in application order. The array is keyed {appDirectory}_{extensionKey}, e.g. blog_Blog, and that key is what core turns into your tab's language string.
The contract
namespace IPS\Extensions;
abstract class GroupFormAbstract
{
/* REQUIRED. Add your fields to $form. Core has already called
$form->addTab( 'group__{app}_{key}' ) before this runs, so
everything you add lands on your own tab automatically.
$group may be an unsaved `new Group` with NO data at all. */
abstract public function process( Form $form, Group $group ) : void;
/* REQUIRED. $values is the FLAT, WHOLE-FORM value array from
$form->values() - every field from every app, keyed by field
name. Mutate $group; do NOT call $group->save() (the caller
does it for you immediately afterwards). */
abstract public function save( array $values, Group $group ) : void;
/* OPTIONAL, default no-op. Called from Group::__clone() AFTER the
new row has already been INSERTed. If you change $newGroup you
must call $newGroup->save() yourself. */
public function cloneGroup( Group $oldGroup, Group $newGroup ) : void {}
/* OPTIONAL, default TRUE. Group::canDelete() returns FALSE if ANY
extension returns FALSE. Advisory only - see below. */
public function canDelete( Group $group ) : bool { return TRUE; }
/* OPTIONAL, default no-op. Called from Group::delete() BEFORE the
core_groups row is removed, so $group->g_id is still valid. */
public function delete( Group $group ) : void {}
}
No method declares a thrown exception, and nothing catches one on your behalf. The only try/catch anywhere near these calls is in the ACP delete controller, and it misattributes what it catches — see below.
A minimal example
For an app in applications/myapp. First, register it in applications/myapp/data/extensions.json:
{
"core": {
"GroupForm": {
"MySettings": "IPS\\myapp\\extensions\\core\\GroupForm\\MySettings"
}
}
}
Then applications/myapp/extensions/core/GroupForm/MySettings.php:
<?php
namespace IPS\myapp\extensions\core\GroupForm;
use IPS\Extensions\GroupFormAbstract;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Number;
use IPS\Helpers\Form\YesNo;
use IPS\Member\Group;
use IPS\Settings;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class MySettings extends GroupFormAbstract
{
public function process( Form $form, Group $group ) : void
{
$form->addHeader( 'myapp_group_widgets' );
/* $group->g_id is NULL for a new group and every column reads
back as NULL, so seed defaults explicitly. */
$form->add( new YesNo(
'myapp_can_widget',
$group->g_id ? (bool) $group->myapp_can_widget : TRUE,
FALSE,
array( 'togglesOn' => array( 'myapp_widget_limit' ) )
) );
/* Guests can never own widgets, so this field is conditional -
save() must repeat the same condition. */
if ( $group->g_id != Settings::i()->guest_group )
{
$form->add( new Number(
'myapp_widget_limit',
$group->g_id ? $group->myapp_widget_limit : 10,
FALSE,
array( 'unlimited' => -1 ),
NULL, NULL, NULL,
'myapp_widget_limit' /* html id, matches togglesOn */
) );
}
}
public function save( array $values, Group $group ) : void
{
$group->myapp_can_widget = (int) $values['myapp_can_widget'];
if ( array_key_exists( 'myapp_widget_limit', $values ) )
{
$group->myapp_widget_limit = $values['myapp_can_widget']
? (int) $values['myapp_widget_limit']
: 0;
}
}
}
Three language strings are required in applications/myapp/dev/lang.php — the tab, and one per field (the field name is the label key):
'group__myapp_MySettings' => "My App", 'myapp_group_widgets' => "Widgets", 'myapp_can_widget' => "Can create widgets?", 'myapp_widget_limit' => "Maximum widgets",
And the columns have to exist. Add them in applications/myapp/setup/install/queries.json, which is also what core reads back at uninstall time to drop them again:
[
{
"method": "addColumn",
"params": [
"core_groups",
{
"name": "myapp_can_widget",
"type": "TINYINT",
"length": 1,
"allow_null": true,
"default": "0",
"comment": "",
"unsigned": true,
"auto_increment": false
}
]
}
]
Saving a group dies with "Unknown column 'myapp_can_widget' in 'field list'"
IPS\Member\Group is a plain ActiveRecord. Its __set() puts any property you assign into $this->changed, and save() hands changed straight to Db::i()->update( 'core_groups', ... ). There is no whitelist, so the first sign that your column does not exist is a MySQL error at save time, after the form has already validated.
This is worth calling out because the extension stub that the Developer Center generates (applications/core/data/defaults/extensions/GroupForm.txt) does exactly this and nothing else:
public function save( array $values, SystemGroup $group ) : void
{
$group->example = $values['example'];
}
while the ACP's own description of the extension (language key ext__GroupForm) says the opposite: "Recommended to store your group settings as an application setting, rather than add to the core_groups table." Pick one and be consistent. If you store settings outside core_groups, do not assign to $group at all; write to your own table in save(). If you add columns, add them via queries.json as above — note that every first-party group column (g_blog_*, idm_*, g_create_albums …) lives in core's data/schema.json, not in the owning app's, so you cannot copy that pattern.
Reading is safe in the opposite direction: ActiveRecord::__get() returns NULL for an unknown key rather than throwing, so a typo in a column name reads as NULL forever and silently.
"Undefined array key" in your own save(), and the setting quietly becomes 0
$values only contains fields that were actually added to the form. Any if in process() that skips a field must be repeated in save(), or you get a PHP warning and store NULL.
Core gets this wrong itself. IPS\core\extensions\core\GroupForm\Social::process() only adds gbw_view_helpful when Settings::i()->reputation_enabled, but save() pushes it into $bwKeys for every non-guest group and then does an unguarded read:
foreach ( $bwKeys as $k )
{
$group->g_bitoptions[ $k ] = $values[ $k ]; /* no isset() */
}
Everywhere else core guards. Follow the guarded pattern:
foreach ( array( 'myapp_widget_limit', 'myapp_widget_quota' ) as $k )
{
if ( array_key_exists( $k, $values ) )
{
$group->$k = $values[ $k ];
}
}
Use array_key_exists() rather than isset() — a legitimately NULL value is indistinguishable from an absent one under isset().
A field the admin edited comes back with its old value
If a field is hidden by a togglesOn/togglesOff/toggles relationship whose controlling field is off, Form::values() does not omit it and does not return the POSTed value. It returns the element's defaultValue — the second constructor argument, i.e. whatever you seeded the field with when you built the form:
if ( $element->htmlId and in_array( $element->htmlId, $htmlIdsToIgnore ) )
{
$values[ $_name ] = $element->defaultValue;
continue;
}
So a toggled-off field silently reverts to the current database value on an existing group, and to your hard-coded default on a new one. That is why core normalises by hand before writing, e.g. Content::save():
if ( !$values['g_attach'] )
{
$values['gbw_delete_attachments'] = false;
}
Do the same: if your master switch is off, force the dependent values yourself instead of trusting what came back.
The tab is labelled "group__myapp_MySettings" in the ACP
The controller adds your tab before it calls you, using the extension key, not anything you control:
foreach ( $extensions as $k => $class )
{
$form->addTab( 'group__' . $k );
$class->process( $form, $group );
}
With $k = "myapp_MySettings" the required key is group__myapp_MySettings. When a language key is missing, Lang::replaceWords() falls through to $replacement = $values['key'] — it prints the key itself. So the symptom is a tab button reading group__myapp_MySettings, and nothing in the logs.
The key is derived from the file name of the extension. Renaming MySettings.php to Groups.php silently changes the required language key, and your old string becomes dead.
The good news is that addTab() alone does not create a tab. It only records a title and sets currentTab; Form::__toString() builds the rendered tab list from $this->elements, which stays untouched until something is actually added. So a process() that adds nothing produces no tab, and the missing string is never printed — which is why Commerce's PreventDeletion, a canDelete()-only extension with an empty process(), ships with no group__nexus_PreventDeletion string at all. (Be careful with the third argument to addTab(): passing a $blurbLang inserts an element, and that alone is enough to make the tab appear.)
The Developer Center does not know that, though. Its missing-strings scan (admin/developer/details.php) builds group__{app}_{key} from extensions.json for every GroupForm extension, whether or not that extension renders anything, so a canDelete()-only extension will always be reported as a missing string.
The tab never appears at all, and there is no error anywhere
Three separate silent-skip paths, all of which produce exactly the same nothing:
Application::extensions()skips any entry inextensions.jsonwhere!is_string( $classname ) or !class_exists( $classname )— a namespace typo, a wrong file name, or a parse error in your class removes the tab with no message.Application::constructExtensionClass()catchesRuntimeExceptionandOutOfRangeExceptionthrown from your constructor and returnsnull. Any other exception type propagates and takes the page down instead.- The whole list is cached in the
extensionsdatastore (Store::i()->extensions), keyed only by extension type. It is rebuilt when anApplicationrecord is saved or deleted (Application::$cachesincludes'extensions') or from the Developer Center's Extensions screen. Hand-editingextensions.jsonon a live site changes nothing until that cache is cleared.
Also note allExtensions() skips disabled applications entirely and, when RECOVERY_MODE is on, skips every non-IPS application. In recovery mode your delete() and cloneGroup() hooks do not run, so a group deleted in recovery mode leaves your rows orphaned.
Copying a group copies the wrong group's data (or nothing at all)
This is the worst one, because the signature lies. Group::__clone():
public function __clone()
{
$oldId = $this->g_id;
$oldGroup = $this; /* NOT a copy - an alias */
parent::__clone(); /* sets g_id = NULL, then INSERTs, giving
$this (and therefore $oldGroup) the NEW id */
...
foreach ( $extensions as $class )
{
$class->cloneGroup( $oldGroup, $this );
}
}
PHP's clone invokes __clone() on the new object, so $this is already the copy. $oldGroup = $this takes an object handle, not a value copy. By the time your cloneGroup() runs, $oldGroup and $newGroup are the same object and both report the new group's g_id. The real source id is held in the local $oldId, which is never passed to you.
The practical consequence: you cannot write SELECT ... WHERE group_id = $oldGroup->g_id to copy rows from your own table — that reads the brand-new group, finds nothing, and the copy silently comes out empty. Core's own GroupSettings::cloneGroup() is unaffected only because it reads $oldGroup->g_icon, a core_groups column value that parent::__clone() already copied into the new row.
That is the argument for storing per-group data as core_groups columns: the row copy is automatic and cloneGroup() becomes unnecessary. If you store data in your own table keyed by group id, there is no reliable way to identify the source group from inside cloneGroup() in 5.0.19 — do not build on the first argument.
Changes made in cloneGroup() are not persisted
Group::__clone() never calls save() after the extension loop; the only save is the INSERT inside parent::__clone(), which happened before you were called. If you assign to $newGroup you must persist it yourself, exactly as core does:
public function cloneGroup( Group $oldGroup, Group $newGroup ) : void
{
$newGroup->myapp_licence_key = NULL;
$newGroup->save(); /* required - nothing else will */
}
Without the save() the property is set on an in-memory object that is then discarded, and the ACP redirects to the new group's edit form showing the un-changed value.
Deleting a group reports a 404 "We could not locate the item you are trying to view" (2C108/2)
This is the trap whose error surfaces nowhere near its cause. The ACP delete action wraps everything — including $group->canDelete() and $group->delete() — in one try block:
try
{
$group = Group::load( Request::i()->id );
if( !$group->canDelete() ) { ... }
...
$group->delete();
}
catch ( OutOfRangeException $e )
{
Output::i()->error( 'node_error', '2C108/2', 404, '' );
}
catch( InvalidArgumentException $e )
{
Output::i()->error( 'cannot_delete_protected_group', '1C108/4', 403, '' );
}
An OutOfRangeException escaping your delete() or canDelete() — and that is the exception every ActiveRecord::load() and Node::load() in IPS throws when a record is missing — is reported to the admin as "the group you are trying to view does not exist", error code 2C108/2, HTTP 404. An InvalidArgumentException is reported as "You cannot remove the preset groups". Neither message mentions your application, and neither is logged as a fault in your app.
Worse, Group::delete() runs the extension loop before parent::delete(), with no transaction:
foreach ( $extensions as $class )
{
$class->delete( $this );
}
parent::delete();
Core sorts itself first, so by the time your delete() throws, GroupSettings::delete() has already deleted the group's icon file — but the core_groups row survives. The admin sees a 404, the group is still listed, and its icon is gone. Catch everything inside your own delete():
public function delete( Group $group ) : void
{
try
{
Db::i()->delete( 'myapp_group_settings', array( 'group_id=?', $group->g_id ) );
}
catch ( Exception $e ) { }
}
One bad app blanks the group form, or 500s the whole group list
Nothing in any of the four call sites is defensive. process() and save() are called in a bare foreach in groups::form(), so an exception in your process() propagates straight out of the controller and takes down the entire group add/edit screen for every application, not just your tab. There is no per-extension isolation to fall back on.
canDelete() is the more dangerous one, because of where it is called from. The group listing calls it once per row inside the rowButtons closure:
if ( Member::loggedIn()->hasAcpRestriction( 'core', 'members', 'groups_delete' )
AND Group::load( $row['g_id'] )->canDelete() )
An exception there breaks Members → Groups itself, so the admin cannot reach any group form to disable whatever is broken. It also means every query you run in canDelete() runs once per group per page load — Commerce's PreventDeletion::canDelete() issues two.
$group->my_column is NULL in process() when adding a new group
The controller builds the form from new Group when there is no id in the request. That object has an empty _data array, so every column reads back as NULL — not the schema default. This is why core writes $group->g_id ? $group->g_attach_max : 500000 throughout. Blog's save() spells out the other half of the problem:
/* We intval here because (some of) the columns do not accept null values, but these are null when creating a new group */ $group->g_blog_maxblogs = (int) $values['g_blog_maxblogs'];
Note also the save order for a new group: the controller calls $group->save() once before the extension loop, purely so the group has an id for translatable fields, then calls each save(), then saves again. So $group->g_id is always populated inside save(), even on creation — but it was NULL in process() moments earlier.
Members in secondary groups get the wrong value for your setting
This is the "reading it back" half, and it is where most third-party group settings go wrong. $member->group is not a Group object. Member::get_group() returns a flat array built by merging the primary group's _data with g_bitoptions->asArray(), and then merging every secondary group over the top. The default merge rule for a key nobody has registered is "bigger number wins":
if ( !isset( $this->_group[ $k ] ) OR $v > $this->_group[ $k ] )
{
$this->_group[ $k ] = $v;
}
For a boolean permission that is usually what you want (any group granting it wins). For anything else it is wrong:
| Your semantics | Default merge does | Register |
|---|---|---|
0 means unlimited | picks the largest finite limit; 0 always loses | zeroIsBest |
-1 means unlimited | -1 always loses to any real number | neg1IsBest |
| lower is more permissive (a wait period) | picks the longest wait | lessIsMore |
| a CSV / JSON blob | string comparison, then an arbitrary winner | callback |
| meaningless to merge (an id, an icon) | merges anyway | exclude |
The registration point is a second extension, core/GroupLimits, whose single getLimits() method returns those five keys. Downloads' is the model to copy:
public function getLimits(): array
{
return array(
'exclude' => array(),
'lessIsMore' => array( 'idm_wait_period' ),
'neg1IsBest' => array( 'idm_max_size' ),
'zeroIsBest' => array( 'idm_throttling' ),
'callback' => array( 'idm_restrictions' => function( $a, $b, $k ) { ... } )
);
}
A callback receives ( $mergedSoFar, $thisSecondaryGroupData, $key, $memberData ); returning an array merges it wholesale into the group data, returning anything non-NULL sets that one key, and returning NULL leaves the value untouched.
Two further points about reading. First, use $member->group['myapp_x'] ?? $default — if your app is disabled or its column has been dropped, the key is simply absent and you get an undefined-key warning in front-end code. Second, group data is served from the groups datastore (Group::getStore()). Saving through the Group object clears it, because Group::$caches lists 'groups'; a raw Db::i()->update( 'core_groups', ... ) does not, and readers will keep seeing the old value until something else clears the cache.
Another app's setting overwrites yours (or yours overwrites theirs)
$values is one flat array for the entire form. Form::_insert() keys elements by $input->name, and Form::values() writes $values[ $element->name ], so two extensions that both add a field called enabled or max_items produce two <input> elements with the same name attribute on the same page. The browser posts one value, both extensions read it, and the field label (which is the same language key) will show whichever string won.
There is no namespacing. Prefix every field name with your app directory — myapp_can_widget, not can_widget — exactly as Downloads uses idm_. This applies to addHeader() keys and to the html ids you pass as the eighth constructor argument too.
Your tab is not hidden when the group cannot view the site
Core's GroupSettings::process() tries to hide every other extension's tab behind the g_view_board toggle:
foreach ( Application::allExtensions( 'core', 'GroupForm', FALSE ) as $key => $class )
{
if ( $key != 'core_GroupSettings' )
{
$tabs[] = $form->id . '_tab_group__' . $key;
}
}
$form->add( new YesNo( 'g_view_board', ..., array( 'togglesOn' => array_merge( $tabs, ... ) ) ) );
Those ids do not exist in IC5's markup. The admin form template renders tab buttons as id='ipsTabs_{$formId}_{$tabKey}' and panels as ipsTabs_{$formId}_{$tabKey}_panel; the toggle JS resolves targets with [id='…'], so 4_tab_group__myapp_MySettings matches nothing. Server-side it matches nothing either, because Form::values() compares the toggle list against element htmlIds, and a tab is not an element.
The consequence is not cosmetic: your fields are always rendered, always submitted, and your save() always runs, including for a group with g_view_board = 0. If your setting is meaningless for a group that cannot see the site, enforce it in save() the way core does for its own fields:
if ( !$values['g_view_board'] )
{
$values['myapp_can_widget'] = false;
}
canDelete() returning FALSE does not actually prevent deletion
Group::canDelete() is consulted in exactly two places: the ACP list, to decide whether to render a delete button, and the top of the ACP delete controller. Group::delete() itself never calls it — its only guard is a hard-coded check on the guest, member and admin group ids:
public function delete(): void
{
if ( in_array( $this->g_id, array( Settings::i()->guest_group, Settings::i()->member_group, Settings::i()->admin_group ) ) )
{
throw new InvalidArgumentException;
}
...
}
So canDelete() is a UI affordance, not a constraint. Any other code path that calls $group->delete() directly — another app, a task, a support-tool script — deletes the group regardless of what your extension said. If the data really must not be orphaned, handle it in delete() as well.
One cosmetic bug to be aware of while testing: when canDelete() returns FALSE and the admin reaches the delete URL directly, the controller calls Output::i()->error( 'cannot_delete_group', '2C108/5', 403, '' ), and cannot_delete_group is not defined anywhere in core's language files. The admin sees the literal string cannot_delete_group on the error page. That is core's missing string, not yours.
Verified against
Read from Invision Community 5.0.19 source: system/Extensions/GroupFormAbstract.php, system/Member/Group.php, system/Member/Member.php, system/Application/Application.php, system/Patterns/ActiveRecord.php, system/Helpers/Form/Form.php, applications/core/modules/admin/members/groups.php, applications/core/modules/admin/developer/details.php, applications/core/dev/html/admin/forms/template.phtml, dev/js/framework/common/ui/ips.ui.form.js, and all seven core implementations (core/Content, core/GroupSettings, core/Social, blog/Blog, downloads/Downloads, gallery/Gallery, nexus/PreventDeletion). Nothing here is inferred from Invision Community 4.
Recommended Comments