{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "name": "WordPress Hooks Index",
  "description": "Core WordPress actions and filters with signature, firing order, a worked example and the gotcha that bites people. Each entry carries a `verified` flag stating whether it has been hand-checked against WordPress source.",
  "source": "https://odiethemes.com/reference/wordpress-hooks",
  "license": "CC-BY-4.0",
  "licenseUrl": "https://creativecommons.org/licenses/by/4.0/",
  "attribution": "Data from Odie Themes — https://odiethemes.com/reference/wordpress-hooks",
  "count": 63,
  "hooks": [
    {
      "name": "init",
      "type": "action",
      "group": "Lifecycle",
      "signature": "add_action( 'init', 'my_callback' );",
      "whatItDoes": "Runs after WordPress has finished loading but before any headers are sent. The standard place to register custom post types, taxonomies, and rewrite rules.",
      "whenItFires": "On every request, front end and admin, after core, plugins and the theme are loaded.",
      "example": "add_action( 'init', function () {\n    register_post_type( 'book', array(\n        'public'       => true,\n        'label'        => 'Books',\n        'show_in_rest' => true, // required for the block editor\n    ) );\n} );",
      "gotcha": "init runs on EVERY request including AJAX, REST and cron. Doing anything slow here (an HTTP request, a heavy query) taxes every single page load. Also: the current user is available here, but you cannot output anything — headers have not been sent yet, but they are about to be.",
      "verified": true
    },
    {
      "name": "after_setup_theme",
      "type": "action",
      "group": "Lifecycle",
      "signature": "add_action( 'after_setup_theme', 'my_callback' );",
      "whatItDoes": "Runs once the theme is loaded. The correct place for add_theme_support(), add_image_size(), and registering nav menus.",
      "whenItFires": "After the theme's functions.php is loaded, BEFORE init.",
      "example": "add_action( 'after_setup_theme', function () {\n    add_theme_support( 'post-thumbnails' );\n    add_theme_support( 'title-tag' );\n    add_image_size( 'card', 600, 400, true ); // true = hard crop\n} );",
      "gotcha": "Registering image sizes on init instead of here works by accident in most cases and then fails mysteriously in some. Use after_setup_theme — it is what it is for.",
      "verified": true
    },
    {
      "name": "plugins_loaded",
      "type": "action",
      "group": "Lifecycle",
      "signature": "add_action( 'plugins_loaded', 'my_callback' );",
      "whatItDoes": "Runs once all active plugins are loaded. The earliest safe point to interact with another plugin.",
      "whenItFires": "After every plugin file is included, before the theme is loaded.",
      "gotcha": "The current user is NOT yet available here — wp_get_current_user() will not work reliably. If you need the user, use init.",
      "verified": true
    },
    {
      "name": "wp_loaded",
      "type": "action",
      "group": "Lifecycle",
      "signature": "add_action( 'wp_loaded', 'my_callback' );",
      "whatItDoes": "Runs after WordPress is fully loaded — plugins, theme, init, and the user are all ready.",
      "whenItFires": "After init and after the current user is set up.",
      "verified": true
    },
    {
      "name": "admin_init",
      "type": "action",
      "group": "Lifecycle",
      "signature": "add_action( 'admin_init', 'my_callback' );",
      "whatItDoes": "Runs at the start of every admin request. Used for register_setting(), permission checks, and admin redirects.",
      "whenItFires": "On every admin-side request.",
      "gotcha": "It also fires on admin-ajax.php requests, which are not really \"admin screens\". If your callback assumes a screen exists, guard it with wp_doing_ajax().",
      "verified": true
    },
    {
      "name": "wp_enqueue_scripts",
      "type": "action",
      "group": "Enqueue",
      "signature": "add_action( 'wp_enqueue_scripts', 'my_callback' );",
      "whatItDoes": "The one correct place to enqueue front-end CSS and JavaScript.",
      "whenItFires": "On front-end page loads, before the head is rendered.",
      "example": "add_action( 'wp_enqueue_scripts', function () {\n    wp_enqueue_style(\n        'child-style',\n        get_stylesheet_uri(),\n        array( 'parent-style' ),          // load AFTER the parent\n        wp_get_theme()->get( 'Version' )  // cache-bust on version change\n    );\n} );",
      "gotcha": "Despite the name it handles STYLES as well as scripts — there is no wp_enqueue_styles hook, and people waste real time looking for one. Also: this does not fire in the admin (use admin_enqueue_scripts) or in the block editor (use enqueue_block_editor_assets).",
      "verified": true
    },
    {
      "name": "admin_enqueue_scripts",
      "type": "action",
      "group": "Enqueue",
      "signature": "add_action( 'admin_enqueue_scripts', 'my_callback' ); // receives $hook_suffix",
      "whatItDoes": "Enqueue CSS/JS in the admin. Receives the current screen's hook suffix so you can load assets on one screen only.",
      "whenItFires": "On admin page loads.",
      "example": "add_action( 'admin_enqueue_scripts', function ( $hook ) {\n    if ( 'settings_page_my-plugin' !== $hook ) {\n        return; // do not load our JS on every admin screen\n    }\n    wp_enqueue_script( 'my-admin', plugins_url( 'admin.js', __FILE__ ), array(), '1.0', true );\n} );",
      "gotcha": "Not guarding on $hook is the single most common cause of a plugin breaking unrelated admin screens. Load your assets where they are needed and nowhere else.",
      "verified": true
    },
    {
      "name": "enqueue_block_editor_assets",
      "type": "action",
      "group": "Enqueue",
      "signature": "add_action( 'enqueue_block_editor_assets', 'my_callback' );",
      "whatItDoes": "Enqueue assets into the block editor (Gutenberg) only — not the front end, not the rest of the admin.",
      "whenItFires": "When the block editor loads.",
      "verified": true
    },
    {
      "name": "wp_head",
      "type": "action",
      "group": "Enqueue",
      "signature": "add_action( 'wp_head', 'my_callback' );",
      "whatItDoes": "Prints output into the <head>. Used for meta tags and inline critical CSS.",
      "whenItFires": "Inside <head>, on the front end.",
      "gotcha": "Do NOT enqueue scripts or styles here — use wp_enqueue_scripts, which lets WordPress handle dependencies and deduplication. Echoing a <script> tag into wp_head bypasses all of that and is why so many sites load jQuery three times.",
      "verified": true
    },
    {
      "name": "wp_footer",
      "type": "action",
      "group": "Enqueue",
      "signature": "add_action( 'wp_footer', 'my_callback' );",
      "whatItDoes": "Prints output just before </body>. The right place for analytics snippets and deferred markup.",
      "whenItFires": "At the end of the front-end page.",
      "gotcha": "A theme that forgets to call wp_footer() breaks the admin bar, breaks any plugin that enqueues a footer script, and is an automatic rejection in the wordpress.org theme review.",
      "verified": true
    },
    {
      "name": "the_content",
      "type": "filter",
      "group": "Content",
      "signature": "add_filter( 'the_content', 'my_callback' ); // receives $content",
      "whatItDoes": "Filters post content immediately before it is displayed. The usual place to append or prepend markup to a post.",
      "whenItFires": "Every time the_content() is called.",
      "example": "add_filter( 'the_content', function ( $content ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content; // <- the guard everyone forgets\n    }\n    return $content . '<p class=\"cta\">Enjoyed this? Read more.</p>';\n} );",
      "gotcha": "This runs far more often than you think — in widgets, in REST responses, in some plugins' excerpt generation, and once per post in a loop on an archive. Without the is_singular / in_the_loop / is_main_query guard, your CTA appears fifteen times on the blog index and inside the RSS feed. This is the most-misused filter in WordPress.",
      "verified": true
    },
    {
      "name": "the_title",
      "type": "filter",
      "group": "Content",
      "signature": "add_filter( 'the_title', 'my_callback', 10, 2 ); // receives $title, $post_id",
      "whatItDoes": "Filters the post title before display.",
      "whenItFires": "Every time the_title() or get_the_title() is called.",
      "gotcha": "It also fires for titles in the admin menu, in nav menus, and in the document <title>. Filtering it unconditionally will rename things in places you did not intend.",
      "verified": true
    },
    {
      "name": "excerpt_length",
      "type": "filter",
      "group": "Content",
      "signature": "add_filter( 'excerpt_length', 'my_callback' ); // receives $length (words)",
      "whatItDoes": "Sets how many WORDS an auto-generated excerpt contains. Default is 55.",
      "whenItFires": "When WordPress generates an excerpt for a post that has no manual excerpt.",
      "example": "add_filter( 'excerpt_length', function ( $length ) {\n    return 25;\n}, 999 ); // high priority: many themes set this too, and last one wins",
      "gotcha": "It has no effect on posts that have a MANUAL excerpt — those are used verbatim. And many themes set this filter themselves, so you often need a high priority number to win.",
      "verified": true
    },
    {
      "name": "excerpt_more",
      "type": "filter",
      "group": "Content",
      "signature": "add_filter( 'excerpt_more', 'my_callback' ); // receives $more",
      "whatItDoes": "Changes the string appended to a truncated excerpt. Default is \" […]\".",
      "whenItFires": "When an auto-generated excerpt is truncated.",
      "verified": true
    },
    {
      "name": "pre_get_posts",
      "type": "action",
      "group": "Query",
      "signature": "add_action( 'pre_get_posts', 'my_callback' ); // receives WP_Query $query (by reference)",
      "whatItDoes": "Modifies a query BEFORE it runs. The correct way to change what appears on an archive — far better than a second WP_Query.",
      "whenItFires": "After the query vars are parsed, before the SQL is built.",
      "example": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() ) {\n        return; // <- both guards are mandatory\n    }\n    if ( $query->is_category() ) {\n        $query->set( 'posts_per_page', 12 );\n    }\n} );",
      "gotcha": "It runs in the ADMIN too, and it runs for EVERY query, including menus, widgets and the media library. Omit the is_admin() and is_main_query() guards and you will silently change what the admin post list shows — a bug that is genuinely hard to trace back. Also note: modify $query, do not return anything.",
      "verified": true
    },
    {
      "name": "posts_where",
      "type": "filter",
      "group": "Query",
      "signature": "add_filter( 'posts_where', 'my_callback', 10, 2 ); // receives $where, WP_Query $query",
      "whatItDoes": "Filters the raw SQL WHERE clause of a query.",
      "whenItFires": "While the query SQL is being assembled.",
      "gotcha": "You are writing raw SQL. Always guard with $query->is_main_query() and always use $wpdb->prepare() for any user input. This is the fastest way to introduce SQL injection into a WordPress site.",
      "verified": true
    },
    {
      "name": "found_posts",
      "type": "filter",
      "group": "Query",
      "signature": "add_filter( 'found_posts', 'my_callback', 10, 2 ); // receives $found_posts, WP_Query $query",
      "whatItDoes": "Filters the total number of posts a query found — which is what pagination is calculated from.",
      "whenItFires": "After the query runs, before pagination is computed.",
      "verified": true
    },
    {
      "name": "big_image_size_threshold",
      "type": "filter",
      "group": "Images & media",
      "signature": "add_filter( 'big_image_size_threshold', 'my_callback' ); // receives int $threshold",
      "whatItDoes": "Sets the pixel size above which WordPress downscales an upload and serves a \"-scaled\" copy instead of your original. Default 2560.",
      "whenItFires": "On upload, when WordPress decides whether an image is \"big\".",
      "example": "// Serve originals at full resolution — no -scaled copy.\nadd_filter( 'big_image_size_threshold', '__return_false' );\n\n// Or just raise the ceiling:\nadd_filter( 'big_image_size_threshold', function () {\n    return 3840;\n} );",
      "gotcha": "This is the single most common cause of \"my image looks blurry after uploading to WordPress\", and almost nobody knows it exists. Your original is still on disk — it is simply never served. Note it applies to the LONGEST edge, and only affects images uploaded after the filter is active.",
      "verified": true
    },
    {
      "name": "intermediate_image_sizes_advanced",
      "type": "filter",
      "group": "Images & media",
      "signature": "add_filter( 'intermediate_image_sizes_advanced', 'my_callback', 10, 2 ); // receives $sizes, $metadata",
      "whatItDoes": "Filters the array of image sizes WordPress is about to generate for an upload. Unset a size to stop generating it.",
      "whenItFires": "On upload, before the derivative files are created.",
      "example": "add_filter( 'intermediate_image_sizes_advanced', function ( $sizes ) {\n    unset( $sizes['1536x1536'] );\n    unset( $sizes['2048x2048'] );\n    return $sizes;\n} );",
      "gotcha": "Removing a size that your theme actually USES means WordPress falls back to the full-size original — which is worse than the disk space you saved. And this only stops NEW files being made; the existing ones stay until you regenerate.",
      "verified": true
    },
    {
      "name": "jpeg_quality",
      "type": "filter",
      "group": "Images & media",
      "signature": "add_filter( 'jpeg_quality', 'my_callback', 10, 2 ); // receives $quality, $context",
      "whatItDoes": "Sets the JPEG quality WordPress re-encodes uploaded images at. Default 82.",
      "whenItFires": "Whenever WordPress writes a JPEG.",
      "gotcha": "Quality 82 is fine for photographs and visibly poor for screenshots, logos and flat graphics — those have hard edges and no photographic noise to hide artefacts in. Also: wp_editor_set_quality is the more modern filter and covers the image editor classes directly; setting both is common.",
      "verified": true
    },
    {
      "name": "wp_editor_set_quality",
      "type": "filter",
      "group": "Images & media",
      "signature": "add_filter( 'wp_editor_set_quality', 'my_callback', 10, 2 ); // receives $quality, $mime_type",
      "whatItDoes": "Sets the output quality used by the WP_Image_Editor classes (GD and Imagick).",
      "whenItFires": "When an image editor instance sets its quality.",
      "verified": true
    },
    {
      "name": "image_size_names_choose",
      "type": "filter",
      "group": "Images & media",
      "signature": "add_filter( 'image_size_names_choose', 'my_callback' ); // receives $sizes",
      "whatItDoes": "Adds your custom image sizes to the size dropdown in the media modal and block editor.",
      "whenItFires": "When the media UI builds its size selector.",
      "example": "add_filter( 'image_size_names_choose', function ( $sizes ) {\n    return array_merge( $sizes, array( 'card' => __( 'Card', 'textdomain' ) ) );\n} );",
      "gotcha": "add_image_size() registers the size but does NOT make it selectable in the editor. Every \"my custom image size does not appear in the dropdown\" question is this filter being missing.",
      "verified": true
    },
    {
      "name": "wp_handle_upload_prefilter",
      "type": "filter",
      "group": "Images & media",
      "signature": "add_filter( 'wp_handle_upload_prefilter', 'my_callback' ); // receives $file",
      "whatItDoes": "Inspects or rejects a file BEFORE it is moved into the uploads folder.",
      "whenItFires": "During upload, before the file is written.",
      "gotcha": "To reject a file, set $file['error'] to a message string and return $file. Returning false or throwing will not do what you want.",
      "verified": true
    },
    {
      "name": "wp_generate_attachment_metadata",
      "type": "filter",
      "group": "Images & media",
      "signature": "add_filter( 'wp_generate_attachment_metadata', 'my_callback', 10, 2 ); // receives $metadata, $attachment_id",
      "whatItDoes": "Filters the metadata array (including the generated sizes) after an attachment is processed.",
      "whenItFires": "After the derivative images are created on upload.",
      "gotcha": "This is the hook image-optimization plugins use. It can be slow — it runs after every image size is written, and doing an HTTP request here makes every upload wait on it.",
      "verified": true
    },
    {
      "name": "wp_calculate_image_srcset",
      "type": "filter",
      "group": "Images & media",
      "signature": "add_filter( 'wp_calculate_image_srcset', 'my_callback', 10, 5 );",
      "whatItDoes": "Filters the srcset candidates WordPress generates for a responsive image.",
      "whenItFires": "When a responsive image is rendered.",
      "gotcha": "Removing the medium_large (768w) size elsewhere silently degrades srcset — that size exists almost entirely to serve it.",
      "verified": false
    },
    {
      "name": "admin_menu",
      "type": "action",
      "group": "Admin",
      "signature": "add_action( 'admin_menu', 'my_callback' );",
      "whatItDoes": "Registers admin menu and submenu pages.",
      "whenItFires": "When the admin menu is being built.",
      "example": "add_action( 'admin_menu', function () {\n    add_options_page(\n        'My Plugin',\n        'My Plugin',\n        'manage_options',   // capability — NOT a role\n        'my-plugin',\n        'my_render_settings_page'\n    );\n} );",
      "gotcha": "The capability argument is a CAPABILITY, not a role. Passing \"administrator\" appears to work (because admins have a capability of that name in some setups) and then quietly grants access to the wrong people. Use manage_options.",
      "verified": true
    },
    {
      "name": "admin_notices",
      "type": "action",
      "group": "Admin",
      "signature": "add_action( 'admin_notices', 'my_callback' );",
      "whatItDoes": "Prints a notice at the top of an admin screen.",
      "whenItFires": "On admin page render.",
      "gotcha": "It fires on EVERY admin screen. A plugin that shows an unconditional \"thanks for installing!\" notice on all of them is the single most-hated pattern in the WordPress admin. Guard it, and make it dismissible.",
      "verified": true
    },
    {
      "name": "save_post",
      "type": "action",
      "group": "Admin",
      "signature": "add_action( 'save_post', 'my_callback', 10, 3 ); // receives $post_id, $post, $update",
      "whatItDoes": "Runs whenever a post is created or updated. The usual place to save meta box data.",
      "whenItFires": "After a post is written to the database.",
      "example": "add_action( 'save_post', function ( $post_id ) {\n    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;\n    if ( wp_is_post_revision( $post_id ) ) return;\n    if ( ! current_user_can( 'edit_post', $post_id ) ) return;\n    // ... now it is safe to save\n}, 10, 1 );",
      "gotcha": "It fires on AUTOSAVES and on REVISIONS as well as real saves. Without the three guards above, your meta gets overwritten with empty values every time the editor autosaves — a bug that looks like data randomly disappearing.",
      "verified": true
    },
    {
      "name": "add_meta_boxes",
      "type": "action",
      "group": "Admin",
      "signature": "add_action( 'add_meta_boxes', 'my_callback', 10, 2 ); // receives $post_type, $post",
      "whatItDoes": "Registers meta boxes on the post edit screen.",
      "whenItFires": "When the edit screen is built.",
      "gotcha": "Classic meta boxes do not appear in the block editor unless the block editor falls back to compatibility mode. For a block-editor-native site, register post meta with show_in_rest and build a sidebar panel instead.",
      "verified": true
    },
    {
      "name": "wp_login",
      "type": "action",
      "group": "Users & auth",
      "signature": "add_action( 'wp_login', 'my_callback', 10, 2 ); // receives $user_login, WP_User $user",
      "whatItDoes": "Runs after a user successfully logs in.",
      "whenItFires": "On successful authentication.",
      "verified": true
    },
    {
      "name": "user_register",
      "type": "action",
      "group": "Users & auth",
      "signature": "add_action( 'user_register', 'my_callback', 10, 2 ); // receives $user_id, $userdata",
      "whatItDoes": "Runs immediately after a new user is created.",
      "whenItFires": "On registration.",
      "verified": true
    },
    {
      "name": "authenticate",
      "type": "filter",
      "group": "Users & auth",
      "signature": "add_filter( 'authenticate', 'my_callback', 30, 3 ); // receives $user, $username, $password",
      "whatItDoes": "Filters the authentication result. Return a WP_Error to block a login.",
      "whenItFires": "During the login attempt.",
      "gotcha": "Priority matters a lot here. WordPress core hooks its own checks at priorities 20 and 30 — attach too early and you run before the password has even been checked.",
      "verified": true
    },
    {
      "name": "rest_api_init",
      "type": "action",
      "group": "REST & cron",
      "signature": "add_action( 'rest_api_init', 'my_callback' );",
      "whatItDoes": "Registers REST routes and fields.",
      "whenItFires": "When the REST API initialises.",
      "example": "add_action( 'rest_api_init', function () {\n    register_rest_route( 'myplugin/v1', '/items', array(\n        'methods'             => 'GET',\n        'callback'            => 'my_get_items',\n        'permission_callback' => '__return_true', // BE DELIBERATE ABOUT THIS\n    ) );\n} );",
      "gotcha": "permission_callback is REQUIRED — since WP 5.5 omitting it throws a notice, and setting it to __return_true makes the endpoint fully public. That is fine for genuinely public data and a serious hole for anything else. Decide, do not default.",
      "verified": true
    },
    {
      "name": "cron_schedules",
      "type": "filter",
      "group": "REST & cron",
      "signature": "add_filter( 'cron_schedules', 'my_callback' ); // receives $schedules",
      "whatItDoes": "Adds a custom recurrence interval for wp_schedule_event().",
      "whenItFires": "When WordPress builds its list of available schedules.",
      "example": "add_filter( 'cron_schedules', function ( $schedules ) {\n    $schedules['every_five_minutes'] = array(\n        'interval' => 300,             // seconds\n        'display'  => __( 'Every 5 minutes', 'textdomain' ),\n    );\n    return $schedules;\n} );",
      "gotcha": "WP-Cron is not a real cron. It only fires when someone visits the site, so a low-traffic site will not run a five-minute schedule every five minutes. If the timing actually matters, disable WP-Cron (DISABLE_WP_CRON) and call wp-cron.php from a real system cron.",
      "verified": true
    },
    {
      "name": "comment_post",
      "type": "action",
      "group": "Comments",
      "signature": "add_action( 'comment_post', 'my_callback', 10, 3 ); // receives $comment_id, $approved, $commentdata",
      "whatItDoes": "Runs immediately after a comment is saved.",
      "whenItFires": "On comment submission.",
      "verified": true
    },
    {
      "name": "preprocess_comment",
      "type": "filter",
      "group": "Comments",
      "signature": "add_filter( 'preprocess_comment', 'my_callback' ); // receives $commentdata",
      "whatItDoes": "Filters comment data before it is saved. Used for spam checks and validation.",
      "whenItFires": "Before the comment is written.",
      "gotcha": "To reject a comment here, call wp_die() with a message — returning false does not stop it.",
      "verified": true
    },
    {
      "name": "woocommerce_thankyou",
      "type": "action",
      "group": "WooCommerce",
      "signature": "add_action( 'woocommerce_thankyou', 'my_callback' ); // receives $order_id",
      "whatItDoes": "Runs on the order-received page after a successful checkout.",
      "whenItFires": "When the customer lands on the thank-you page.",
      "gotcha": "This is NOT a reliable place to trigger fulfilment. It only fires if the customer actually reaches the thank-you page — if they close the tab after paying, it never runs. Use woocommerce_payment_complete or an order-status transition hook for anything that must happen.",
      "verified": true
    },
    {
      "name": "woocommerce_before_add_to_cart_button",
      "type": "action",
      "group": "WooCommerce",
      "signature": "add_action( 'woocommerce_before_add_to_cart_button', 'my_callback' );",
      "whatItDoes": "Outputs markup immediately before the add-to-cart button on a product page.",
      "whenItFires": "While the product page renders.",
      "verified": true
    },
    {
      "name": "woocommerce_checkout_fields",
      "type": "filter",
      "group": "WooCommerce",
      "signature": "add_filter( 'woocommerce_checkout_fields', 'my_callback' ); // receives $fields",
      "whatItDoes": "Adds, removes or reorders fields on the classic checkout.",
      "whenItFires": "When the checkout form is built.",
      "gotcha": "This only affects the CLASSIC (shortcode) checkout. The newer block-based checkout ignores it entirely and is customised through Slot & Fill in JavaScript. This trips up almost everyone the first time.",
      "verified": true
    },
    {
      "name": "script_loader_tag",
      "type": "filter",
      "group": "Enqueue & assets",
      "signature": "add_filter( 'script_loader_tag', 'my_callback', 10, 3 ); // receives $tag, $handle, $src",
      "whatItDoes": "Filters the full <script> HTML tag before it is printed. The supported way to add defer, async, type=\"module\", integrity or any other attribute to an enqueued script.",
      "whenItFires": "When WP_Scripts prints each enqueued script, in the head or the footer.",
      "example": "add_filter( 'script_loader_tag', function ( $tag, $handle, $src ) {\n    if ( 'my-script' !== $handle ) {\n        return $tag; // only touch our own handle\n    }\n    return str_replace( ' src=', ' defer src=', $tag );\n}, 10, 3 );",
      "gotcha": "Returning a modified tag without checking $handle rewrites EVERY script on the page — including jQuery and admin scripts — and deferring jQuery breaks inline scripts that call $ before it exists. Also, this filter does not run for inline scripts added with wp_add_inline_script(), so a deferred script's inline data can execute before the script itself.",
      "verified": true
    },
    {
      "name": "style_loader_tag",
      "type": "filter",
      "group": "Enqueue & assets",
      "signature": "add_filter( 'style_loader_tag', 'my_callback', 10, 4 ); // receives $tag, $handle, $href, $media",
      "whatItDoes": "Filters the full <link rel=\"stylesheet\"> tag before it is printed. Used to add attributes, or to convert a stylesheet into a preload for non-critical CSS.",
      "whenItFires": "When WP_Styles prints each enqueued stylesheet.",
      "example": "add_filter( 'style_loader_tag', function ( $tag, $handle ) {\n    if ( 'my-nonessential' !== $handle ) {\n        return $tag;\n    }\n    return str_replace( \"media='all'\", \"media='print' onload=\\\"this.media='all'\\\"\", $tag );\n}, 10, 4 );",
      "gotcha": "The $media attribute in the tag is quoted with SINGLE quotes by core, so a str_replace looking for media=\"all\" silently matches nothing and you conclude the filter never ran. Match the actual output, or build the tag yourself.",
      "verified": true
    },
    {
      "name": "wp_default_scripts",
      "type": "action",
      "group": "Enqueue & assets",
      "signature": "add_action( 'wp_default_scripts', 'my_callback' ); // receives WP_Scripts $scripts (by reference)",
      "whatItDoes": "Runs while WordPress registers its own bundled scripts. The correct place to swap a core-registered handle — for example pointing jQuery at a different version — before anything can depend on it.",
      "whenItFires": "During WP_Scripts setup, before any theme or plugin enqueue hook runs.",
      "example": "add_action( 'wp_default_scripts', function ( $scripts ) {\n    if ( is_admin() ) {\n        return; // never touch admin scripts\n    }\n    // Drop jQuery Migrate from the jquery bundle.\n    if ( ! empty( $scripts->registered['jquery'] ) ) {\n        $scripts->registered['jquery']->deps = array_diff(\n            $scripts->registered['jquery']->deps,\n            array( 'jquery-migrate' )\n        );\n    }\n} );",
      "gotcha": "This fires far earlier than wp_enqueue_scripts, which is exactly why deregistering core jQuery from wp_enqueue_scripts often appears to do nothing — something already depends on it by then. Modify $scripts directly; it is passed by reference and you return nothing. Forgetting the is_admin() guard here breaks the block editor and the media library, which is the worst version of this bug because the front end looks fine.",
      "verified": true
    },
    {
      "name": "login_enqueue_scripts",
      "type": "action",
      "group": "Enqueue & assets",
      "signature": "add_action( 'login_enqueue_scripts', 'my_callback' );",
      "whatItDoes": "Enqueue CSS and JavaScript on wp-login.php — the login, registration and lost-password screens.",
      "whenItFires": "In the <head> of the login page.",
      "example": "add_action( 'login_enqueue_scripts', function () {\n    wp_enqueue_style(\n        'my-login',\n        get_stylesheet_directory_uri() . '/login.css',\n        array(),\n        wp_get_theme()->get( 'Version' )\n    );\n} );",
      "gotcha": "Neither wp_enqueue_scripts nor admin_enqueue_scripts fires on the login screen, which is why a custom login style added the usual way just never appears. The login page also does not load your theme, so anything your CSS inherits from the theme is not there.",
      "verified": true
    },
    {
      "name": "enqueue_block_assets",
      "type": "action",
      "group": "Enqueue & assets",
      "signature": "add_action( 'enqueue_block_assets', 'my_callback' );",
      "whatItDoes": "Enqueue assets that must load in BOTH the block editor and the front end — typically the shared styles a block needs to look the same in each.",
      "whenItFires": "On front-end page loads and when the block editor loads.",
      "example": "add_action( 'enqueue_block_assets', function () {\n    wp_enqueue_style(\n        'my-block-shared',\n        plugins_url( 'block.css', __FILE__ ),\n        array(),\n        '1.0'\n    );\n} );",
      "gotcha": "It fires in both contexts, so anything editor-only registered here leaks onto the front end, and any script enqueued here runs inside the editor too. If you want editor-only, use enqueue_block_editor_assets; if you want front-end-only, use wp_enqueue_scripts. Putting editor JavaScript on enqueue_block_assets is a common cause of console errors on the public site.",
      "verified": true
    },
    {
      "name": "wp_resource_hints",
      "type": "filter",
      "group": "Enqueue & assets",
      "signature": "add_filter( 'wp_resource_hints', 'my_callback', 10, 2 ); // receives $urls, $relation_type",
      "whatItDoes": "Filters the URLs WordPress emits as resource hints (dns-prefetch, preconnect, prefetch, prerender) in the head.",
      "whenItFires": "While the <head> is being printed, once per relation type.",
      "example": "add_filter( 'wp_resource_hints', function ( $urls, $relation_type ) {\n    if ( 'dns-prefetch' === $relation_type ) {\n        $urls = array_diff( wp_dependencies_unique_hosts(), $urls );\n    }\n    return $urls;\n}, 10, 2 );",
      "gotcha": "The filter runs separately for each relation type, so you must check $relation_type or you will strip preconnect hints while trying to remove a dns-prefetch. Entries can be plain URL strings OR arrays with href/crossorigin keys, and code that assumes strings will break on the array form.",
      "verified": false
    },
    {
      "name": "wp_insert_post_data",
      "type": "filter",
      "group": "Posts & queries",
      "signature": "add_filter( 'wp_insert_post_data', 'my_callback', 10, 2 ); // receives $data, $postarr",
      "whatItDoes": "Filters the post row array immediately before it is written to the database. The only hook that can change post_title, post_content, post_status or post_name on the way in — save_post is too late, the row is already saved.",
      "whenItFires": "Inside wp_insert_post(), after sanitisation, before the INSERT/UPDATE query runs.",
      "example": "add_filter( 'wp_insert_post_data', function ( $data, $postarr ) {\n    if ( 'post' === $data['post_type'] && '' === trim( $data['post_title'] ) ) {\n        $data['post_title'] = 'Untitled ' . current_time( 'Y-m-d' );\n    }\n    return $data;\n}, 10, 2 );",
      "gotcha": "$data arrives SLASHED. If you run wp_unslash() on it and return it, every apostrophe in the site's content quietly loses its backslash and the next save mangles it. Modify the slashed value in place, or re-slash with wp_slash() before returning. It also fires for revisions and autosaves, so check $data['post_type'] before touching anything. Later WordPress versions pass extra arguments after $postarr; requesting only 2 is safe and is what almost all code does.",
      "verified": true
    },
    {
      "name": "wp_after_insert_post",
      "type": "action",
      "group": "Posts & queries",
      "signature": "add_action( 'wp_after_insert_post', 'my_callback', 10, 4 ); // receives $post_id, WP_Post $post, $update, $post_before",
      "whatItDoes": "Runs after a post AND its meta and terms have all been saved. The reliable place to react to a finished save.",
      "whenItFires": "At the very end of the save, after save_post and after the block editor / REST API has written post meta and taxonomy terms. WordPress 5.6 and later.",
      "example": "add_action( 'wp_after_insert_post', function ( $post_id, $post, $update, $post_before ) {\n    if ( 'post' !== $post->post_type || wp_is_post_revision( $post_id ) ) {\n        return;\n    }\n    // Safe: meta is written by now.\n    $price = get_post_meta( $post_id, 'price', true );\n}, 10, 4 );",
      "gotcha": "This exists because save_post lies to you in the block editor. The REST API saves the post row first and the meta and terms afterwards, so get_post_meta() inside save_post returns the PREVIOUS value — the classic \"my meta is always one save behind\" bug. Move that code here. Requires WP 5.6+, so guard with a function_exists / version check if you support older installs.",
      "verified": true
    },
    {
      "name": "transition_post_status",
      "type": "action",
      "group": "Posts & queries",
      "signature": "add_action( 'transition_post_status', 'my_callback', 10, 3 ); // receives $new_status, $old_status, WP_Post $post",
      "whatItDoes": "Runs on every post status change and tells you both the old and the new status. The correct hook for \"do something the first time a post is published\".",
      "whenItFires": "During wp_insert_post(), after the row is written, as the status moves from one value to another.",
      "example": "add_action( 'transition_post_status', function ( $new, $old, $post ) {\n    if ( 'publish' !== $new || 'publish' === $old ) {\n        return; // already published: this is just an edit\n    }\n    if ( 'post' !== $post->post_type ) {\n        return;\n    }\n    my_send_new_post_notification( $post->ID );\n}, 10, 3 );",
      "gotcha": "It fires on every save of an already-published post as publish -> publish. Miss the `'publish' === $old` check and your \"new post published!\" email goes out again every time somebody fixes a typo. For a brand new post $old_status is the literal string 'new', not '' or 'auto-draft'. It also fires for revisions and auto-drafts, so check $post->post_type.",
      "verified": true
    },
    {
      "name": "{$new_status}_{$post_type}",
      "type": "action",
      "group": "Posts & queries",
      "signature": "add_action( 'publish_post', 'my_callback', 10, 2 ); // dynamic: publish_page, publish_book, draft_post…",
      "whatItDoes": "A dynamic action built from the new status and the post type, so you can hook exactly one status/type combination without a callback full of if statements.",
      "whenItFires": "Alongside transition_post_status, during the same status change.",
      "example": "// Fires for a custom post type 'book' entering publish.\nadd_action( 'publish_book', function ( $post_id, $post ) {\n    my_ping_index( $post_id );\n}, 10, 2 );",
      "gotcha": "The hook name only encodes the NEW status, so publish_post fires on every subsequent update of an already-published post too — it is not a \"published for the first time\" hook, however much the name suggests it. If you need first-publish only, use transition_post_status and compare $old_status, or hook the old-to-new pair draft_to_publish. Note the argument count here is what I am least sure of; verify against your WordPress version before relying on the second parameter.",
      "verified": false
    },
    {
      "name": "posts_clauses",
      "type": "filter",
      "group": "Posts & queries",
      "signature": "add_filter( 'posts_clauses', 'my_callback', 10, 2 ); // receives array $clauses, WP_Query $query",
      "whatItDoes": "Filters every SQL clause of a query at once — where, join, orderby, groupby, distinct, fields and limits — as one array. Use it instead of posts_where when your change needs a JOIN and an ORDER BY to agree with each other.",
      "whenItFires": "While WP_Query assembles its SQL, after the individual clause filters.",
      "example": "add_filter( 'posts_clauses', function ( $clauses, $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_post_type_archive( 'book' ) ) {\n        return $clauses;\n    }\n    global $wpdb;\n    $clauses['join']    .= \" INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = {$wpdb->posts}.ID AND pm.meta_key = 'rating' \";\n    $clauses['orderby'] = ' pm.meta_value+0 DESC ';\n    return $clauses;\n}, 10, 2 );",
      "gotcha": "get_posts() sets 'suppress_filters' => true by default, so this filter — and posts_where, posts_join and the_posts — never runs for it. Code that works perfectly in a `new WP_Query()` and does nothing at all in get_posts() is this, not a caching problem. Pass 'suppress_filters' => false, or use WP_Query directly. And you are writing raw SQL: prepare anything that came from a user.",
      "verified": true
    },
    {
      "name": "the_posts",
      "type": "filter",
      "group": "Posts & queries",
      "signature": "add_filter( 'the_posts', 'my_callback', 10, 2 ); // receives array $posts, WP_Query $query",
      "whatItDoes": "Filters the array of post objects a query returned, after the SQL has run. Used to reorder, filter out or inject posts in PHP when SQL cannot express what you need.",
      "whenItFires": "At the end of WP_Query::get_posts(), once the results are fetched.",
      "example": "add_filter( 'the_posts', function ( $posts, $query ) {\n    if ( is_admin() || ! $query->is_main_query() ) {\n        return $posts;\n    }\n    return array_values( array_filter( $posts, function ( $p ) {\n        return ! get_post_meta( $p->ID, 'hidden', true );\n    } ) );\n}, 10, 2 );",
      "gotcha": "Removing posts here does NOT update $query->found_posts, so pagination still counts the ones you dropped: page 2 shows fewer items and the last page can come back empty. If you filter here, filter found_posts to match — or better, exclude them in pre_get_posts so the database does the counting. Also, like posts_clauses, it is skipped entirely by get_posts() unless you set 'suppress_filters' => false.",
      "verified": true
    },
    {
      "name": "admin_bar_menu",
      "type": "action",
      "group": "Admin",
      "signature": "add_action( 'admin_bar_menu', 'my_callback', 100 ); // receives WP_Admin_Bar $wp_admin_bar (by reference)",
      "whatItDoes": "Adds, changes or removes nodes in the toolbar. You mutate the passed WP_Admin_Bar object with add_node() / remove_node() rather than returning anything.",
      "whenItFires": "While the toolbar is assembled — on admin screens AND on the front end whenever the bar is shown.",
      "example": "add_action( 'admin_bar_menu', function ( $wp_admin_bar ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n    $wp_admin_bar->add_node( array(\n        'id'    => 'purge-cache',\n        'title' => 'Purge cache',\n        'href'  => wp_nonce_url( admin_url( 'admin-post.php?action=purge_cache' ), 'purge_cache' ),\n    ) );\n}, 100 ); // core's own nodes are added at priorities up to ~80",
      "gotcha": "Priority is the whole game: core registers its own nodes across a spread of priorities, so at the default 10 your node lands in the middle of them and remove_node() on a core node silently does nothing because that node does not exist yet. Use a high number (100+) to append or to remove. Also remember this fires on the FRONT END too — guard with is_admin() if the item is admin-only.",
      "verified": true
    },
    {
      "name": "plugin_action_links_{$plugin_file}",
      "type": "filter",
      "group": "Admin",
      "signature": "add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), 'my_callback' ); // receives $actions",
      "whatItDoes": "Filters the row of action links under your plugin on the Plugins screen — the Activate / Deactivate / Edit row. The standard way to add a \"Settings\" link.",
      "whenItFires": "While the Plugins list table renders each row.",
      "example": "add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), function ( $actions ) {\n    array_unshift( $actions, sprintf(\n        '<a href=\"%s\">%s</a>',\n        esc_url( admin_url( 'options-general.php?page=my-plugin' ) ),\n        esc_html__( 'Settings', 'textdomain' )\n    ) );\n    return $actions;\n} );",
      "gotcha": "The dynamic part must be plugin_basename() of your MAIN plugin file. Register this from an include and __FILE__ resolves to that include, the hook name never matches, and your link just never appears — no error, nothing to debug. Also: array_unshift puts Settings first; a plain append lands it after Deactivate where nobody looks.",
      "verified": true
    },
    {
      "name": "manage_{$post_type}_posts_columns",
      "type": "filter",
      "group": "Admin",
      "signature": "add_filter( 'manage_book_posts_columns', 'my_callback' ); // receives $columns",
      "whatItDoes": "Filters the column headers of the post-list table for one post type. Keys are column slugs, values are the header labels.",
      "whenItFires": "When the list table builds its column set, before any row is rendered.",
      "example": "add_filter( 'manage_book_posts_columns', function ( $columns ) {\n    $date = $columns['date'];\n    unset( $columns['date'] );\n    $columns['isbn'] = __( 'ISBN', 'textdomain' );\n    $columns['date'] = $date; // put Date back on the end\n    return $columns;\n} );",
      "gotcha": "This only adds the HEADER. Nothing renders in the cells until you also hook manage_{$post_type}_posts_custom_column — a half-done pair gives you a column of blanks. And a plain append lands your column after Date, which reads wrong; unset Date and re-add it as above. For the Pages screen the non-dynamic variant is manage_pages_columns, not manage_posts_columns.",
      "verified": true
    },
    {
      "name": "manage_{$post_type}_posts_custom_column",
      "type": "action",
      "group": "Admin",
      "signature": "add_action( 'manage_book_posts_custom_column', 'my_callback', 10, 2 ); // receives $column, $post_id",
      "whatItDoes": "Renders the cell contents for a custom column you registered on the post-list table. You echo; you do not return.",
      "whenItFires": "Once per custom column per row, while the list table renders.",
      "example": "add_action( 'manage_book_posts_custom_column', function ( $column, $post_id ) {\n    if ( 'isbn' === $column ) {\n        echo esc_html( get_post_meta( $post_id, 'isbn', true ) );\n    }\n}, 10, 2 );",
      "gotcha": "It is an ACTION that echoes — but the equivalents for other list tables are FILTERS that return, with different argument orders (manage_users_custom_column takes $output, $column_name, $user_id and must return $output; the taxonomy one is manage_{$taxonomy}_custom_column). Copy the post version into a users screen and you get an empty column plus a \"return value ignored\" style bug with no warning.",
      "verified": true
    },
    {
      "name": "admin_body_class",
      "type": "filter",
      "group": "Admin",
      "signature": "add_filter( 'admin_body_class', 'my_callback' ); // receives $classes",
      "whatItDoes": "Adds classes to the admin <body> so you can scope admin CSS to a specific screen without touching every rule.",
      "whenItFires": "When the admin header renders the opening body tag.",
      "example": "add_filter( 'admin_body_class', function ( $classes ) {\n    $screen = get_current_screen();\n    if ( $screen && 'book' === $screen->post_type ) {\n        $classes .= ' my-book-screen'; // note the leading space\n    }\n    return $classes;\n} );",
      "gotcha": "It passes a space-separated STRING, not an array — unlike the front-end body_class filter, which passes an array. Treating it like an array (or concatenating without a leading space) fuses your class onto the previous one and the selector silently never matches.",
      "verified": true
    },
    {
      "name": "admin_post_{$action}",
      "type": "action",
      "group": "Admin",
      "signature": "add_action( 'admin_post_my_action', 'my_callback' ); // no arguments",
      "whatItDoes": "Handles a form POST or link sent to admin-post.php for logged-in users. The supported alternative to writing your own endpoint file.",
      "whenItFires": "On a request to wp-admin/admin-post.php whose action parameter matches the suffix.",
      "example": "add_action( 'admin_post_my_action', function () {\n    check_admin_referer( 'my_action' );\n    if ( ! current_user_can( 'manage_options' ) ) {\n        wp_die( esc_html__( 'Nope.', 'textdomain' ), 403 );\n    }\n    update_option( 'my_thing', sanitize_text_field( wp_unslash( $_POST['my_thing'] ?? '' ) ) );\n    wp_safe_redirect( add_query_arg( 'updated', '1', admin_url( 'options-general.php?page=my-plugin' ) ) );\n    exit; // without this you get a blank white screen\n} );",
      "gotcha": "Three separate traps. (1) Logged-out requests fire admin_post_nopriv_{$action} instead — same suffix, different hook — so a public form that \"works while testing\" dies for everyone else. (2) Nothing is printed for you: finish with wp_safe_redirect() + exit or the user stares at a blank page. (3) The nonce and capability checks are yours to write; admin-post.php performs neither.",
      "verified": true
    },
    {
      "name": "wp_ajax_{$action}",
      "type": "action",
      "group": "REST & AJAX",
      "signature": "add_action( 'wp_ajax_my_action', 'my_callback' ); // $action = the 'action' param sent in the request",
      "whatItDoes": "Handles an admin-ajax.php request from a LOGGED-IN user. The hook name is built from the 'action' value your JavaScript posts.",
      "whenItFires": "On admin-ajax.php, after WordPress loads and the current user is set, for authenticated users only.",
      "example": "add_action( 'wp_ajax_my_save_thing', function () {\n    check_ajax_referer( 'my_save_thing' );          // nonce, or anyone can call this\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        wp_send_json_error( 'nope', 403 );          // capability != logged in\n    }\n    update_option( 'my_thing', sanitize_text_field( $_POST['value'] ) );\n    wp_send_json_success( array( 'saved' => true ) ); // dies for you\n} );",
      "gotcha": "Your handler MUST die. If it just returns, admin-ajax.php appends \"0\" to your output and your JSON silently fails to parse — that stray 0 is the single most-searched AJAX symptom in WordPress. wp_send_json_success()/wp_send_json_error() die for you; a bare echo does not. Second trap: being logged in is not permission. Anyone with a subscriber account can hit this hook, so you still need check_ajax_referer() AND a current_user_can() check.",
      "verified": true
    },
    {
      "name": "wp_ajax_nopriv_{$action}",
      "type": "action",
      "group": "REST & AJAX",
      "signature": "add_action( 'wp_ajax_nopriv_my_action', 'my_callback' );",
      "whatItDoes": "Handles an admin-ajax.php request from a LOGGED-OUT visitor. Same action name, separate hook.",
      "whenItFires": "On admin-ajax.php for unauthenticated requests only.",
      "example": "// Front-end AJAX has to be registered TWICE to work for everyone.\nadd_action( 'wp_ajax_my_public_thing',        'my_handler' );\nadd_action( 'wp_ajax_nopriv_my_public_thing', 'my_handler' );",
      "gotcha": "\"It works when I'm logged in and returns 0 when I log out\" is always this hook being missing. wp_ajax_ and wp_ajax_nopriv_ are completely separate — front-end AJAX needs both registered. And because nopriv is by definition public, nonces here only prove request freshness, not identity: never put anything behind it that a stranger should not be able to run.",
      "verified": true
    },
    {
      "name": "rest_authentication_errors",
      "type": "filter",
      "group": "REST & AJAX",
      "signature": "add_filter( 'rest_authentication_errors', 'my_callback' ); // receives WP_Error|null|true $errors",
      "whatItDoes": "Decides whether a REST request is authenticated. Return a WP_Error to reject it, true to accept it, or pass the value through untouched.",
      "whenItFires": "Early in REST request handling, before routing and before any route's permission_callback.",
      "example": "add_filter( 'rest_authentication_errors', function ( $errors ) {\n    if ( ! empty( $errors ) ) {\n        return $errors; // someone already decided — do not stomp them\n    }\n    if ( ! is_user_logged_in() ) {\n        return new WP_Error( 'rest_forbidden', 'Login required.', array( 'status' => 401 ) );\n    }\n    return $errors;\n} );",
      "gotcha": "The \"lock down the REST API\" snippet copy-pasted from a hundred blog posts breaks the block editor, because Gutenberg talks to the REST API as you and some requests are evaluated before your session is what you expect. Two rules: always return early if $errors is already non-null (otherwise you silently cancel every other auth plugin), and never return a bare true — that asserts the request IS authenticated and short-circuits core's own checks.",
      "verified": true
    },
    {
      "name": "rest_pre_dispatch",
      "type": "filter",
      "group": "REST & AJAX",
      "signature": "add_filter( 'rest_pre_dispatch', 'my_callback', 10, 3 ); // receives $result, WP_REST_Server $server, WP_REST_Request $request",
      "whatItDoes": "Lets you hijack a REST request before it is routed. Return anything non-null and that becomes the response.",
      "whenItFires": "After authentication, before the route is matched and before the endpoint's permission_callback or callback runs.",
      "example": "add_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) {\n    if ( '/myplugin/v1/report' !== $request->get_route() ) {\n        return $result; // null = carry on normally\n    }\n    $cached = get_transient( 'my_report' );\n    return ( false === $cached ) ? $result : rest_ensure_response( $cached );\n}, 10, 3 );",
      "gotcha": "Returning non-null skips the endpoint entirely — including its permission_callback. Serve a cached response here and you have just served it to everyone, authenticated or not. Do your own capability check before returning, and return $result (usually null) in every other case or you will blank out the whole REST API, block editor included.",
      "verified": true
    },
    {
      "name": "rest_prepare_{$post_type}",
      "type": "filter",
      "group": "REST & AJAX",
      "signature": "add_filter( 'rest_prepare_post', 'my_callback', 10, 3 ); // receives WP_REST_Response $response, WP_Post $post, WP_REST_Request $request",
      "whatItDoes": "Filters the REST response for a single post of that post type, just before it is returned. Use it to add, reshape or strip fields.",
      "whenItFires": "Per item, after the item is prepared — for single-item and collection requests alike.",
      "example": "add_filter( 'rest_prepare_post', function ( $response, $post, $request ) {\n    $response->data['reading_time'] = (int) ceil( str_word_count( wp_strip_all_tags( $post->post_content ) ) / 200 );\n    return $response;\n}, 10, 3 );",
      "gotcha": "$response is a WP_REST_Response object, not an array — writing $response['x'] = ... or returning an array from here breaks the endpoint. Modify $response->data and return the object. Also: fields bolted on here exist in the output but not in the schema, so they cannot be requested via _fields, are invisible to API docs, and are read-only. If clients need to write the field, use register_rest_field() on rest_api_init instead. The hook name uses the post type slug, so a CPT called 'book' is rest_prepare_book — with 'page' and 'attachment' being the two everyone types wrong.",
      "verified": true
    },
    {
      "name": "rest_{$post_type}_query",
      "type": "filter",
      "group": "REST & AJAX",
      "signature": "add_filter( 'rest_post_query', 'my_callback', 10, 2 ); // receives array $args, WP_REST_Request $request",
      "whatItDoes": "Filters the WP_Query arguments a REST collection request is about to run, so you can support query params core does not expose.",
      "whenItFires": "When a posts collection endpoint builds its query, before the query executes.",
      "example": "add_filter( 'rest_post_query', function ( $args, $request ) {\n    if ( $request->get_param( 'featured' ) ) {\n        $args['meta_key']   = 'is_featured';\n        $args['meta_value'] = '1';\n    }\n    return $args;\n}, 10, 2 );",
      "gotcha": "REST strips unregistered query parameters, so reading $request->get_param() for something you never declared usually returns nothing — register it in the route's 'args' (or via rest_endpoints) first. And pre_get_posts also fires for REST requests, so a filter you added for the front end is quietly reshaping your API responses too.",
      "verified": false
    }
  ]
}