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.

Syntaxinit → wp_loaded → pre_get_posts → template_redirect → wp_enqueue_scripts (front end); admin_init on admin requests

WordPress 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.

HookFiresArguments
initAfter WordPress has loaded and the user is authenticated, before headers are sent. Since 1.5.0.none
wp_loadedOnce WordPress, all plugins and the theme are fully loaded. Since 3.0.0.none
admin_initStart of every admin-side request, including admin-ajax.php and admin-post.php. Since 2.5.0.none
pre_get_postsAfter query vars are set, before the SQL runs. Since 2.0.0.WP_Query $query, by reference
template_redirectBefore WordPress picks a template file. Since 1.5.0.none
wp_enqueue_scriptsFront-end asset queue, from wp_head() at priority 1. Since 2.8.0.none
the_contentPost content on its way to output. Since 0.71.string $content
save_postAfter 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

Example · php
<?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.

Example · php
// 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.

Example · php
// 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.

Example · php
// 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

  • Be the first to comment on this lesson.