Skip to Content
WordPress

WordPress Action vs Filter Hooks: When to Use Each

WordPress Action vs Filter Hooks: When to Use Each

WordPress action vs filter hooks is the most fundamental distinction in WordPress plugin development — and the one most often misunderstood by newer developers. Both let you extend WordPress without modifying core. Both have similar signatures. But they do completely different things, and using the wrong type for the job either breaks your code or quietly produces wrong results.

Action hooks let you RUN code at a specific event (“when an order is saved, log it”). Filter hooks let you MODIFY a value before it’s used (“change the displayed price”). That’s the core difference: actions execute, filters transform. Once that clicks, every WordPress / WooCommerce hook makes intuitive sense — you can predict which one to use just from the name.

Quick verdict: use actions for events (something happened, do something in response); use filters for value modification (something is about to be displayed or used, change it first). This guide explains the difference, when to use each, the priority and accepted_args parameters that most tutorials skip, the specific save_post_shop_order hook hierarchy that catches most WooCommerce developers, and the common gotchas that cause real production bugs.

WordPress action vs filter hooks: quick reference

WordPress action vs filter hooks — visual reference and overview

If you are evaluating WordPress action vs filter hooks for your next project, you are weighing real trade-offs between cost, complexity, ownership, and time-to-launch. The right WordPress action vs filter hooks decision depends on a handful of variables — team capacity, scope clarity, and how much ongoing maintenance you can absorb. The summary below is the 60-second version; the rest of this guide unpacks the nuance.

  • WordPress action vs filter hooks pricing typically ranges based on scope clarity, integration count, and ongoing support requirements.
  • WordPress action vs filter hooks timelines vary from days (small scope) to months (enterprise scope) depending on complexity.
  • The biggest variable in WordPress action vs filter hooks is requirements clarity at the brief stage — vague briefs produce vague quotes.
  • Vendor selection for WordPress action vs filter hooks matters more than tool selection — the right team beats the right stack.
  • WordPress action vs filter hooks ROI is positive when scope is bounded, deliverables are specified, and success criteria are measurable.

For complementary perspectives on WordPress action vs filter hooks, the WordPress action hooks reference and WordPress filter hooks reference resources cover adjacent angles worth reviewing alongside this guide. They focus on the underlying technology and standards — this post focuses on the WordPress action vs filter hooks decision specifically.

When you revisit your WordPress action vs filter hooks approach in 12 to 24 months, three signals usually indicate a refresh is justified. First, the original brief no longer matches business reality — product, audience, or operational scope has shifted. Second, the underlying technology has moved forward enough that the WordPress action vs filter hooks decision made under previous constraints would be different today. Third, ongoing maintenance overhead has crept up beyond what was forecast at launch. None of these are emergencies on their own; together they signal it is time to revisit fundamentals rather than patch around them.

The core difference between WordPress action and filter hooks

Side-by-side comparison of action vs filter hooks at every level — purpose, signature, return requirement, naming convention:

AspectAction hooksFilter hooks
PurposeRun code at an eventModify a value before it’s used
Callback returnsNothing (return value ignored)MUST return a value
Trigger functiondo_action()apply_filters()
Register functionadd_action()add_filter()
Naming conventionevent-style: save_post, wp_loadedtransform-style: the_content, woocommerce_get_price_html
When to useReact to eventsCustomize displayed / computed values
StorageBoth stored in $wp_filter globalSame
Multiple callbacksAll fire in priority orderChained — each callback receives previous one’s output

A subtle but important detail: WordPress core stores BOTH actions and filters in the same $wp_filter global. The distinction is purely behavioral at the call site — do_action() ignores return values; apply_filters() threads them through. You can technically hook a callback into a “filter” with add_action() and it works (because both functions are aliases of the same underlying mechanism), but doing so signals confusion to anyone reading your code.

WordPress action hooks — the 4 functions you need

Every action-hook workflow uses one or more of these four functions. The signatures + behaviors:

// Action hooks — four core functions for working with them.

// 1. add_action() — attach your callback to fire when the hook runs
add_action( 'save_post_shop_order', 'raj_log_order_saved' );

