====== Admidio Hooks — Developer Guide ====== For the full list of hooks, see the [[.:hooks:catalogue|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-''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. ===== 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'' | ''_creating'' | before the INSERT | | after create | ''entity_created'' | ''_created'' | after the outermost transaction commits | | create failed | ''entity_create_failed'' | ''_create_failed'' | on failure or rollback | | before update | ''entity_updating'' | ''_updating'' | before the UPDATE | | after update | ''entity_updated'' | ''_updated'' | after commit | | update failed | ''entity_update_failed'' | ''_update_failed'' | on failure or rollback | | before delete | ''entity_deleting'' | ''_deleting'' | before the DELETE | | after delete | ''entity_deleted'' | ''_deleted'' | after commit | | delete failed | ''entity_delete_failed'' | ''_delete_failed'' | on failure or rollback | | field value | ''entity_value'' | ''_value'' | in ''setValue()'', before type canonicalization | '''' 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'', 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'', 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 ''_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. ===== Vetoing an operation before it happens ===== The pre-stage Actions (''_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 . '' . ''; }); ''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 |