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.
What this is
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.
The three primitives
- 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-nullwins,false/0/“”/[]count as answers,nullmeans “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.
Entity CRUD: subscribe to what you care about
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.
EntityChangeSet — what every persistence hook receives
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')); } });
Normalizing or rejecting a value
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('usd_usf_id') === $emailFieldId && !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.
Vetoing an operation before it happens
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'); } });
A business event, not a row save
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' });
Adding CSS/JS to one page
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.
Extending a form
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.
Supplying a missing translation
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.
Adding to a list
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.
Hook engine API reference
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 |