function raj_log_order_saved( $post_id ) {
    error_log( 'WooCommerce order ' . $post_id . ' was saved.' );
}

// 2. do_action() — fire the hook (used by WordPress core, plugins, themes
//    to broadcast that something happened; you can also fire your own)
do_action( 'raj_custom_event', $some_data );

// 3. remove_action() — detach a callback. Must pass the SAME priority that
//    was used when adding. Default priority is 10.
remove_action( 'save_post_shop_order', 'raj_log_order_saved' );

// 4. has_action() — check whether a callback is attached. Returns the
//    priority (integer) or false. Useful for conditional logic.
$priority = has_action( 'save_post_shop_order', 'raj_log_order_saved' );
if ( false !== $priority ) {
    // The callback IS hooked
}

WordPress filter hooks — the 4 functions you need

Filter hooks have the same 4-function structure as actions, with the critical difference: callbacks MUST return a value. Forgetting to return is the #1 cause of “the page is blank” bugs in WordPress.

// Filter hooks — four core functions for working with them.

// 1. add_filter() — attach a callback that MODIFIES and RETURNS a value.
//    The function MUST return a value (the modified, or unmodified, input).
add_filter( 'woocommerce_get_price_html', 'raj_add_price_badge', 10, 2 );

function raj_add_price_badge( $price_html, $product ) {
    if ( $product->is_on_sale() ) {
        $price_html .= ' <span class="sale-badge">SALE</span>';
    }
    return $price_html; // ALWAYS return — even if unchanged
}

// 2. apply_filters() — fire the filter (used by plugins/themes to expose
//    a value for modification by other code)
$modified_value = apply_filters( 'raj_custom_value', $original_value, $context );

// 3. remove_filter() — detach a callback (same signature as remove_action)
remove_filter( 'woocommerce_get_price_html', 'raj_add_price_badge', 10 );

// 4. has_filter() — check whether a callback is attached
if ( false !== has_filter( 'woocommerce_get_price_html', 'raj_add_price_badge' ) ) {
    // The filter IS hooked
}

The forgetful-return bug: If your filter callback doesn’t return a value (or returns null), the value passed through the filter becomes null for every subsequent callback AND the final consumer. Pages blank out. Prices show as 0. Always return SOMETHING — even if unmodified, return the input.

When to use action vs filter — decision rules

Five rules of thumb that pick the right hook type 95% of the time:

  • If you need to RUN code in response to something (a post was saved, a user logged in, an order was placed) → action
  • If you need to MODIFY a value before WordPress uses it (change a price, prepend HTML to content, alter a query) → filter
  • If the hook name reads like an event (init, save_post, woocommerce_payment_complete) → almost certainly an action
  • If the hook name reads like “get X” or modifies a noun (the_content, woocommerce_get_price_html, get_post_metadata) → almost certainly a filter
  • If you find yourself wanting to “modify and then run side effects” — use a filter for the modification, then trigger a separate action for the side effects. Don’t mix in a single callback

Priority and accepted_args — the parameters most tutorials skip

add_action() and add_filter() both take 4 parameters total, but most tutorials only show 2. The third and fourth — priority and accepted_args — are what production code actually needs:

// Priority controls the ORDER callbacks run when multiple callbacks
// share a hook. Lower priority runs first. Default is 10.

add_action( 'save_post_shop_order', 'callback_a', 5  );   // runs first
add_action( 'save_post_shop_order', 'callback_b', 10 );   // runs second (default)
add_action( 'save_post_shop_order', 'callback_c', 99 );   // runs last

// accepted_args controls how many arguments the callback RECEIVES.
// The hook may pass multiple args, but your callback only gets the
// first N where N = accepted_args (default 1).

// Action signature: do_action( 'save_post', $post_id, $post, $update )
// — passes 3 arguments. Tell add_action() how many you want:
add_action( 'save_post', 'raj_my_save_callback', 10, 3 ); // get all 3

function raj_my_save_callback( $post_id, $post, $update ) {
    // $post_id is the post ID (int)
    // $post is the WP_Post object
    // $update is bool — true if updating, false if new
}

