Skip to content

Instantly share code, notes, and snippets.

@vapvarun
Created June 4, 2026 03:14
Show Gist options
  • Select an option

  • Save vapvarun/2484b2dcafb5e3428f8ddb09c17a65a2 to your computer and use it in GitHub Desktop.

Select an option

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
<?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 );
<?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 );
<?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' );
}
} );
<?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' ) );
*/
<?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;
} );
<?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