WordPress Hook Priorities: Negative Values, PHP_INT_MAX, Execution Order, and Late Hooks

WordPress’s hook system processes callbacks in ascending priority order: a callback registered at priority 5 runs before one at priority 10, which runs before one at priority 20. The default priority is 10. Most developers know this, but the edge cases cause hard-to-trace bugs: two plugins registering the same callback priority run in the order they were registered within that priority bucket (first registered = first to run). Negative priorities are valid and run before any positive priority. PHP_INT_MAX is a legitimate priority value and is the correct way to register a callback that must run absolutely last. Re-registering a hook inside a callback (the “late hook” pattern) lets you run code after everything else on a hook has completed. Understanding these mechanics is essential for debugging callback ordering issues, for correctly positioning child-theme overrides relative to parent-theme callbacks, and for writing plugin code that interoperates predictably with other plugins.

Problem: Your plugin modifies the the_content output, but another plugin's content filter runs after yours and overwrites the changes. You need your callback to always run last — after all other plugins — without hardcoding a number that might not be high enough.

Solution: Use PHP_INT_MAX as the priority for your final callback. For more complex ordering, use the "late hook" pattern: hook into the target hook at a reasonable priority, then inside that callback remove and re-add your real callback at PHP_INT_MAX.

<?php
// ── Priority basics ───────────────────────────────────────────────────
add_filter( 'the_content', 'run_first',  1   ); // runs first
add_filter( 'the_content', 'run_normal', 10  ); // default — runs third
add_filter( 'the_content', 'run_late',   100 ); // runs last among these three

// ── PHP_INT_MAX — guaranteed last ────────────────────────────────────
add_filter( 'the_content', 'my_final_content_filter', PHP_INT_MAX );

function my_final_content_filter( string $content ): string {
    // Runs after ALL other the_content filters, including third-party plugins
    return $content . '<p class="content-stamp">Processed.</p>';
}

// ── Negative priority — runs before everything else ───────────────────
add_action( 'init', 'my_very_early_init', -10 );

function my_very_early_init() {
    // Runs before any callback registered at priority >= 0
}

// ── Late hook pattern — wait for all callbacks at current priority ─────
// Goal: inspect $wp_query AFTER all pre_get_posts callbacks have run
add_action( 'pre_get_posts', 'observe_final_query_state_setup', 10 );

function observe_final_query_state_setup( WP_Query $query ) {
    // Re-register at PHP_INT_MAX from within the callback itself
    remove_action( 'pre_get_posts', 'observe_final_query_state_setup', 10 );
    add_action( 'pre_get_posts', 'observe_final_query_state', PHP_INT_MAX );
}

function observe_final_query_state( WP_Query $query ) {
    // Now runs after all other pre_get_posts callbacks
    error_log( 'Final post_type: ' . print_r( $query->get( 'post_type' ), true ) );
}

// ── Inspecting registered callbacks at a priority ─────────────────────
global $wp_filter;
if ( isset( $wp_filter['the_content'] ) ) {
    foreach ( $wp_filter['the_content']->callbacks as $priority => $callbacks ) {
        foreach ( $callbacks as $key => $data ) {
            $func = is_array( $data['function'] )
                ? get_class( $data['function'][0] ) . '::' . $data['function'][1]
                : ( is_string( $data['function'] ) ? $data['function'] : '[closure]' );
            echo "Priority $priority: $func
";
        }
    }
}

NOTE: Within the same priority, callbacks run in the order add_action() / add_filter() was called — first registered, first to run. This means plugin load order (determined by file system alphabetical order or the active_plugins option) affects hook ordering when two plugins use the same priority. If you need your callback to consistently run after another plugin's callback at the same priority, either increase your priority number or use the late hook pattern shown above. Also, remove_action() and remove_filter() must specify the same priority that was used during registration — omitting the priority argument defaults to 10 and will fail silently if the original callback was registered at a different priority.