// Filter signature: apply_filters( 'the_content', $content )
// — passes 1 argument; many filters pass more via $args
add_filter( 'woocommerce_get_price_html', 'raj_price_callback', 10, 2 );
//                                                            priority^  ^accepted_args

function raj_price_callback( $price_html, $product ) {
    return $price_html;
}

accepted_args must match what the callback declares: WordPress passes accepted_args arguments to your callback. If your function declares fewer parameters than you set accepted_args to, the extras are ignored. If you declare MORE parameters than accepted_args, the extras are uninitialized — PHP throws a warning. Always match the two: callback parameter count = accepted_args.

Naming conventions for WordPress hooks

Predictable hook names tell you the type without checking documentation:

  • Actions named with event verbs/nounsinit, wp_loaded, save_post, user_register, woocommerce_new_order, payment_complete
  • Filters named with “get_” or value nounsthe_content, the_title, get_avatar, woocommerce_get_price_html, wp_title
  • Dynamic hook patterns — actions like save_post_{post_type} fire a specific variant per post type. Common in WordPress core + WooCommerce
  • Plugin-prefixed — your plugin’s hooks should ALL start with your unique prefix to avoid collisions (e.g., raj_, woocommerce_, elementor_)

Deep dive: save_post_shop_order and the WordPress save_post hierarchy

When a WooCommerce order is saved, FIVE hooks fire in sequence. Picking the right one depends on whether you need general post-save logic, order-specific logic, or post-save-with-everything-finalized logic.

// The save_post hook hierarchy — what actually fires when a WooCommerce
// order (or any post) is saved, in order:

// 1. save_post                    — fires for EVERY post type
// 2. save_post_{post_type}        — type-specific (e.g., save_post_shop_order)
// 3. wp_after_insert_post         — fires after WP finishes saving (WP 5.6+)
// 4. (WC-specific for orders:)
//    woocommerce_new_order        — only when an ORDER is newly created
//    woocommerce_update_order     — only when an existing order is updated

// All five fire when an order is saved. Pick the right one for the job:

// For: "I want to react to ALL post saves" → save_post (broadest)
add_action( 'save_post', 'raj_all_post_saves', 10, 3 );

// For: "I want to react ONLY when a shop_order is saved" → save_post_shop_order
add_action( 'save_post_shop_order', 'raj_order_saved', 10, 3 );

// For: "I want to react AFTER all save logic + meta is done" → wp_after_insert_post
add_action( 'wp_after_insert_post', 'raj_after_save', 10, 4 );

// For: "I want order-specific WC logic" → use WC's order hooks
// (these get WC_Order objects directly, not just post IDs)
add_action( 'woocommerce_new_order',    'raj_new_order',    10, 2 );
add_action( 'woocommerce_update_order', 'raj_order_update', 10, 2 );

// Callback signatures:
function raj_order_saved( $post_id, $post, $update ) {
    // $update is true if updating an existing order, false if newly created
    // $post is the WP_Post object (not WC_Order)

    // To get the WC_Order, wrap it:
    $order = wc_get_order( $post_id );
    if ( ! $order ) return;

    // Common pattern — branch on $update so insert and update logic differ
    if ( $update ) {
        // Existing order was updated
    } else {
        // New order was created
    }
}

The complete save_post_shop_order pattern with production guards

Production code that reacts to save_post_shop_order needs five guards to avoid common bugs. Skip any of them and you’ll hit issues that only show up in real-world traffic:

// Complete pattern — react to "save_post_shop_order" with all the
// guards production code needs. This is the most-asked-about WC hook.

add_action( 'save_post_shop_order', 'raj_react_to_order_save', 10, 3 );

