Admidio Hooks — Developer Guide

For the full list of hooks, see the Hooks Catalogue.

Note: The underlying plugin system that lets Admidio extend easily with third party plugins is not yet implemented. The hooks functionality described here, however, also works with manually added extensions in the admidio code tree.

Admidio's hooks are a generic extension mechanism to allow plugins to react to core events or even modify core Admidio behavior without patching core files or subclassing core classes:

  • You register a callback function for any of the documented hooks
  • At several strategic spots in the code, Admidio core dispatches a named hook
  • Core calls all registered callback functions for the particular named hook.

Hooks cover three kinds of thing:

  • React to something that happened — a record was created/updated/deleted, a login succeeded, an email was sent — to sync an external system, send a notification, write an audit log.
  • Change or reject a value or structure on its way through core — a field before it's saved, the options a select box offers, a page's title, a list's columns — including refusing it by throwing.
  • Extend a UI object before it's used — add a field to a form, a menu item to a page, an action icon to a list row — by mutating the (mutable) object core hands you.

All of it is dispatched from a single static registry, Admidio\Hooks\Hooks. Registering a callback is the entire integration; there is no interface to implement and nothing to configure beyond the hook name.

  • Action (addAction/doAction) — something happened. Return value ignored. Throwing propagates.
  • Filter (addFilter/applyFilters/applyTypedFilters) — transform a value; each callback gets the previous one's result. applyTypedFilters() additionally throws if a callback changes the value's type.
  • Resolver (addResolver/resolve) — ask providers in priority order for one answer; first non-null wins, false/0/“”/[] count as answers, null means “not me”.
Hooks::addAction('user_created', $callback, priority: 10);
Hooks::addFilter('entity_value', $callback);
Hooks::addResolver('translation_missing', $callback);

Lower priority runs earlier. A callback may throw an Admidio Exception to veto whatever it is hooked into; the dispatcher lets it propagate except at the handful of failure/diagnostic sites named in the catalogue, where the original failure must stay authoritative.

Every persisted entity dispatches two names per lifecycle stage: a generic one (entity_created) and one named after the entity (oidc_client_created). Subscribe to the specific name — you get called only for that entity, not for the thousands of unrelated saves a request makes.

Stage Generic Specific Fires
before create entity_creating <id>_creating before the INSERT
after create entity_created <id>_created after the outermost transaction commits
create failed entity_create_failed <id>_create_failed on failure or rollback
before update entity_updating <id>_updating before the UPDATE
after update entity_updated <id>_updated after commit
update failed entity_update_failed <id>_update_failed on failure or rollback
before delete entity_deleting <id>_deleting before the DELETE
after delete entity_deleted <id>_deleted after commit
delete failed entity_delete_failed <id>_delete_failed on failure or rollback
field value entity_value <id>_value in setValue(), before type canonicalization

<id> is the entity's stable hook ID (oidc_client, event, user, … — full list in the catalogue), never the PHP class name. Every stage above takes (EntityChangeSet $changeSet, ?Entity $entity), with one exception: deleted and delete_failed always get null for $entity, never the record — by the time either can fire, Entity::delete() has already cleared the object (immediately for a failure outside a transaction, later through the commit/rollback queue for everything else), and a bulk deletion reuses one scratch object for every row it removes, so it would as often be the wrong record as an empty one. EntityChangeSet::getSnapshot() is what those two describe the record from instead. The value filter has its own, different arguments: (mixed $value, Entity $entity, string $columnName, mixed $oldValue). Before an operation the generic name runs first, wrapping the specific one; after it (success or failure) the specific name runs first — a generic listener's “before” always encloses a specific listener's “before” and “after”.

$entity is what makes a pre-action useful for more than observing: it is the live record, so you can read a field the change set does not carry, call a domain method, or look up/connect a record of your own module before the save completes — not just react afterwards.

Hooks::addAction('oidc_client_creating', function (EntityChangeSet $changeSet, ?Entity $entity) {
    // read a field the change set does not carry, to connect the new client to your own record
    linkClientToExternalTenant($entity->getValue('ocl_client_id'));
});
Hooks::addAction('oidc_client_created', function (EntityChangeSet $changeSet) {
    syncClientToExternalSystem($changeSet->getId(), $changeSet->getNewValue('ocl_client_name'));
});
Hooks::addAction('oidc_client_updated', function (EntityChangeSet $changeSet) {
    if ($changeSet->hasChanged('ocl_redirect_uri')) {
        syncClientToExternalSystem($changeSet->getId(), $changeSet->getNewValue('ocl_client_name'));
    }
});

Use the generic entity_created/entity_updated/entity_deleted only for something genuinely cross-cutting (an audit log, a Devhelper panel) — you will be called for every entity in the installation. EntityChangeSet gives old/new values per field, so you never need your own before/after cache, and it only fires for a committed change: a rolled-back save, an update that ends up equal to what it started from, or two saves of the same record in one transaction, all produce either nothing or one event, never a false positive.

Immutable. Admidio\Hooks\ValueObject\EntityChangeSet.

Method Returns
getHookId() the entity's stable hook ID, e.g. oidc_client
getEntityClass() the PHP class — prefer getHookId(), a class may be renamed
getTableName() the table, with the installation's table prefix
getColumnPrefix() the column prefix, e.g. usr
getKeyColumnName() the key column's name, e.g. usr_id
getId() the key value; null in a pre-create action, set from the create action on
getUuid() the record's UUID, or null if its table has none
getOperation() EntityChangeSet::OPERATION_CREATE / _UPDATE / _DELETE
isCreate() / isUpdate() / isDelete() bool shortcuts for the above
getOperationId() ID shared by a pre-action and its matching post/failure action
getChanges() array<string,EntityFieldChange>, every changed column, keyed by name
getBusinessChanges() same, with creator/editor/counter bookkeeping columns left out
hasChanged(string $column) bool
getChange(string $column) the EntityFieldChange for that column, or null
getOldValue(string $column) / getNewValue(string $column) the raw old/new value, or null
getSnapshot() array<string,mixed>, the record as the database held it before the operation — empty for a create; for a delete, the only place to read the removed record from, since Entity::delete() clears the object
getCauseHookId() / getCauseId() which record's deletion cascaded into this one, or both null
isCascade() bool shortcut for “cause is not null”

getChange()/getChanges() return EntityFieldChange objects (Admidio\Hooks\ValueObject\EntityFieldChange): public readonly $column, $oldValue, $newValue, $type (the DB column type, e.g. varchar), $kind (EntityFieldChange::KIND_BUSINESS / KIND_TECHNICAL / KIND_REDACTED), plus isBusiness() and isRedacted(). A redacted column (a password, a key) still reports that it changed; its values are replaced with EntityChangeSet::REDACTED_VALUE instead of being withheld silently.

Hooks::addAction('event_updated', function (EntityChangeSet $changeSet) {
    if ($changeSet->hasChanged('dat_begin')) {
        myCalendarSync($changeSet->getUuid(), $changeSet->getNewValue('dat_begin'));
    }
});

entity_value (generic) and <hookId>_value (specific) filter every proposed field value before Admidio's own type canonicalization runs, so your result still gets the normal validation, not instead of it.

Hooks::addFilter('user_data_value', function (mixed $value, Entity $userData, string $columnName, mixed $oldValue) {
    if ($columnName !== 'usd_value') {
        return $value;
    }
    // reject an email outside the association's domain
    if ($userData->getValue('usf_name_intern') === 'EMAIL' && !str_ends_with($value, '@example.org')) {
        throw new Exception('Only @example.org addresses are allowed.');
    }
    return $value;
});

Throwing here refuses the save() call outright — the caller sees the exception, nothing is written.

The pre-stage Actions (<hookId>_creating/_updating/_deleting, and their generic counterparts) hand you the proposed EntityChangeSet and the live entity before anything is written. Throw to refuse the whole operation.

Hooks::addAction('event_updating', function (EntityChangeSet $changeSet, ?Entity $entity) {
    // dat_room_id is not part of this change, but the entity carries it too
    if ($changeSet->hasChanged('dat_begin') && $entity->getValue('dat_room_id') > 0 && !currentUserMayReschedule()) {
        throw new Exception('SYS_NO_RIGHTS');
    }
});

Some things aren't one entity's save. Accepting a registration touches a UserRegistration, a User and a set of Membership rows — nothing in the entity hooks alone says “this was a registration being accepted.” user_registration_accepted is the semantic hook for exactly that.

Hooks::addAction('user_registration_accepted', function (User $user, string $method) {
    if ($method === UserRegistration::ACCEPTED_BY_APPROVAL) {
        notifyExternalSystem($user->getValue('usr_uuid'));
    }
});

The same pattern covers “mark a user dirty for SCIM”: subscribe to user_changes_cumulated instead of the three entities (user, user_data, membership) individually — it already does the grouping for you and fires once per affected user per request, excluding login-counter noise.

Hooks::addAction('user_changes_cumulated', function (string $userUuid, array $reasons) {
    markUserDirtyForScim($userUuid); // reasons: subset of 'user', 'profile', 'membership'
});

page_before_render hands you the finished, mutable PagePresenter right before it renders. Key your logic on the stable page ID, not on CURRENT_URL.

Hooks::addAction('page_before_render', function (PagePresenter $page) {
    if ($page->getHtmlID() === 'adm_preferences') {
        $page->addJavascript('alert("Discuss all changes with the webmaster first.");', true);
    }
});

page_title/page_headline are Filters for the two texts, dispatched from the same place; register those instead if you only need to change wording, not add markup or scripts.

form_built hands you the finished FormPresenter before it is rendered and before it is stored for POST validation — the same instance both see, so removing a field here really removes it from what a submitted request is checked against.

Hooks::addAction('form_built', function (FormPresenter $form) {
    if ($form->getId() === 'adm_preferences_system_form' && $form->hasElement('rss_login')) {
        $form->removeElement('rss_login');
    }
});

For a select/radio/button-group.radio, form_select_options filters the offered entries the same way — and the filtered set is what validate() checks a submission against, so removing an option is a real restriction, not decoration.

translation_missing is a Resolver: return the translated string, or null to let the next resolver (or the reference-language fallback) answer instead.

Hooks::addResolver('translation_missing', function (string $textId, string $language) {
    return $myTranslationApi->translate(lookupReferenceText($textId), $language); // null if it can't
});

Cache what you return — this runs on every Language::get() for a text nobody has translated yet.

Proven so far on Contacts only (listId = 'contacts' ''). ''list_row_actions adds an icon/link to each row without touching contacts_data.php:

Hooks::addFilter('list_row_actions', function (string $actionsHtml, string $listId, array $row) {
    if ($listId !== 'contacts') {
        return $actionsHtml;
    }
    return $actionsHtml . '<a href="' . inventoryUrlFor($row['usr_uuid']) . '">'
        . '<i class="bi bi-box-seam"></i></a>';
});

list_data (raw row, before formatting) and list_rendered_data (finished row, before it is sent) are the data/presentation pair for the same list; list_columns can only relabel a column header, not add one — there is no shared column/data pipeline yet, so a filter that changes the column count is refused.

Everything Admidio\Hooks\Hooks offers. $name is always the hook name string; $priority defaults to 10, lower runs earlier; $acceptedArgs caps how many of the dispatched arguments your callback receives (default: all); $id, if given, makes the registration unique per hook regardless of priority — registering the same $id again replaces the previous one instead of adding a second.

Register — a plugin's normal entry point:

Function Signature
addAction() (string $name, callable $callback, int $priority = 10, ?int $acceptedArgs = null, ?string $id = null): void
addFilter() same signature
addResolver() same signature

Remove:

Function Signature Returns
removeAction() (string $name, string or callable $idOrCallback): bool whether something was removed
removeFilter() same signature
removeResolver() same signature
$id = 'my-plugin-user-sync';
Hooks::addAction('oidc_client_created', $callback, id: $id);
Hooks::removeAction('oidc_client_created', $id); // or pass $callback itself if you kept no ID

Query — check before registering, or from a callback that wants to know if anyone else is listening:

Function Signature
hasAction() (string $name): bool
hasFilter() (string $name): bool
hasResolver() (string $name): bool

Dispatch — core calls these at its own dispatch sites, but they are not reserved for core: your module or plugin can call them at its own extension points the same way, to let other plugins hook into your code. Register with add*() as usual, dispatch with these:

Function Signature Behaviour
doAction() (string $name, mixed …$args): void calls every registered Action; a callback's exception propagates
doActionCatchErrors() same logs and swallows a callback's exception instead — used only at failure/diagnostic sites, so a listener's own bug cannot hide the original failure
applyFilters() (string $name, mixed $value, mixed …$args): mixed chains every registered Filter, each getting the previous one's result
applyTypedFilters() same as above, and throws UnexpectedValueException if a callback's return type differs from $value's
resolve() (string $name, mixed $default = null, mixed …$args): mixed asks every registered Resolver in priority order, returns the first non-null answer, or $default if none answers

Testing:

Function Signature
reset() (string $name = “”): void — clears one hook's registrations, or the whole registry when called without an argument
  • en/entwickler/hooks.txt
  • Last modified: 2026/08/26 22:33
  • by kainhofer