Hooks You Will Actually Use
The handful of WordPress hooks that cover most real work, what each one is for, and the arguments it passes.
init → wp_loaded → pre_get_posts → template_redirect → wp_enqueue_scripts (front end); admin_init on admin requestsWordPress fires thousands of hooks. In practice a small set covers most plugin and theme work, and knowing where each sits in the request is what makes the rest learnable.
| Hook | Fires | Arguments |
|---|---|---|
init | After WordPress has loaded and the user is authenticated, before headers are sent. Since 1.5.0. | none |
wp_loaded | Once WordPress, all plugins and the theme are fully loaded. Since 3.0.0. | none |
admin_init | Start of every admin-side request, including admin-ajax.php and admin-post.php. Since 2.5.0. | none |
pre_get_posts | After query vars are set, before the SQL runs. Since 2.0.0. | WP_Query $query, by reference |
template_redirect | Before WordPress picks a template file. Since 1.5.0. | none |
wp_enqueue_scripts | Front-end asset queue, from wp_head() at priority 1. Since 2.8.0. | none |
the_content | Post content on its way to output. Since 0.71. | string $content |
save_post | After a post is inserted or updated. Since 1.5.0. | int $post_id, WP_Post $post, bool $update |
Choosing between them
Register post types and taxonomies on init; anything needing every plugin to have finished loading goes on wp_loaded. Redirects belong on template_redirect, which is the last point before output starts.
Example
<?php
// Registration work: init.
add_action( 'init', function () {
register_post_type(
'acme_doc',
array(
'public' => true,
'label' => __( 'Docs', 'acme' ),
'supports' => array( 'title', 'editor' ),
'has_archive' => true,
'show_in_rest' => true,
)
);
} );
// Query modification: pre_get_posts. Guard, then mutate — never return anything.
add_action( 'pre_get_posts', function ( $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return; // This is an action, so a bare return is correct here.
}
if ( $query->is_post_type_archive( 'acme_doc' ) ) {
$query->set( 'posts_per_page', 50 );
$query->set( 'orderby', 'title' );
$query->set( 'order', 'ASC' );
}
} );
// Front-end assets: wp_enqueue_scripts, loaded only where they are needed.
add_action( 'wp_enqueue_scripts', function () {
if ( ! is_singular( 'acme_doc' ) ) {
return;
}
wp_enqueue_style( 'acme-doc', plugins_url( 'doc.css', __FILE__ ), array(), '1.0.0' );
} );When to use it
- Register a custom post type so it is available to the main query and REST.
- Change posts per page on an archive without editing the template.
- Load a stylesheet only on the templates that need it.
- Redirect logged-out visitors away from a members-only post type before output starts.
More examples
template_redirect for redirects and gating
Use template_redirect rather than init for access control, because by then the query has run and the conditional tags (is_singular, is_page, is_404) are accurate. On init they are not yet meaningful, which is why gating code written there behaves unpredictably.
// template_redirect is the last hook before WordPress chooses a template file,
// and nothing has been echoed yet — so headers can still be sent.
add_action( 'template_redirect', function () {
if ( ! is_singular( 'acme_doc' ) ) {
return;
}
if ( ! is_user_logged_in() ) {
wp_safe_redirect( wp_login_url( get_permalink() ), 302 );
exit; // Always exit after a redirect.
}
if ( ! current_user_can( 'read_acme_doc', get_the_ID() ) ) {
// 403 with a real template rather than a redirect.
status_header( 403 );
include get_query_template( '403' );
exit;
}
} );admin_init versus init
Settings registration and schema migrations go here so front-end page loads do not pay for them. Remember that admin_init fires on AJAX requests too: anything expensive or anything that writes will run on every admin-ajax.php call, so gate it on a version check or a screen check.
// admin_init is roughly the admin-side analogue of init, and fires LATER than init.
// It also runs on admin-ajax.php and admin-post.php, not only on visible screens.
add_action( 'admin_init', function () {
register_setting(
'acme_options', // option group
'acme_api_key', // option name
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
)
);
} );
// A one-off migration also belongs on admin_init, not on every front-end request.
add_action( 'admin_init', function () {
if ( get_option( 'acme_db_version' ) === ACME_DB_VERSION ) {
return;
}
acme_run_migrations();
update_option( 'acme_db_version', ACME_DB_VERSION );
} );wp_loaded when everything must be registered first
The hook to reach for whenever your code needs a complete picture: every post type, every taxonomy, every other plugin's registrations. Front-end requests continue to template_redirect after this, and admin requests continue to admin_init, so wp_loaded is common ground for both.
// init is too early to inspect what other plugins registered — some of them
// register their own hooks ON init, at priorities you do not control.
// wp_loaded fires once WP, all plugins and the theme are fully loaded.
add_action( 'wp_loaded', function () {
// Now every post type exists, so this is accurate.
$types = get_post_types( array( 'public' => true ), 'names' );
foreach ( $types as $type ) {
add_filter( "manage_{$type}_posts_columns", 'acme_add_column' );
}
// Safe place to position yourself relative to another plugin's filter.
$priority = has_filter( 'the_content', 'other_plugin_content' );
if ( false !== $priority ) {
add_filter( 'the_content', 'acme_content', $priority + 1 );
}
} );
Discussion