function raj_react_to_order_save( $post_id, $post, $update ) {
    // Guard 1 — skip autosaves (WP fires save_post during autosave too)
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }

    // Guard 2 — skip revisions (post_type would be 'revision' on revisions)
    if ( wp_is_post_revision( $post_id ) ) {
        return;
    }

    // Guard 3 — skip if post status is auto-draft (newly created, not saved yet)
    if ( 'auto-draft' === $post->post_status ) {
        return;
    }

    // Guard 4 — get the WC_Order object and bail if it's not really an order
    $order = wc_get_order( $post_id );
    if ( ! $order instanceof WC_Order ) {
        return;
    }

    // Guard 5 — prevent infinite loops if your callback updates the order.
    // Temporarily remove this action while we modify the order, then re-add.
    remove_action( 'save_post_shop_order', 'raj_react_to_order_save', 10 );

    // Your business logic here
    error_log( 'Order #' . $order->get_order_number() . ' saved. Total: ' . $order->get_total() );

    if ( ! $update ) {
        // Newly-created order — send custom notification, sync to CRM, etc.
        do_action( 'raj_new_order_event', $order );
    } else {
        // Existing order updated — sync the changes
        do_action( 'raj_order_updated_event', $order );
    }

    // Re-add the hook
    add_action( 'save_post_shop_order', 'raj_react_to_order_save', 10, 3 );
}

Why so many guards?: WordPress fires save_post in many contexts beyond actual user-triggered saves — autosaves, revisions, REST API writes, programmatic wp_insert_post() calls. Without guards, your callback runs on autosaves (wasted work), revisions (writes to the wrong post), and inside your own updates (infinite loop). The five guards prevent all common failure modes.

Common WooCommerce action vs filter patterns

Quick reference of the WooCommerce hooks developers reach for most often, grouped by type:

HookTypeCommon use
save_post_shop_orderactionReact when an order is saved (any change)
woocommerce_new_orderactionReact only when a NEW order is created
woocommerce_order_status_changedactionReact when an order status changes
woocommerce_payment_completeactionReact when payment is successfully captured
woocommerce_email_classesfilterRegister custom email classes
woocommerce_get_price_htmlfilterModify how prices display on shop / cart / checkout
woocommerce_cart_item_pricefilterModify per-item price display in cart
woocommerce_add_to_cart_validationfilterBlock / allow add-to-cart based on custom logic
woocommerce_email_recipient_new_orderfilterCustomize who receives the new-order email
woocommerce_checkout_create_orderactionModify the order object before it’s saved

Removing default WordPress / WooCommerce hooks

Sometimes you need to DISABLE built-in WP / WC functionality. The remove pattern is straightforward but has two gotchas:

// Removing a default WordPress / WooCommerce hook — common pattern when
// you want to disable built-in functionality. Two important rules:
//
//   1. Use the EXACT same priority that was used to add the hook.
//      Default is 10 if not specified.
//   2. Run remove_action() AFTER the original add_action() ran.
//      If your remove fires before the add, it has no effect.

// Example — disable WooCommerce's default product image zoom on single
// product pages. WC adds this at woocommerce_init priority 10:

add_action( 'wp', function () {
    remove_theme_support( 'wc-product-gallery-zoom' );
} );

// Example — remove WooCommerce's default order email for new orders.
// WC registers it via the WC_Emails class on woocommerce_email priority 10:

add_action( 'woocommerce_email', function ( $email_class ) {
    remove_action(
        'woocommerce_order_status_pending_to_processing_notification',
        array( $email_class->emails['WC_Email_Customer_Processing_Order'], 'trigger' )
    );
}, 99 ); // priority 99 ensures this runs AFTER WC registers the email

// Removing closures — IMPOSSIBLE via remove_action() because closures
// don't have a stable identity. If you need to remove a hook, use a
// named function or method, not a closure.

Closures cannot be removed: A hook registered via an anonymous function — add_action( 'init', function () { ... } ) — cannot be removed via remove_action() because the closure has no stable identity to reference. If you anticipate ever needing to remove a hook, register it via a named function or object method, not a closure.

Create your own action and filter hooks

Making your plugin extensible means emitting hooks at key extension points so other developers can integrate without modifying your code. Best practice:

// Creating your own action and filter hooks — the right way to make
// your plugin extensible. Other developers can then hook into your code
// without modifying it.

// Custom ACTION hook — broadcast that something happened in your plugin.
// Document the hook in a PHPDoc above it so integrators know what they
// can hook into.

