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
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:
hook(P, new X()); // 500
hook(P, $this->getUser()); // 500
hook(P, ['a' => 1]); // 500
hook(P, 'literal'); // 500And 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.
#[Hook(point: 0x2300)] // recommended
#[Hook(point: \App\Consts\Hook::XXX)] // wedges the plugin on older coresAttribute 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
| Constant | Value | Where |
|---|---|---|
ADMIN_VIEW_HEADER | 0x2 | Global head — put CSS here |
ADMIN_VIEW_FOOTER | 0x1 | Global footer — put JS here |
ADMIN_VIEW_BODY | 0x10201 | Global body |
ADMIN_VIEW_MENU | 0x3 | Left sidebar — add your own menu entry |
ADMIN_VIEW_NAV | 0x4 | Top navigation |
ADMIN_VIEW_AUTH_LOGIN_FORM | 0x60 | Inside the admin login form |
ADMIN_VIEW_USER_HEADER | 0x10002 | Members page, head |
ADMIN_VIEW_USER_FOOTER | 0x9 | Members page, footer |
ADMIN_VIEW_USER_TOOLBAR | 0x10 | Members page, toolbar |
ADMIN_VIEW_COMMODITY_TOOLBAR | 0x7 | Products toolbar |
ADMIN_VIEW_COMMODITY_FOOTER | 0x6 | Products footer |
ADMIN_VIEW_CATEGORY_TOOLBAR | 0x701 | Categories toolbar |
ADMIN_VIEW_ORDER_TOOLBAR | 0x13 | Orders toolbar |
ADMIN_VIEW_ORDER_FOOTER | 0x12 | Orders footer |
ADMIN_VIEW_CARD_TOOLBAR | 0x801 | Card keys toolbar |
ADMIN_VIEW_CARD_FOOTER | 0x802 | Card keys footer |
ADMIN_VIEW_CONFIG_TOOLBAR | 0x14 | Site settings toolbar |
Adding a menu entry:
#[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
| Constant | Value | Where |
|---|---|---|
USER_VIEW_HEADER | 0x128 | Storefront head |
USER_VIEW_BODY | 0x129 | Storefront body |
USER_VIEW_FOOTER | 0x130 | Storefront footer |
USER_GLOBAL_VIEW_HEADER | 0x228 | Global head, including the user centre |
USER_GLOBAL_VIEW_BODY | 0x229 | Global body |
USER_GLOBAL_VIEW_FOOTER | 0x230 | Global footer |
USER_VIEW_INDEX_HEADER | 0x10001 | Home page head |
USER_VIEW_INDEX_BODY | 0x10003 | Home page body |
USER_VIEW_INDEX_FOOTER | 0x10004 | Home page footer |
USER_VIEW_MENU | 0x57 | User centre menu |
USER_VIEW_HEADER_NAV | 0x88 | Storefront top navigation (array-based, see below) |
USER_VIEW_AUTH_LOGIN_BUTTON | 0x41 | Next to the login button |
USER_VIEW_AUTH_REGISTER_BUTTON | 0x42 | Next to the register button |
USER_VIEW_SECURITY_NAV | 0x43 | Security settings navigation |
USER_VIEW_PERSONAL_FORM | 0x44 | Profile form |
USER_VIEW_QUERY_TRADE_NO | 0x89 | Order 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
| Constant | Value | Notes |
|---|---|---|
KERNEL_INIT | 0x30 | Core initialised — the earliest point available. Use it to intercept whole requests |
HACK_ROUTE_TABLE_COLUMNS | 0x2005 | The only way to add a column to an admin table |
HACK_ROUTE_TABLE_SEARCH | 0x2006 | Add search criteria to an admin table |
HACK_SUBMIT_FORM | 0x9038 | Add fields to an admin form |
HACK_SUBMIT_TAB | 0x9039 | Add tabs to an admin form |
USER_API_AUTH_LOGIN_BEGIN | 0x21 | Before storefront login |
USER_API_AUTH_REGISTER_BEGIN | 0x19 | Before storefront registration |
Data points
These carry arguments, passed by reference — modifying them changes what happens next.
Orders and payments
| Constant | Value | Arguments |
|---|---|---|
USER_API_ORDER_TRADE_BEGIN | 0x16 | array $map raw checkout data |
USER_API_ORDER_TRADE_PAY_BEGIN | 0x171 | Commodity $commodity, Order $order, Pay $pay |
USER_API_ORDER_TRADE_AFTER | 0x17 | Commodity $commodity, Order $order, Pay $pay |
USER_API_ORDER_PAY_AFTER | 0x18 | Commodity $commodity, Order $order, Pay $pay — payment complete |
ORDER_MANUAL_DELIVERY_AFTER | 0x2200 | Order $order, bool $overwrite — after manual delivery is written |
USER_API_RECHARGE_AFTER | 0x18191 | Recharge $recharge, Pay $pay — top-up complete |
SERVICE_PAY_CALLBACK_FAIL | 0x3010 | string $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
| Constant | Value | Arguments |
|---|---|---|
USER_API_AUTH_REGISTER_AFTER | 0x20 | User $user |
USER_API_AUTH_LOGIN_AFTER | 0x22 | User $user |
USER_API_AUTH_LOGIN_FAIL | 0x23 | string $account, string $reason |
ADMIN_API_AUTH_LOGIN_AFTER | 0x61 | Manage $manage |
ADMIN_API_AUTH_LOGIN_FAIL | 0x62 | string $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.
| Constant | Value | Arguments |
|---|---|---|
USER_API_INDEX_CATEGORY_LIST | 0x49 | array $category |
USER_API_INDEX_COMMODITY_LIST | 0x50 | array $data |
USER_API_INDEX_COMMODITY_DETAIL_INFO | 0x51 | array $item |
USER_API_INDEX_PAY_LIST | 0x53 | array $pay |
USER_API_INDEX_QUERY_LIST | 0x54 | array $data |
USER_API_INDEX_QUERY_SECRET | 0x55 | Order $order |
USER_API_PURCHASE_RECORD_LIST | 0x56 | array $data |
Products and stock
| Constant | Value | Arguments |
|---|---|---|
COMMODITY_CHANGE_AFTER | 0x8100 | int[] $ids, string $action, ?Commodity $before |
CARD_CHANGE_AFTER | 0x8101 | int[] $commodityIds, string $reason |
SERVICE_SHOP_GET_ITEM_STOCK | 0x8000 | Commodity $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. Ondeletethe 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_AFTERandORDER_MANUAL_DELIVERY_AFTERinstead.
Tickets
| Constant | Value | Arguments |
|---|---|---|
USER_API_TICKET_CREATE_AFTER | 0x2100 | Ticket $ticket, TicketMessage $message |
USER_API_TICKET_REPLY_AFTER | 0x2101 | Ticket $ticket, TicketMessage $message |
ADMIN_API_TICKET_REPLY_AFTER | 0x2102 | Ticket $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
| Constant | Value | Arguments |
|---|---|---|
SERVICE_SMTP_SEND_BEFORE | 0x3000 | array $config, string $email, string $title, string $content |
SERVICE_SMTP_SEND_SUCCESS | 0x3001 | same |
SERVICE_SMTP_SEND_ERROR | 0x3002 | same |
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
| Constant | Value | Arguments |
|---|---|---|
CONTROLLER_CALL_BEFORE | 0x31 | object $controller, string $action |
CONTROLLER_CALL_AFTER | 0 | object $controller, string $action, mixed $result |
HTTP_ROUTE_RESPONSE | 0x47 | string $routePath, mixed $result |
HTTP_NOT_FOUND | 0x48 | string $routePath — route did not match |
RENDER_VIEW | 0x33 | string $result — rendered output, can be rewritten |
WAF_INTERCEPT | 0x289 | string $message — WAF blocked something |
CSP_SOURCE_ALLOW | 0x8102 | array $sources — CSP allowlist |
LANG_MISS | 0x9100 | array $sourceList, array $langList — missing translations |
ADMIN_API_PLUGIN_SAVE_CONFIG | 0x15 | int $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.
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 verbatimAfter 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
| Constant | Value | Arguments | Location |
|---|---|---|---|
USER_API_AUTH_REGISTER_VALIDATED | 0x2300 | $risk, $user | Registration: after validation, before insert |
USER_API_AUTH_PASSWORD_BEGIN | 0x2301 | $risk, $account | Password recovery: before the code is checked |
USER_API_RECHARGE_TRADE_BEGIN | 0x2302 | $risk, $user, $map | Top-up: after amount and channel validation |
USER_API_CASH_SUBMIT_BEGIN | 0x2303 | $risk, $user, $map | Withdrawal: after binding checks, before insert |
USER_API_TICKET_CREATE_BEGIN | 0x2304 | $risk, $user, $map | Ticket creation: before the service layer |
USER_API_ORDER_DELIVERY_BEGIN | 0x2305 | $risk, $order, $commodity | Immediately 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.
| Constant | Value | Use instead |
|---|---|---|
ADMIN_VIEW_USER_TABLE | 0x8 | HACK_ROUTE_TABLE_COLUMNS |
ADMIN_VIEW_COMMODITY_TABLE | 0x5 | HACK_ROUTE_TABLE_COLUMNS |
ADMIN_VIEW_CATEGORY_TABLE | 0x702 | HACK_ROUTE_TABLE_COLUMNS |
ADMIN_VIEW_ORDER_TABLE | 0x11 | HACK_ROUTE_TABLE_COLUMNS |
ADMIN_VIEW_CATEGORY_POST | 0x703 | HACK_SUBMIT_FORM |
ADMIN_VIEW_COMMODITY_POST | 0x45 | HACK_SUBMIT_FORM |
USER_VIEW_COMMODITY_POST | 0x46 | — |
USER_API_INDEX_TRADE_CALC_AMOUNT | 0x52 | — |
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:
escapeHtmlis not globally available inside column renderers; handle escaping yourselfOrder.amountis a string; cast before doing arithmetic with it
