====== Admidio Hooks Catalogue ====== This page documents every hook actually implemented in Admidio 5.1. Engine: ''Admidio\Hooks\Hooks'' (static). Three primitives — Action (''addAction''/''doAction''), Filter (''addFilter''/''applyFilters''/''applyTypedFilters''), Resolver (''addResolver''/''resolve''). See the [[..:hooks|developer documentation]] how to use the hooks documented here in your own code. ---- ===== 1. Entity persistence lifecycle ===== Implemented once in ''Entity''. Every hookable entity dispatches the same nine stages under its own name and under the generic ''entity_*'' name. A plugin normally subscribes to the entity-specific name; a cross-cutting plugin (audit, Devhelper) subscribes to the generic one. ==== Stage pattern ==== ^ Stage ^ Type ^ Generic name ^ Entity-specific name ^ Order ^ Args ^ | pre-create | Action | ''entity_creating'' | ''_creating'' | generic first | ''(EntityChangeSet $changeSet, ?Entity $entity)'' | | post-create (committed) | Action | ''entity_created'' | ''_created'' | specific first | ''(EntityChangeSet $changeSet, ?Entity $entity)'' | | create failed | Action | ''entity_create_failed'' | ''_create_failed'' | specific first | ''(EntityChangeSet $changeSet, ?Entity $entity)'' | | pre-update | Action | ''entity_updating'' | ''_updating'' | generic first | ''(EntityChangeSet $changeSet, ?Entity $entity)'' | | post-update (committed) | Action | ''entity_updated'' | ''_updated'' | specific first | ''(EntityChangeSet $changeSet, ?Entity $entity)'' | | update failed | Action | ''entity_update_failed'' | ''_update_failed'' | specific first | ''(EntityChangeSet $changeSet, ?Entity $entity)'' | | pre-delete | Action | ''entity_deleting'' | ''_deleting'' | generic first | ''(EntityChangeSet $changeSet, ?Entity $entity)'' | | post-delete (committed) | Action | ''entity_deleted'' | ''_deleted'' | specific first | ''(EntityChangeSet $changeSet, null)'' | | delete failed | Action | ''entity_delete_failed'' | ''_delete_failed'' | specific first | ''(EntityChangeSet $changeSet, null)'' | | proposed field value | Filter | ''entity_value'' | ''_value'' | generic first | ''(mixed $value, Entity $entity, string $columnName, mixed $oldValue)'' | | readable name | Filter | ''entity_readable_name'' | ''_readable_name'' | generic first | ''(string $name, Entity $entity)'' | **The second argument is the live entity**, so a callback can read a field the change set does not carry (an unchanged column, a related record) or call a domain method on it — not just observe what changed. It is ''null'' for exactly two stages: ''deleted'' and ''delete_failed''. ''Entity::delete()'' clears the object before either can be dispatched — 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 the object at that point would as often be the wrong record as an empty one. ''EntityChangeSet::getSnapshot()'' is what those two describe the record from instead. Committed (''*_created''/''*_updated''/''*_deleted'') fires after the outermost transaction really commits, never before, and never for a change that is later rolled back. Two saves of the same record in one transaction coalesce into one event. A create+delete of the same never-persisted record fires nothing. ''*_value'' runs before Admidio's own type canonicalization, so it sees the proposed input, not the stored form. Bulk-deleted dependent records (''Entity::deleteDependentRecords()'' / ''hookBulkDeletion()'') dispatch the same ''deleting''/''deleted'' pair per record, with ''EntityChangeSet::isCascade()'' / ''getCauseHookId()'' / ''getCauseId()'' naming the record whose deletion caused it. ==== Entities and their hook ID ==== ^ Hook ID () ^ Class ^ Table ^ | ''announcement'' | ''Announcements\Entity\Announcement'' | ''adm_announcements'' | | ''category'' | ''Categories\Entity\Category'' | ''adm_categories'' | | ''component'' | ''Components\Entity\Component'' | ''adm_components'' | | ''event'' | ''Events\Entity\Event'' | ''adm_events'' | | ''file'' | ''Documents\Entity\File'' | ''adm_files'' | | ''folder'' | ''Documents\Entity\Folder'' | ''adm_folders'' | | ''forum_post'' | ''Forum\Entity\Post'' | ''adm_forum_posts'' | | ''forum_topic'' | ''Forum\Entity\Topic'' | ''adm_forum_topics'' | | ''inventory_field'' | ''Inventory\Entity\ItemField'' | ''adm_inventory_fields'' | | ''inventory_field_select_option'' | ''Inventory\Entity\SelectOptions'' | ''adm_inventory_field_options'' | | ''inventory_item'' | ''Inventory\Entity\Item'' | ''adm_inventory_items'' | | ''inventory_item_borrow_data'' | ''Inventory\Entity\ItemBorrowData'' | ''adm_inventory_item_borrow_data'' | | ''inventory_item_data'' | ''Inventory\Entity\ItemData'' | ''adm_inventory_item_data'' | | ''list_column'' | ''Roles\Entity\ListColumns'' | ''adm_list_columns'' | | ''list_configuration'' | ''Roles\Entity\ListConfiguration'' | ''adm_lists'' | | ''membership'' | ''Roles\Entity\Membership'' | ''adm_members'' | | ''menu_entry'' | ''Menu\Entity\MenuEntry'' | ''adm_menu'' | | ''message'' | ''Messages\Entity\Message'' | ''adm_messages'' | | ''message_content'' | ''Messages\Entity\MessageContent'' | ''adm_messages_content'' | | ''oidc_client'' | ''SSO\Entity\OIDCClient'' | ''adm_oidc_clients'' | | ''organization'' | ''Organizations\Entity\Organization'' | ''adm_organizations'' | | ''photo_album'' | ''Photos\Entity\Album'' | ''adm_photos'' | | ''profile_field'' | ''ProfileFields\Entity\ProfileField'' | ''adm_user_fields'' | | ''profile_field_select_option'' | ''ProfileFields\Entity\SelectOptions'' | ''adm_user_field_options'' | | ''role'' | ''Roles\Entity\Role'' | ''adm_roles'' | | ''role_dependency'' | ''Roles\Entity\RolesDependencies'' | ''adm_role_dependencies'' | | ''role_right'' | ''Roles\Entity\RolesRights'' | ''adm_roles_rights'' | | ''role_right_assignment'' | ''Roles\Entity\RolesRightsData'' | ''adm_roles_rights_data'' | | ''room'' | ''Events\Entity\Room'' | ''adm_rooms'' | | ''saml_client'' | ''SSO\Entity\SAMLClient'' | ''adm_saml_clients'' | | ''sso_key'' | ''SSO\Entity\Key'' | ''adm_sso_keys'' | | ''user'' | ''Users\Entity\User'' | ''adm_users'' | | ''user_data'' | ''Users\Entity\UserData'' | ''adm_user_data'' | | ''user_relation'' | ''Users\Entity\UserRelation'' | ''adm_user_relations'' | | ''user_relation_type'' | ''Users\Entity\UserRelationType'' | ''adm_user_relation_types'' | | ''weblink'' | ''Weblinks\Entity\Weblink'' | ''adm_links'' | ==== Deliberately unhookable (no hook ID, dispatch nothing) ==== ''LogChanges'', ''Text'', ''Preferences'', ''AutoLogin'', ''Session'', ''SSOClient'' (its subclasses ''OIDCClient''/ ''SAMLClient'' are hookable), ''TokenEntity'' (OIDC access/refresh tokens, auth codes). Infrastructure, security-sensitive, or too high-frequency to be normal CRUD subjects. ---- ===== 2. Semantic / cross-cutting hooks ===== ^ Hook ^ Type ^ Args ^ Dispatched from ^ | ''user_registration_accepted'' | Action | ''(User $user, string $method)'' — ''$method'' is ''UserRegistration::ACCEPTED_BY_APPROVAL'' or ''::ACCEPTED_BY_ASSIGNMENT'' | ''UserRegistration::acceptRegistration()'', ''RegistrationService::assignRegistration()'' | | ''user_changes_cumulated'' | Action | ''(string $userUuid, array $reasons)'' — reasons are a subset of ''user'', ''profile'', ''membership'' | ''ChangeNotification::sendNotifications()'', once per affected user per request | ---- ===== 3. Forms ===== ^ Hook ^ Type ^ Args ^ Dispatched from ^ | ''form_built'' | Action | ''(FormPresenter $form)'' | ''FormPresenter::finalize()'' — once per form, however many times/from where ''finalize()'' is reached | | ''form_select_options'' | Filter | ''(array $values, string $elementId, string $elementType, FormPresenter $form)'' — must return an array of the same shape | ''FormPresenter::finalize()'', after ''form_built'', only for ''select'' / ''radio'' / ''button-group.radio'' elements | ''form_select_options'''s result is enforced: it is the same element definition ''validate()'' checks a POST against, so removing an entry there really refuses it server-side. ---- ===== 4. Pages ===== ^ Hook ^ Type ^ Args ^ Dispatched from ^ | ''page_title'' | ''Filter'' | ''(string $title, PagePresenter $page)'' | ''PagePresenter::show()'' (or ''getFilteredTitle()'' for a caller that needs it earlier) | | ''page_headline'' | ''Filter'' | ''(string $headline, PagePresenter $page)'' | ''PagePresenter::show()'' (or ''getFilteredHeadline()'') | | ''page_before_render'' | Action | ''(PagePresenter $page)'' | ''PagePresenter::show()'', after the two text filters | Both filters and the action fire exactly once per page regardless of how many times ''show()'' / ''getFilteredTitle()'' / ''getFilteredHeadline()'' are called on it. ''PagePresenter::getHtmlID()'' gives the stable per-page identifier (''adm_[_]'') to key behaviour on. ---- ===== 5. Components ===== ^ Hook ^ Type ^ Args ^ Dispatched from ^ | ''component_visible'' | ''Filter'' | ''(bool $visible, string $componentName)'' | ''Component::isVisible()'' | | ''component_administrable'' | ''Filter'' | ''(bool $administrable, string $componentName)'' | ''Component::isAdministrable()'' | Typed filters — a callback must return a ''bool'', not a truthy value. ---- ===== 6. Translation ===== ^ Hook ^ Type ^ Args ^ Dispatched from ^ | ''translation_text'' | ''Filter'' | ''(string $text, string $textId, string $language)'' | ''Language::get()'', on every lookup, after the cache read | | ''translation_missing'' | Resolver | ''(string $textId, string $language)'', default ''null'' | ''Language::get()'', only when neither the requested nor the reference language has the text | | ''translation_fallback_used'' | Action | ''(string $textId, string $language, string $referenceLanguage)'' | ''Language::get()'', when the reference-language text was used | | ''translation_unresolved'' | Action | ''(string $textId, string $language)'' | ''Language::get()'', when nothing — requested, reference, or resolver — produced a text | ---- ===== 7. Login, session and diagnostics ===== ^ Hook ^ Type ^ Args ^ Dispatched from ^ | ''login_attempt'' | Action | ''(string $loginName, string $orgShortName)'' | ''ModuleLogin'', at the start of a login attempt; throwing refuses it | | ''login_succeeded'' | Action | ''(User $user, string $orgShortName)'' | ''ModuleLogin'', after a successful login | | ''login_failed'' | Action | ''(string $loginName, string $reason, string $orgShortName)'' — no password/TOTP | ''ModuleLogin'', for an unknown user as well as a wrong password/TOTP | | ''logout'' | Action | ''(User $user)'' | ''system/logout.php'' | | ''exception_terminating'' | Action | ''(Throwable $exception, bool $isJsonResponse)'' | ''handleException()'' | ---- ===== 8. Email ===== ^ Hook ^ Type ^ Args ^ Dispatched from ^ | ''email_recipients'' | ''Filter'' | ''(array $recipients, string $subject)'' | ''Email::sendEmail()'' (internally ''deliver()''), before sending | | ''email_sent'' | Action | ''(string $subject, int $recipientCount)'' | ''Email'', after a successful send | | ''email_failed'' | Action | ''(string $subject, int $recipientCount, string $errorMessage)'' | ''Email'', after a failed send | No email hook ever receives the ''Email''/PHPMailer object — it carries the SMTP credentials in public properties. ---- ===== 9. Lists ===== Proven on one module (''contacts'') with the stable ''listId'' ''contacts''; not yet implemented for any other list. ^ Hook ^ Type ^ Args ^ Dispatched from ^ | ''list_columns'' | ''Filter'' | ''(array $columnHeadings, string $listId)'' — must return the same number of entries | ''contacts.php'', before the headings are handed to the page | | ''list_data'' | ''Filter'' | ''(array $row, string $listId)'' | ''contacts_data.php'', right after a row is fetched, before formatting | | ''list_row_actions'' | ''Filter'' | ''(string $actionsHtml, string $listId, array $row)'' | ''contacts_data.php'', right before the action-icons cell is written | | ''list_rendered_data'' | ''Filter'' | ''(array $columnValues, string $listId, array $row)'' | ''contacts_data.php'', right before a row is added to the JSON response | ''list_columns'' can relabel a column but not add or remove one: rows are still built by column position, there is no shared list/column pipeline yet, and a count change is refused with an exception rather than silently misaligning every row. ---- ===== 10. Module-specific (not part of the generic vocabulary) ===== ^ Hook ^ Type ^ Args ^ Dispatched from ^ | ''category_report_enabled'' | ''Filter'' | ''(bool $enabled)'' | ''category_report.php'' | | ''category_report_config'' | ''Filter'' | ''(array $config)'' | ''category_report.php'' | Kept deliberately module-specific: ''category_report_config'' is genuine module data with no generic equivalent, and ''category_report_enabled'' lets the module tell a direct URL "disabled" apart from "no rights" after ''component_visible'' already folds the setting into ''Component::isVisible()''. ---- ===== Hook engine API reference ===== Hooks::addAction(string $name, callable $callback, int $priority = 10, ?int $acceptedArgs = null, ?string $id = null): void Hooks::doAction(string $name, mixed ...$args): void Hooks::doActionCatchErrors(string $name, mixed ...$args): void // failure/diagnostic dispatch only Hooks::addFilter(string $name, callable $callback, int $priority = 10, ?int $acceptedArgs = null, ?string $id = null): void Hooks::applyFilters(string $name, mixed $value, mixed ...$args): mixed Hooks::applyTypedFilters(string $name, mixed $value, mixed ...$args): mixed // return type must match $value's Hooks::addResolver(string $name, callable $callback, int $priority = 10, ?int $acceptedArgs = null, ?string $id = null): void Hooks::resolve(string $name, mixed $default = null, mixed ...$args): mixed // first non-null answer wins Hooks::removeAction(string $name, string|callable $idOrCallback): bool Hooks::removeFilter(string $name, string|callable $idOrCallback): bool Hooks::removeResolver(string $name, string|callable $idOrCallback): bool Hooks::hasAction/hasFilter/hasResolver(string $name): bool Hooks::reset(string $name = ''): void // one hook, or the whole registry Lower priority runs earlier; same priority runs in registration order. A callback throws to veto an Action or a Filter; the exception propagates. ''doActionCatchErrors()'' logs and swallows instead, used only at failure/diagnostic sites where the original failure must stay the one Admidio reports. ''doAction()''/''doActionCatchErrors()''/''applyFilters()''/''applyTypedFilters()''/''resolve()'' are not only for core's own dispatch sites: a module or plugin can call them at its own extension points to make itself extensible by other plugins the same way core is — register the names with ''add*()'' as usual, then dispatch them from your own code with these.