Created
June 4, 2026 03:14
-
-
Save vapvarun/2484b2dcafb5e3428f8ddb09c17a65a2 to your computer and use it in GitHub Desktop.
WordPress Dynamic Hooks: save_post_{type}, manage_{screen}_columns, theme_mod_{name} — tweakswp.com
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * save_post_{$post_type} dynamic hook — basic usage | |
| * | |
| * The hook fires only when the specified post type is saved. | |
| * Avoids the post_type check you would need on save_post. | |
| * | |
| * Tutorial: https://tweakswp.com/wordpress-dynamic-hooks-save-post-type/ | |
| */ | |
| // Fires only when a 'product' post is saved. | |
| add_action( 'save_post_product', function( $post_id, $post, $update ) { | |
| // Skip autosaves. | |
| if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { | |
| return; | |
| } | |
| // Skip revisions. | |
| if ( wp_is_post_revision( $post_id ) ) { | |
| return; | |
| } | |
| // Your logic here — runs only for the 'product' CPT. | |
| update_post_meta( $post_id, '_product_processed', current_time( 'mysql' ) ); | |
| }, 10, 3 ); | |
| // Same pattern for a different post type — no extra post_type guard needed. | |
| add_action( 'save_post_portfolio', function( $post_id, $post, $update ) { | |
| if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { | |
| return; | |
| } | |
| if ( wp_is_post_revision( $post_id ) ) { | |
| return; | |
| } | |
| // Fires exclusively for 'portfolio' posts. | |
| update_post_meta( $post_id, '_portfolio_indexed', '1' ); | |
| }, 10, 3 ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * save_post_{$post_type} with nonce verification and meta box data | |
| * | |
| * Complete pattern for saving a custom meta box attached to a specific CPT. | |
| * Includes nonce check, capability check, autosave guard, and sanitization. | |
| * | |
| * Tutorial: https://tweakswp.com/wordpress-dynamic-hooks-save-post-type/ | |
| */ | |
| /** | |
| * Register the meta box. | |
| */ | |
| add_action( 'add_meta_boxes_event', function() { | |
| add_meta_box( | |
| 'event_details', | |
| 'Event Details', | |
| 'tweakswp_event_details_meta_box_html', | |
| 'event', | |
| 'normal', | |
| 'high' | |
| ); | |
| } ); | |
| /** | |
| * Render meta box HTML. | |
| * | |
| * @param WP_Post $post Current post object. | |
| */ | |
| function tweakswp_event_details_meta_box_html( $post ) { | |
| wp_nonce_field( 'save_event_details_' . $post->ID, 'event_details_nonce' ); | |
| $venue = get_post_meta( $post->ID, '_event_venue', true ); | |
| $tickets = get_post_meta( $post->ID, '_event_max_tickets', true ); | |
| ?> | |
| <p> | |
| <label for="event_venue">Venue</label> | |
| <input type="text" id="event_venue" name="event_venue" | |
| value="<?php echo esc_attr( $venue ); ?>" class="widefat" /> | |
| </p> | |
| <p> | |
| <label for="event_max_tickets">Max tickets</label> | |
| <input type="number" id="event_max_tickets" name="event_max_tickets" | |
| value="<?php echo esc_attr( $tickets ); ?>" min="0" /> | |
| </p> | |
| <?php | |
| } | |
| /** | |
| * Save meta box data — fires only for 'event' post type. | |
| * | |
| * @param int $post_id Post ID. | |
| * @param WP_Post $post Post object. | |
| * @param bool $update Whether this is an update. | |
| */ | |
| add_action( 'save_post_event', function( $post_id, $post, $update ) { | |
| // Guard: autosave. | |
| if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { | |
| return; | |
| } | |
| // Guard: capability. | |
| if ( ! current_user_can( 'edit_post', $post_id ) ) { | |
| return; | |
| } | |
| // Guard: nonce. | |
| if ( | |
| ! isset( $_POST['event_details_nonce'] ) || | |
| ! wp_verify_nonce( | |
| sanitize_key( $_POST['event_details_nonce'] ), | |
| 'save_event_details_' . $post_id | |
| ) | |
| ) { | |
| return; | |
| } | |
| // Save venue. | |
| if ( isset( $_POST['event_venue'] ) ) { | |
| update_post_meta( | |
| $post_id, | |
| '_event_venue', | |
| sanitize_text_field( wp_unslash( $_POST['event_venue'] ) ) | |
| ); | |
| } | |
| // Save max tickets — must be a non-negative integer. | |
| if ( isset( $_POST['event_max_tickets'] ) ) { | |
| $tickets = absint( $_POST['event_max_tickets'] ); | |
| update_post_meta( $post_id, '_event_max_tickets', $tickets ); | |
| } | |
| }, 10, 3 ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * manage_{$screen->id}_columns dynamic hook — add custom admin columns | |
| * | |
| * The hook name is built from the current screen ID. | |
| * For post types: manage_{post_type}_posts_columns | |
| * For custom admin pages: manage_{page_slug}_columns | |
| * | |
| * Tutorial: https://tweakswp.com/wordpress-dynamic-hooks-save-post-type/ | |
| */ | |
| /** | |
| * Add custom columns to the 'event' post type list table. | |
| * | |
| * Hook: manage_event_posts_columns | |
| * | |
| * @param array $columns Existing columns. | |
| * @return array Modified columns. | |
| */ | |
| add_filter( 'manage_event_posts_columns', function( $columns ) { | |
| // Insert after the 'title' column. | |
| $new = array(); | |
| foreach ( $columns as $key => $label ) { | |
| $new[ $key ] = $label; | |
| if ( 'title' === $key ) { | |
| $new['event_venue'] = 'Venue'; | |
| $new['event_tickets'] = 'Max Tickets'; | |
| $new['event_date'] = 'Event Date'; | |
| } | |
| } | |
| return $new; | |
| } ); | |
| /** | |
| * Populate the custom columns with data. | |
| * | |
| * Hook: manage_event_posts_custom_column | |
| * | |
| * @param string $column Column name. | |
| * @param int $post_id Post ID. | |
| */ | |
| add_action( 'manage_event_posts_custom_column', function( $column, $post_id ) { | |
| switch ( $column ) { | |
| case 'event_venue': | |
| $venue = get_post_meta( $post_id, '_event_venue', true ); | |
| echo esc_html( $venue ?: '-' ); | |
| break; | |
| case 'event_tickets': | |
| $tickets = get_post_meta( $post_id, '_event_max_tickets', true ); | |
| echo $tickets !== '' ? absint( $tickets ) : '-'; | |
| break; | |
| case 'event_date': | |
| $date = get_post_meta( $post_id, '_event_start_date', true ); | |
| echo $date ? esc_html( date_i18n( get_option( 'date_format' ), strtotime( $date ) ) ) : '-'; | |
| break; | |
| } | |
| }, 10, 2 ); | |
| /** | |
| * Make the 'event_date' column sortable. | |
| * | |
| * @param array $sortable Existing sortable columns. | |
| * @return array Modified sortable columns. | |
| */ | |
| add_filter( 'manage_edit-event_sortable_columns', function( $sortable ) { | |
| $sortable['event_date'] = 'event_date'; | |
| return $sortable; | |
| } ); | |
| /** | |
| * Handle the sort query for 'event_date'. | |
| * | |
| * @param WP_Query $query The current query. | |
| */ | |
| add_action( 'pre_get_posts', function( $query ) { | |
| if ( ! is_admin() || ! $query->is_main_query() ) { | |
| return; | |
| } | |
| if ( 'event_date' === $query->get( 'orderby' ) ) { | |
| $query->set( 'meta_key', '_event_start_date' ); | |
| $query->set( 'orderby', 'meta_value' ); | |
| } | |
| } ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * theme_mod_{$name} dynamic filter — intercept individual theme mod values | |
| * | |
| * WordPress fires this filter whenever get_theme_mod() is called. | |
| * The hook name is 'theme_mod_' plus the setting name. | |
| * Use it to override a single setting without touching others. | |
| * | |
| * Tutorial: https://tweakswp.com/wordpress-dynamic-hooks-save-post-type/ | |
| */ | |
| /** | |
| * Override the 'header_textcolor' theme mod. | |
| * | |
| * Forces the site title/tagline color regardless of Customizer setting. | |
| * Useful when a child theme or plugin must lock a brand color. | |
| * | |
| * @param mixed $value Current value from the database. | |
| * @return string Hex color (without #). | |
| */ | |
| add_filter( 'theme_mod_header_textcolor', function( $value ) { | |
| return '21759b'; // WordPress blue — no # prefix. | |
| } ); | |
| /** | |
| * Override a custom theme mod that stores the primary button color. | |
| * | |
| * If the mod is not yet set (empty string), fall back to the brand default. | |
| * This avoids a blank value during initial theme activation. | |
| * | |
| * @param mixed $value Value stored in the database. | |
| * @return string Hex color with #. | |
| */ | |
| add_filter( 'theme_mod_primary_button_color', function( $value ) { | |
| if ( empty( $value ) ) { | |
| return '#0073aa'; | |
| } | |
| return $value; | |
| } ); | |
| /** | |
| * Context-aware override: change the background color on specific page templates. | |
| * | |
| * theme_mod_{$name} runs after wp_setup_globals, so conditional functions | |
| * like is_page_template() are available. | |
| * | |
| * @param mixed $value Current value. | |
| * @return string Modified value. | |
| */ | |
| add_filter( 'theme_mod_background_color', function( $value ) { | |
| if ( is_page_template( 'templates/landing.php' ) ) { | |
| return 'f0f4f8'; // Light slate for landing pages — no # prefix. | |
| } | |
| return $value; | |
| } ); | |
| /** | |
| * Read all theme mods for debugging — use in wp-admin only. | |
| * | |
| * get_theme_mods() returns the raw database array without filter application. | |
| * To see what a specific mod resolves to (after filters), use get_theme_mod(). | |
| * | |
| * Example (run in WP-CLI or functions.php under is_admin() guard): | |
| * var_dump( get_theme_mod( 'header_textcolor' ) ); | |
| */ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Building a custom dynamic hook pattern in WordPress | |
| * | |
| * The same pattern WordPress core uses: embed a variable in the hook name. | |
| * Callers can target all events or one specific type. | |
| * | |
| * Tutorial: https://tweakswp.com/wordpress-dynamic-hooks-save-post-type/ | |
| */ | |
| /** | |
| * Example: a form processor that fires a dynamic action per form type. | |
| * | |
| * Pattern: | |
| * do_action( "tweakswp_form_submitted", $form_id, $data ) — generic | |
| * do_action( "tweakswp_form_submitted_{$form_type}", $form_id, $data ) — specific | |
| * | |
| * This lets code hook into all submissions OR only a named form type. | |
| * | |
| * @param int $form_id ID of the submitted form. | |
| * @param string $form_type Machine-readable form type (e.g. 'contact', 'quote'). | |
| * @param array $data Submitted and sanitized field values. | |
| */ | |
| function tweakswp_process_form_submission( $form_id, $form_type, $data ) { | |
| // Generic hook: fires for every form type. | |
| do_action( 'tweakswp_form_submitted', $form_id, $data ); | |
| // Dynamic hook: fires only for this specific form type. | |
| do_action( "tweakswp_form_submitted_{$form_type}", $form_id, $data ); | |
| } | |
| // ----------------------------------------------------------------------- | |
| // Consumer code — hooks into a specific form type only. | |
| // ----------------------------------------------------------------------- | |
| /** | |
| * Send a Slack notification when the 'quote' form is submitted. | |
| * | |
| * Only runs when tweakswp_process_form_submission() is called with | |
| * $form_type = 'quote'. A 'contact' submission does NOT trigger this. | |
| * | |
| * @param int $form_id Form ID. | |
| * @param array $data Submitted field values. | |
| */ | |
| add_action( 'tweakswp_form_submitted_quote', function( $form_id, $data ) { | |
| $webhook = get_option( 'slack_quote_webhook' ); | |
| if ( ! $webhook ) { | |
| return; | |
| } | |
| wp_remote_post( $webhook, array( | |
| 'body' => wp_json_encode( array( | |
| 'text' => sprintf( | |
| 'New quote request from %s <%s>', | |
| sanitize_text_field( $data['name'] ?? '' ), | |
| sanitize_email( $data['email'] ?? '' ) | |
| ), | |
| ) ), | |
| 'headers' => array( 'Content-Type' => 'application/json' ), | |
| ) ); | |
| }, 10, 2 ); | |
| /** | |
| * Log ALL form submissions to a custom table, regardless of type. | |
| * | |
| * Hooks into the generic action — runs for 'contact', 'quote', 'feedback', | |
| * and any form type added in the future without changing this callback. | |
| * | |
| * @param int $form_id Form ID. | |
| * @param array $data Submitted field values. | |
| */ | |
| add_action( 'tweakswp_form_submitted', function( $form_id, $data ) { | |
| global $wpdb; | |
| $wpdb->insert( | |
| $wpdb->prefix . 'form_submissions', | |
| array( | |
| 'form_id' => absint( $form_id ), | |
| 'submitted' => current_time( 'mysql' ), | |
| 'ip_address' => sanitize_text_field( $_SERVER['REMOTE_ADDR'] ?? '' ), | |
| ), | |
| array( '%d', '%s', '%s' ) | |
| ); | |
| }, 10, 2 ); | |
| // ----------------------------------------------------------------------- | |
| // Dynamic filter variant: modify a value per named context. | |
| // ----------------------------------------------------------------------- | |
| /** | |
| * Apply a dynamic filter to get a per-context configuration value. | |
| * | |
| * @param string $context The named context (e.g. 'email', 'sms', 'webhook'). | |
| * @param array $defaults Default configuration. | |
| * @return array Filtered configuration. | |
| */ | |
| function tweakswp_get_notification_config( $context, $defaults = array() ) { | |
| // Generic filter: any code can modify config for any context. | |
| $config = apply_filters( 'tweakswp_notification_config', $defaults, $context ); | |
| // Dynamic filter: code that targets only this specific context. | |
| $config = apply_filters( "tweakswp_notification_config_{$context}", $config ); | |
| return $config; | |
| } | |
| // Override the email notification config specifically. | |
| add_filter( 'tweakswp_notification_config_email', function( $config ) { | |
| $config['from_name'] = get_bloginfo( 'name' ); | |
| $config['reply_to'] = get_option( 'admin_email' ); | |
| return $config; | |
| } ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Dynamic hooks: gotchas and when NOT to use them | |
| * | |
| * Documents common mistakes and edge cases with dynamic WordPress hook names. | |
| * | |
| * Tutorial: https://tweakswp.com/wordpress-dynamic-hooks-save-post-type/ | |
| */ | |
| // ----------------------------------------------------------------------- | |
| // Gotcha 1: Hook name case sensitivity | |
| // ----------------------------------------------------------------------- | |
| // Post type slugs are lowercase. This will NEVER fire for 'Product' (capital P). | |
| // Always register CPTs with lowercase slugs. | |
| add_action( 'save_post_product', function( $post_id ) { | |
| // Correct — 'product' is lowercase. | |
| } ); | |
| // If your CPT slug has hyphens, the hook name has hyphens too. | |
| add_action( 'save_post_my-custom-type', function( $post_id ) { | |
| // Works, but hyphens in CPT slugs are legal — underscores are more common. | |
| } ); | |
| // ----------------------------------------------------------------------- | |
| // Gotcha 2: save_post vs save_post_{$post_type} execution order | |
| // ----------------------------------------------------------------------- | |
| // save_post fires for EVERY post type. save_post_{$post_type} fires after it. | |
| // Both fire on autosaves — always add the autosave guard. | |
| add_action( 'save_post_event', function( $post_id ) { | |
| if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { | |
| return; // Required — autosaves trigger this hook too. | |
| } | |
| // Your code. | |
| } ); | |
| // ----------------------------------------------------------------------- | |
| // Gotcha 3: manage_{$screen->id}_columns — screen ID is not always obvious | |
| // ----------------------------------------------------------------------- | |
| // The screen ID for a top-level menu page registered with 'toplevel_page_' prefix. | |
| // Use add_management_page, add_options_page etc. — each generates a different ID. | |
| // Log it to find the exact value: | |
| add_action( 'current_screen', function( $screen ) { | |
| if ( defined( 'WP_DEBUG' ) && WP_DEBUG && current_user_can( 'manage_options' ) ) { | |
| error_log( 'Current screen ID: ' . $screen->id ); | |
| } | |
| } ); | |
| // For a WooCommerce orders list table: manage_shop_order_posts_columns (classic editor) | |
| // For HPOS (WooCommerce 7.1+): manage_woocommerce_page_wc-orders_columns | |
| add_filter( 'manage_woocommerce_page_wc-orders_columns', function( $columns ) { | |
| $columns['order_source'] = 'Source'; | |
| return $columns; | |
| } ); | |
| // ----------------------------------------------------------------------- | |
| // Gotcha 4: theme_mod_{$name} fires before wp() on some hooks | |
| // ----------------------------------------------------------------------- | |
| // Conditional tags (is_singular, is_page_template) are NOT available early. | |
| // The filter fires during get_theme_mod() calls, which can happen before wp(). | |
| // Only use conditional tags if you know the hook context. | |
| // SAFE: wp_head fires after wp() — conditionals work here. | |
| add_action( 'wp_head', function() { | |
| $color = get_theme_mod( 'accent_color', '#0073aa' ); | |
| echo '<style>:root{--accent:' . esc_attr( $color ) . '}</style>'; | |
| } ); | |
| // UNSAFE: Hooking theme_mod_background_color without checking the context. | |
| // This filter can fire during REST API requests, admin pages, etc. | |
| add_filter( 'theme_mod_background_color', function( $value ) { | |
| // is_front_page() may return false or trigger notices if called too early. | |
| // Guard with did_action('wp') to be safe. | |
| if ( ! did_action( 'wp' ) ) { | |
| return $value; | |
| } | |
| if ( is_front_page() ) { | |
| return 'f5f5f5'; | |
| } | |
| return $value; | |
| } ); | |
| // ----------------------------------------------------------------------- | |
| // Gotcha 5: Using do_action() with dynamic names — document your API | |
| // ----------------------------------------------------------------------- | |
| // Dynamic hooks are discovery-hostile. Developers searching for a hook name | |
| // won't find "tweakswp_form_submitted_quote" in a grep of your codebase | |
| // because the full name is never written as a string literal. | |
| // | |
| // Always add a @fires annotation in the calling function's docblock: | |
| /** | |
| * Process a form submission. | |
| * | |
| * @fires tweakswp_form_submitted Generic — all form types. | |
| * @fires tweakswp_form_submitted_{$type} Dynamic — fires for the specific type. | |
| * | |
| * @param int $form_id Form ID. | |
| * @param string $type Form type slug. | |
| * @param array $data Sanitized field values. | |
| */ | |
| function tweakswp_submit_form( $form_id, $type, $data ) { | |
| do_action( 'tweakswp_form_submitted', $form_id, $data ); | |
| do_action( "tweakswp_form_submitted_{$type}", $form_id, $data ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment