Skip to content

Hook reference

Hooks let you slot into existing flows without modifying core code. The current version defines 90 points, listed in full below.

Subscribing

php
<?php
namespace App\Plugin\Demo\Hook;

use App\Controller\Base\View\UserPlugin;
use Kernel\Annotation\Hook;

class Main extends UserPlugin
{
    #[Hook(point: 0x130)]        // USER_VIEW_FOOTER
    public function footer(): void
    {
        echo '<script>console.log("hi")</script>';
    }
}

Three hard rules

1. Arguments must be variables.

hook() receives its variadic arguments by reference, so passing a literal at the call site is an immediate fatal 500:

php
hook(P, new X());            // 500
hook(P, $this->getUser());   // 500
hook(P, ['a' => 1]);         // 500
hook(P, 'literal');          // 500

And it only fails when that line actually runs. When adding core hook points, assign to a variable first.

2. Subscribers use hexadecimal literals, not constants.

php
#[Hook(point: 0x2300)]                        // recommended
#[Hook(point: \App\Consts\Hook::XXX)]         // wedges the plugin on older cores

Attribute arguments are evaluated when the plugin is enabled. On an older core without that constant this throws an Error, leaving the plugin half-enabled — START executed, STATUS never written.

3. Returning bool short-circuits the whole chain.

When a hook method returns a bool, later subscribers are skipped and the caller receives that value directly. Only SERVICE_SMTP_SEND_BEFORE uses this deliberately (returning true means the plugin has taken over sending). Every other point should return void.


View points

These take no arguments; subscribers simply echo HTML, CSS or JavaScript.

Admin panel

ConstantValueWhere
ADMIN_VIEW_HEADER0x2Global head — put CSS here
ADMIN_VIEW_FOOTER0x1Global footer — put JS here
ADMIN_VIEW_BODY0x10201Global body
ADMIN_VIEW_MENU0x3Left sidebar — add your own menu entry
ADMIN_VIEW_NAV0x4Top navigation
ADMIN_VIEW_AUTH_LOGIN_FORM0x60Inside the admin login form
ADMIN_VIEW_USER_HEADER0x10002Members page, head
ADMIN_VIEW_USER_FOOTER0x9Members page, footer
ADMIN_VIEW_USER_TOOLBAR0x10Members page, toolbar
ADMIN_VIEW_COMMODITY_TOOLBAR0x7Products toolbar
ADMIN_VIEW_COMMODITY_FOOTER0x6Products footer
ADMIN_VIEW_CATEGORY_TOOLBAR0x701Categories toolbar
ADMIN_VIEW_ORDER_TOOLBAR0x13Orders toolbar
ADMIN_VIEW_ORDER_FOOTER0x12Orders footer
ADMIN_VIEW_CARD_TOOLBAR0x801Card keys toolbar
ADMIN_VIEW_CARD_FOOTER0x802Card keys footer
ADMIN_VIEW_CONFIG_TOOLBAR0x14Site settings toolbar

Adding a menu entry:

php
#[Hook(point: 0x3)]
public function menu(): void
{
    echo '<div class="menu-item"><a class="menu-link" href="/plugin/Demo/api/index">'
       . '<span class="menu-title">My plugin</span></a></div>';
}

Storefront

ConstantValueWhere
USER_VIEW_HEADER0x128Storefront head
USER_VIEW_BODY0x129Storefront body
USER_VIEW_FOOTER0x130Storefront footer
USER_GLOBAL_VIEW_HEADER0x228Global head, including the user centre
USER_GLOBAL_VIEW_BODY0x229Global body
USER_GLOBAL_VIEW_FOOTER0x230Global footer
USER_VIEW_INDEX_HEADER0x10001Home page head
USER_VIEW_INDEX_BODY0x10003Home page body
USER_VIEW_INDEX_FOOTER0x10004Home page footer
USER_VIEW_MENU0x57User centre menu
USER_VIEW_HEADER_NAV0x88Storefront top navigation (array-based, see below)
USER_VIEW_AUTH_LOGIN_BUTTON0x41Next to the login button
USER_VIEW_AUTH_REGISTER_BUTTON0x42Next to the register button
USER_VIEW_SECURITY_NAV0x43Security settings navigation
USER_VIEW_PERSONAL_FORM0x44Profile form
USER_VIEW_QUERY_TRADE_NO0x89Order lookup page

USER_VIEW_HEADER_NAV (0x88) behaves differently from the other view points: it is array-based. The plugin returns a navigation entry and each theme renders it itself, rather than echoing raw HTML. That way switching themes does not break your layout.

Core and admin tables

ConstantValueNotes
KERNEL_INIT0x30Core initialised — the earliest point available. Use it to intercept whole requests
HACK_ROUTE_TABLE_COLUMNS0x2005The only way to add a column to an admin table
HACK_ROUTE_TABLE_SEARCH0x2006Add search criteria to an admin table
HACK_SUBMIT_FORM0x9038Add fields to an admin form
HACK_SUBMIT_TAB0x9039Add tabs to an admin form
USER_API_AUTH_LOGIN_BEGIN0x21Before storefront login
USER_API_AUTH_REGISTER_BEGIN0x19Before storefront registration

Data points

These carry arguments, passed by reference — modifying them changes what happens next.

Orders and payments

ConstantValueArguments
USER_API_ORDER_TRADE_BEGIN0x16array $map raw checkout data
USER_API_ORDER_TRADE_PAY_BEGIN0x171Commodity $commodity, Order $order, Pay $pay
USER_API_ORDER_TRADE_AFTER0x17Commodity $commodity, Order $order, Pay $pay
USER_API_ORDER_PAY_AFTER0x18Commodity $commodity, Order $order, Pay $pay — payment complete
ORDER_MANUAL_DELIVERY_AFTER0x2200Order $order, bool $overwrite — after manual delivery is written
USER_API_RECHARGE_AFTER0x18191Recharge $recharge, Pay $pay — top-up complete
SERVICE_PAY_CALLBACK_FAIL0x3010string $handle, string $reason, ?string $tradeNo, array $map

SERVICE_PAY_CALLBACK_FAIL values for $reason: handle, not_found, credential, plugin, sign, status, duplicate, amount. Of these, sign/amount/handle/credential usually mean someone is forging callbacks; duplicate is the gateway repeating a notification, which is normal — do not alert on it.

When ORDER_MANUAL_DELIVERY_AFTER fires, $order->secret already holds the new content and delivery_status is 1. $overwrite says whether existing delivery content was replaced. Good place to send the buyer a "dispatched" notification.

Accounts

ConstantValueArguments
USER_API_AUTH_REGISTER_AFTER0x20User $user
USER_API_AUTH_LOGIN_AFTER0x22User $user
USER_API_AUTH_LOGIN_FAIL0x23string $account, string $reason
ADMIN_API_AUTH_LOGIN_AFTER0x61Manage $manage
ADMIN_API_AUTH_LOGIN_FAIL0x62string $email, string $reason

Storefront failure reasons: not_found, password, banned. Admin failure reasons: throttled, captcha, not_found, password, totp, banned, shift, other (waiting for a 2FA code does not count as a failure).

Ideal for brute-force alerting.

Storefront data

These rewrite the data returned to the storefront — hiding products, adjusting displayed prices, adding fields.

ConstantValueArguments
USER_API_INDEX_CATEGORY_LIST0x49array $category
USER_API_INDEX_COMMODITY_LIST0x50array $data
USER_API_INDEX_COMMODITY_DETAIL_INFO0x51array $item
USER_API_INDEX_PAY_LIST0x53array $pay
USER_API_INDEX_QUERY_LIST0x54array $data
USER_API_INDEX_QUERY_SECRET0x55Order $order
USER_API_PURCHASE_RECORD_LIST0x56array $data

Products and stock

ConstantValueArguments
COMMODITY_CHANGE_AFTER0x8100int[] $ids, string $action, ?Commodity $before
CARD_CHANGE_AFTER0x8101int[] $commodityIds, string $reason
SERVICE_SHOP_GET_ITEM_STOCK0x8000Commodity $commodity, string $race, array $sku

COMMODITY_CHANGE_AFTER fires after a product is created, edited, deleted, enabled/disabled, batch-updated or synced from upstream — always after the database transaction commits, so what you receive is guaranteed to be persisted.

$action values: create, update, delete, status, batch, sync. $before carries the pre-edit model only on the single-product save path; it is null elsewhere.

The batch paths (status/batch) hand you the set of IDs in the request, which may include products that did not actually change. Subscribers should diff against their own snapshot rather than assuming every ID really changed. On delete the row is already gone, so only the ID is available.

CARD_CHANGE_AFTER fires after the card key pool changes — that is, when stock of an automatic-delivery product changes — also after commit.

$commodityIds are product IDs, not card key IDs. $reason values: import, edit, lock, unlock, sell, delete.

Stock decreases caused by an order do not come through here. Use USER_API_ORDER_PAY_AFTER and ORDER_MANUAL_DELIVERY_AFTER instead.

Tickets

ConstantValueArguments
USER_API_TICKET_CREATE_AFTER0x2100Ticket $ticket, TicketMessage $message
USER_API_TICKET_REPLY_AFTER0x2101Ticket $ticket, TicketMessage $message
ADMIN_API_TICKET_REPLY_AFTER0x2102Ticket $ticket, TicketMessage $message, Manage $manage

All three fire after the transaction commits; an exception thrown inside the hook does not affect the API result.

Email

ConstantValueArguments
SERVICE_SMTP_SEND_BEFORE0x3000array $config, string $email, string $title, string $content
SERVICE_SMTP_SEND_SUCCESS0x3001same
SERVICE_SMTP_SEND_ERROR0x3002same

SERVICE_SMTP_SEND_BEFORE is the only point that uses its return value to short-circuit: returning true means the plugin has taken delivery over and the core will not use SMTP. Use it to route mail through another channel such as Telegram.

Core and routing

ConstantValueArguments
CONTROLLER_CALL_BEFORE0x31object $controller, string $action
CONTROLLER_CALL_AFTER0object $controller, string $action, mixed $result
HTTP_ROUTE_RESPONSE0x47string $routePath, mixed $result
HTTP_NOT_FOUND0x48string $routePath — route did not match
RENDER_VIEW0x33string $result — rendered output, can be rewritten
WAF_INTERCEPT0x289string $message — WAF blocked something
CSP_SOURCE_ALLOW0x8102array $sources — CSP allowlist
LANG_MISS0x9100array $sourceList, array $langList — missing translations
ADMIN_API_PLUGIN_SAVE_CONFIG0x15int $id, array $map — plugin settings saved

HTTP_NOT_FOUND makes a good scanner-detection signal — a burst of 404s is almost always someone probing you.


Risk and manual-review points

0x2300 to 0x2305 form a risk-control group. They differ from other hooks in two ways that you must read before subscribing.

1. They all receive RiskContext $risk by reference; mutate it and return void

Never return true/false. The dispatcher short-circuits the entire chain on a bool, so the first subscriber that returns one silences every other risk plugin behind it.

php
public const PASS   = 0;   // allow
public const LIMIT  = 1;   // silently throttle (the subscriber enforces it; the core does nothing special)
public const REVIEW = 2;   // hold for manual review
public const DENY   = 3;   // reject outright

$risk->escalate(RiskContext::DENY, 'PluginName', lang('Reason'));  // escalates only, never downgrades
$risk->hardAllow('PluginName', 'Reason');                          // force allow and lock the decision
$risk->ref = 'AR-XXXX';                                            // a traceable reference, echoed back verbatim

After the hook returns, the core reads $risk->action: DENY throws a JSONException, REVIEW takes each scenario's own hold path, and LIMIT is left to the subscriber.

2. Why exceptions alone are not enough

Rejecting could be done by throwing, but holding for review cannot — that requires the core to know "create the account, but do not issue a session", which an exception cannot express.

The points

ConstantValueArgumentsLocation
USER_API_AUTH_REGISTER_VALIDATED0x2300$risk, $userRegistration: after validation, before insert
USER_API_AUTH_PASSWORD_BEGIN0x2301$risk, $accountPassword recovery: before the code is checked
USER_API_RECHARGE_TRADE_BEGIN0x2302$risk, $user, $mapTop-up: after amount and channel validation
USER_API_CASH_SUBMIT_BEGIN0x2303$risk, $user, $mapWithdrawal: after binding checks, before insert
USER_API_TICKET_CREATE_BEGIN0x2304$risk, $user, $mapTicket creation: before the service layer
USER_API_ORDER_DELIVERY_BEGIN0x2305$risk, $order, $commodityImmediately before delivery

Design notes for several of them:

Registration (0x2300) is better than 0x19 for three reasons: username, email and phone are the final, de-duplicated values; $user is passed by reference so fields can be edited directly; and it sits outside the surrounding try block — inside it, any exception is rewritten as "registration failed" and your reason never reaches the user. On REVIEW the core sets $user->status to 0 and skips loginSuccess(); otherwise the user would "register successfully" and then be logged out on their next click.

Password recovery (0x2301) deliberately runs before the verification code is checked: rejecting should not burn the code the user is holding, nor let an attacker run up the site owner's SMS bill.

Top-up (0x2302) sits in the service layer rather than the controller, because the controller assembles no $map — the amount is only parsed further down. $map is read-only context; editing it achieves nothing, since downstream code reads $_POST directly.

Withdrawal (0x2303) needs no new state: cash.status = 0 already means "awaiting the site owner". Only type == 2 (cashing out to spendable balance) settles automatically, so holding simply closes that shortcut.

Before delivery (0x2305) is the only place a card key can be held back after payment, and one insertion covers every payment path (zero-value orders, balance payment, all gateway callbacks).

The money has already arrived, so rejecting is no longer appropriate — the only question is whether the goods go out. Subscribers therefore use REVIEW only: leave delivery_status at 0 and replace secret with an explanatory message, which is exactly the state a manual-delivery product occupies between payment and dispatch. None of the side effects (pulling a key, decrementing stock, commission and rebate ledger entries, the delivery email) have run, so replaying the step idempotently after approval is precisely correct.


Retired points

These 8 constants are still present in Hook.php, but nothing in the core calls them any more. Subscribing does not error; it simply never fires.

ConstantValueUse instead
ADMIN_VIEW_USER_TABLE0x8HACK_ROUTE_TABLE_COLUMNS
ADMIN_VIEW_COMMODITY_TABLE0x5HACK_ROUTE_TABLE_COLUMNS
ADMIN_VIEW_CATEGORY_TABLE0x702HACK_ROUTE_TABLE_COLUMNS
ADMIN_VIEW_ORDER_TABLE0x11HACK_ROUTE_TABLE_COLUMNS
ADMIN_VIEW_CATEGORY_POST0x703HACK_SUBMIT_FORM
ADMIN_VIEW_COMMODITY_POST0x45HACK_SUBMIT_FORM
USER_VIEW_COMMODITY_POST0x46
USER_API_INDEX_TRADE_CALC_AMOUNT0x52

Older tutorials online still teach adding admin table columns via ADMIN_VIEW_USER_TABLE, echoing a fragment of JSON column definition. That approach does nothing in the current version.

Adding an admin table column properly

The only entry point now is HACK_ROUTE_TABLE_COLUMNS (0x2005), used with the Column entity rather than echoed JSON.

Two things to watch:

  • escapeHtml is not globally available inside column renderers; handle escaping yourself
  • Order.amount is a string; cast before doing arithmetic with it

Released under the MIT License