/**
 * Fires after a custom quote is saved.
 *
 * @param int   $quote_id  The quote post ID.
 * @param array $data      The quote data array.
 */
do_action( 'raj_quote_saved', $quote_id, $data );

// Custom FILTER hook — expose a value that other developers can modify.
// ALWAYS provide a default value and accept the value back.

$discount_percent = 10;

/**
 * Filters the default quote discount percentage.
 *
 * @param int   $discount_percent  Default discount.
 * @param array $quote_data        Quote context.
 */
$discount_percent = apply_filters( 'raj_quote_discount_percent', $discount_percent, $data );

// Use the filtered value
$discount_amount = $original_price * ( $discount_percent / 100 );

// Naming convention — prefix every custom hook with your plugin's slug
// so it doesn't collide with other plugins. WooCommerce uses 'woocommerce_';
// I use 'raj_'. Pick a unique short prefix for your plugin.

Documenting custom hooks with PHPDoc comments is the difference between a plugin developers love and one they fork. WordPress’s own hook docs are generated from these comments — they’re the standard way to publish your hook reference.

Preventing infinite loops in WordPress hooks

If your save_post callback updates the post (or any callback that triggers the same hook again), you have an infinite loop. Two reliable solutions:

// Infinite-loop prevention — when your callback updates a post AND your
// callback is hooked into save_post, every update re-fires the hook,
// re-fires the callback, and you have a loop. Two solutions:

// Solution 1 — temporarily remove the hook during your update.
add_action( 'save_post_shop_order', 'raj_safe_callback', 10, 3 );

function raj_safe_callback( $post_id, $post, $update ) {
    // Remove ourselves before doing the update
    remove_action( 'save_post_shop_order', 'raj_safe_callback', 10 );

    // Safe to update without triggering this callback again
    wp_update_post( array(
        'ID'          => $post_id,
        'post_status' => 'processing',
    ) );

    // Re-add ourselves
    add_action( 'save_post_shop_order', 'raj_safe_callback', 10, 3 );
}

// Solution 2 — use wp_update_post() with the 'wp_after_insert_post' hook
// instead, which doesn't re-fire save_post for the inner update. The
// modern WordPress approach since WP 5.6.
add_action( 'wp_after_insert_post', 'raj_after_insert_callback', 10, 4 );

function raj_after_insert_callback( $post_id, $post, $update, $post_before ) {
    if ( 'shop_order' !== $post->post_type ) return;

    // Updates here don't loop because wp_after_insert_post fires AFTER
    // all save_post processing is done for this request.
    wp_update_post( array( 'ID' => $post_id, 'post_status' => 'processing' ) );
}

Common mistakes with WordPress action and filter hooks

Patterns that look correct but break in real-world contexts:

  • Filter callback that doesn’t return a value — pages blank out, prices show as 0. ALWAYS return SOMETHING (even the unmodified input)
  • Forgetting accepted_args — your callback gets only the first argument; subsequent params are uninitialized. Match accepted_args to your function’s parameter count
  • Using add_action() for a value modifier — works (technically) but signals confusion. Use add_filter() for value modification
  • Not specifying priority when removingremove_action('init', 'callback') only removes if the original add used the default priority 10. Always specify priority to be safe
  • Removing closures — impossible; closures have no stable identity. Use named functions if you might need to remove
  • Skipping the save_post guards — autosaves, revisions, and infinite loops all silently corrupt data without proper guards
  • Heavy logic in high-frequency hooksinit, wp_loaded fire on every request; all fires for every hook. Move heavy work to async events or background queues
  • Hook priority collisions with other plugins — both plugins hook at priority 10, theirs runs first, theirs overrides yours. Pick deliberate priorities (e.g., 5 to run early, 99 to run late) when conflict matters

Basics — FAQs

What's the difference between WordPress action and filter hooks?

Action hooks let you RUN code at a specific event (a post was saved, a user logged in). Filter hooks let you MODIFY a value before WordPress uses it (change a displayed price, prepend HTML to content). Actions execute; filters transform. Action callbacks don’t need to return anything; filter callbacks MUST return the (possibly modified) value. Both are stored in the same $wp_filter global internally — the distinction is purely behavioral at the call site.

