Hooks & the Plugin Lifecycle
Admin sidebar entry
pl_on_admin_menu(function () {
pl_register_admin_menu(
'my-plugin', 'My Plugin', my_plugin_admin_url('overview'),
'<path d="..."/>' // inline SVG path data, 24x24 viewBox
);
});
pl_on_admin_menu() fires once per admin page load. Safe to call unconditionally from your entry file — it's simply never fired on front-end requests.
Dashboard widget
pl_on_admin_dashboard(function () {
echo '<div class="dashboard-panel dashboard-panel--sm">…</div>';
});
Unlike admin_menu, there's no collection step — your callback echoes its widget markup directly when admin_dashboard fires on the Dashboard page, inside a shared .dashboard-widgets flex row.
Two layout classes are available on your outer widget <div>:
dashboard-panel— always takes its own full-width row. Use it for a widget with real content to browse (a chart, a list with several columns) — the Analytics traffic widget is adashboard-panel.dashboard-panel--sm— a compact card that packs 2–3 per row alongside other small widgets, growing to fill the row when there's only one or two. Use it for a simple KPI + short list — the Booking and Comments dashboard widgets both usedashboard-panel--sm.
.dashboard-widgets is a flexbox row (flex-wrap), not a CSS grid — deliberately: with a grid, a full-width item on one row forces the same column tracks to exist on every other row, so --sm widgets on a later row get stuck at a fraction of that fixed track width with empty space beside them instead of growing to fill it. Flex-wrap rows size independently, so mixing a dashboard-panel and several dashboard-panel--sm widgets never leaves a gap.
Letting the site owner turn it off
A dashboard widget should always be optional — add a boolean to your plugin's settings (see Options API) and check it before echoing anything, so the callback is a no-op when disabled:
pl_on_admin_dashboard(function () {
if (empty(my_plugin_load_settings()['show_dashboard_widget'])) return;
echo '<div class="dashboard-panel dashboard-panel--sm">…</div>';
});
If your settings live in the CMS's own data/config.json rather than a plugin-local file, read the toggle straight from there instead — $_GET['_ajax']-routed save handlers (see Admin integration) are a common place this setting gets persisted, and it's easy to have two separate save endpoints in a plugin (e.g. a legacy admin/actions.php and the live admin/admin-page.php AJAX router) — if a toggle appears to have no effect, check that the code path the settings form actually submits to is the one being updated.
Activation / deactivation
pl_add_hook('plugin_activate_my-plugin', function () {
// Runs once when activated — create your data directory, write defaults, etc.
});
pl_add_hook('plugin_deactivate_my-plugin', function () {
// Runs once when deactivated. Keep this non-destructive.
});
Intercepting the front-end request before rendering
Two hooks act before any rendering work starts, without index.php ever needing to know your plugin's name.
early_request — right after functions.php loads
pl_add_hook('early_request', function () {
if (my_plugin_should_block()) {
http_response_code(503);
echo 'Come back later.';
exit;
}
});
No arguments. Exit early here and the site never pays the cost of loading content for this request — the right tool for a maintenance-mode screen or anything that needs to block the whole page.
after_routing — once the route is known
pl_add_hook('after_routing', function (bool $isGenuine404) {
if ($isGenuine404) {
// e.g. look up a manual redirect for the requested path
}
});
By this point routing is resolved but no output has started, so header('Location: ...'); exit; still works cleanly. $GLOBALS['data'] at this point only holds the lightweight index. Use this when your plugin's behavior depends on knowing whether the current request is a genuine 404 — a redirect manager deciding whether to send visitors home, for example.
Why these exist as core hooks instead of add_theme_action()
add_theme_action()'s hook points (before_content, footer_scripts, etc. — see Hooks & Filters) all fire during template rendering, for injecting markup into a page that's already being built. early_request and after_routing cover the two moments before that — nothing has been decided yet about what to render, or whether to render anything at all. index.php calls pl_do_hook('early_request') and pl_do_hook('after_routing', $isGenuine404) at the right points without ever naming a specific plugin, so any plugin needing this level of access stays exactly as self-contained as one that only uses admin_menu.
Hook priorities
Every pl_add_hook() call (and pl_add_filter(), see Filters) accepts an optional $priority, default 10 — lower runs first. This matters when two plugins register the same hook and their order of execution is significant.
pl_add_hook('early_request', fn() => my_plugin_first(), 5);
pl_add_hook('early_request', fn() => my_plugin_last(), 20);