Should I use add_action() or add_filter()?

Use add_action() when the hook is an EVENT (named like init, save_post, woocommerce_new_order) — your callback responds to something happening. Use add_filter() when the hook MODIFIES a value (named like the_content, woocommerce_get_price_html) — your callback receives a value, optionally changes it, and returns it. The hook name almost always signals which to use.

What does the "priority" parameter do in WordPress hooks?

Priority controls the ORDER callbacks run when multiple callbacks are attached to the same hook. Lower priority numbers run first; default is 10. Use priority 5 to run early (before most callbacks); priority 99 to run late (after most). When two plugins hook the same action and one needs to win, the loser bumps its priority to run later (and override).

save_post_shop_order specific — FAQs

How do I use the save_post_shop_order hook safely?

Use five guards. (1) Skip autosaves: if ( defined('DOING_AUTOSAVE') && DOING_AUTOSAVE ) return;. (2) Skip revisions: if ( wp_is_post_revision( $post_id ) ) return;. (3) Skip auto-draft status. (4) Get the WC_Order and bail if invalid: $order = wc_get_order( $post_id ); if ( ! $order ) return;. (5) Prevent infinite loops by removing your hook before any update and re-adding after. Without these guards, your callback runs during autosaves, fires on revisions, and can cause infinite loops.

What's the difference between save_post_shop_order and woocommerce_new_order?

save_post_shop_order fires whenever a shop_order post is saved — including updates. woocommerce_new_order fires ONLY when a brand-new order is created (first save). For “react to all order saves” → save_post_shop_order. For “react only when an order is newly created” → woocommerce_new_order. For “react when an order status changes” → woocommerce_order_status_changed.

When should I use wp_after_insert_post instead of save_post?

Use wp_after_insert_post (WP 5.6+) when you need to react AFTER WordPress has finished all save_post processing — including meta updates, taxonomy assignments, and other plugins’ save_post callbacks. save_post fires earlier; wp_after_insert_post fires after everything settles. Use wp_after_insert_post when your callback depends on meta or terms being already saved.

Practical — FAQs

Why is my WordPress filter callback making pages go blank?

Your callback probably doesn’t return a value. Filter callbacks MUST return the value passed in (modified or not). If you forget to return, the filter passes null down the chain, and the final consumer renders null — which displays as nothing. Always end your filter callback with return $value; even if you didn’t change it.

Why can't I remove a hook I just added?

Two common causes. (1) The remove call ran BEFORE the add call — fix by hooking your remove later (use a hook that fires after the add). (2) You used a different priority on remove than add — remove_action('init', 'cb') only removes if cb was added at priority 10. Always specify the priority on remove: remove_action('init', 'cb', $priority). (3) The callback is a closure — closures cannot be removed because they have no stable identity.

How do I prevent infinite loops when modifying posts in a save_post callback?

Two options. (1) Remove your hook before the update, re-add after: remove_action('save_post_shop_order', 'cb', 10); → do update → add_action('save_post_shop_order', 'cb', 10, 3);. (2) Use wp_after_insert_post instead of save_post — wp_after_insert_post fires after all save_post processing, so your update doesn’t re-fire save_post. The modern WP 5.6+ approach.

What is the most important factor in WordPress action vs filter hooks?

The single most important factor in WordPress action vs filter hooks is matching the project scope to the right delivery model. WordPress action vs filter hooks done by the wrong team type can cost 3-5x more than necessary; WordPress action vs filter hooks done by the right team is predictable, bounded, and produces measurable value. Run an honest scope discovery before committing to any WordPress action vs filter hooks engagement, and insist on detailed deliverables in the SOW so both sides are aligned on what success looks like.

Building a WordPress plugin and want pro-grade hook architecture, naming, and extensibility?

3 Comments

  1. Johnson

    Found every information needed regarding action and filter hooks. Excellent work, keep it up.

    1. Raja Aman Ullah

      Thank you.

  2. How to view all WordPress Hooks - WooCommerce Plugin Developer

    […] More BlogsComplete article about WordPress hooks and filtersRaja Aman […]

Leave a Reply