WordPress Plugin Architecture: The Hooks System, Lifecycle & Shortcodes
The foundation of extending WordPress lies in its event-driven architecture. This guide provides a structural analysis of plugin development, detailing lifecycle management (activation/uninstall), the critical distinction between Actions and Filters, and the implementation of user-facing macros via the Shortcode API.
Plugin Basics
Plugin folder structure
A WordPress plugin follows a standard directory convention where the main plugin file resides in wp-content/plugins/your-plugin/ alongside organized subdirectories for assets, includes, and templates—this structure promotes maintainability and follows WordPress coding standards.
wp-content/plugins/my-plugin/
├── my-plugin.php # Main plugin file
├── uninstall.php # Cleanup on deletion
├── includes/ # PHP classes/functions
│ ├── class-admin.php
│ └── class-public.php
├── assets/
│ ├── css/
│ ├── js/
│ └── images/
├── templates/ # Template files
└── languages/ # Translation files (.pot, .po, .mo)
Plugin header requirements
Every WordPress plugin must have a PHP comment block in the main file containing at minimum the Plugin Name field—WordPress parses this header to display plugin information in the admin dashboard and determine if the file is a valid plugin.
<?php /** * Plugin Name: My Awesome Plugin * Plugin URI: https://example.com/my-plugin * Description: Short description of the plugin. * Version: 1.0.0 * Requires at least: 5.8 * Requires PHP: 7.4 * Author: Your Name * Author URI: https://example.com * License: GPL v2 or later * Text Domain: my-plugin * Domain Path: /languages */
Single-file plugins
Single-file plugins contain all functionality within one PHP file placed directly in wp-content/plugins/ or within a folder—ideal for simple utilities, quick customizations, or mu-plugins (must-use plugins) that require minimal code.
<?php /** * Plugin Name: Simple Hello World */ add_action('wp_footer', function() { echo '<!-- Hello from single-file plugin! -->'; });
Multi-file plugins
Multi-file plugins split functionality across multiple files using require or include statements, implementing autoloading or manual file inclusion—this approach is essential for complex plugins following OOP principles, separation of concerns, and testability.
<?php // my-plugin.php (main file) /** * Plugin Name: Multi-File Plugin */ define('MYPLUGIN_PATH', plugin_dir_path(__FILE__)); define('MYPLUGIN_URL', plugin_dir_url(__FILE__)); // Load dependencies require_once MYPLUGIN_PATH . 'includes/class-loader.php'; require_once MYPLUGIN_PATH . 'includes/class-admin.php'; require_once MYPLUGIN_PATH . 'includes/class-public.php'; // Initialize $plugin = new MyPlugin_Loader(); $plugin->run();
Plugin activation
Plugin activation occurs when a user enables the plugin via the admin dashboard—this is the appropriate time to create database tables, set default options, flush rewrite rules, or verify system requirements before the plugin becomes operational.
┌─────────────────────────────────────────────────────┐
│ PLUGIN ACTIVATION FLOW │
├─────────────────────────────────────────────────────┤
│ User clicks "Activate" │
│ ↓ │
│ WordPress includes main plugin file │
│ ↓ │
│ register_activation_hook() callback fires │
│ ↓ │
│ • Create DB tables │
│ • Add default options │
│ • Set capabilities │
│ • Flush rewrite rules │
│ ↓ │
│ Plugin marked as active in database │
└─────────────────────────────────────────────────────┘
Plugin deactivation
Plugin deactivation runs when a user disables the plugin—use this hook for temporary cleanup like removing scheduled cron events or flushing rewrite rules, but never delete user data here since users may reactivate the plugin later.
<?php // Deactivation: cleanup temporary items only function myplugin_deactivate() { // Remove scheduled events wp_clear_scheduled_hook('myplugin_daily_event'); // Flush rewrite rules flush_rewrite_rules(); // DO NOT delete options or tables here! } register_deactivation_hook(__FILE__, 'myplugin_deactivate');
Plugin uninstall (uninstall.php)
The uninstall.php file executes when a user deletes the plugin from WordPress admin—this is where you permanently remove all plugin data including database tables, options, and user meta; always verify the WP_UNINSTALL_PLUGIN constant for security.
<?php // uninstall.php - runs on plugin deletion // Security check: exit if not called by WordPress if (!defined('WP_UNINSTALL_PLUGIN')) { exit; } // Remove options delete_option('myplugin_settings'); delete_option('myplugin_version'); // Remove custom database tables global $wpdb; $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}myplugin_data"); // Remove user meta delete_metadata('user', 0, 'myplugin_user_pref', '', true);
register_activation_hook()
This function registers a callback to execute exactly once when the plugin is activated—it takes the main plugin file path and callback function as arguments; the callback runs in a limited environment so avoid redirects or output.
<?php // Syntax: register_activation_hook(string $file, callable $callback) function myplugin_activate() { // Create database table global $wpdb; $table = $wpdb->prefix . 'myplugin_logs'; $charset = $wpdb->get_charset_collate(); $sql = "CREATE TABLE $table ( id bigint(20) NOT NULL AUTO_INCREMENT, message text NOT NULL, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) $charset;"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta($sql); // Store version for future upgrades add_option('myplugin_version', '1.0.0'); } register_activation_hook(__FILE__, 'myplugin_activate');
register_deactivation_hook()
This function registers a callback to execute when the plugin is deactivated—use it for reversible cleanup operations; the callback receives no arguments and should execute quickly without user-facing output.
<?php // Syntax: register_deactivation_hook(string $file, callable $callback) function myplugin_deactivate() { // Clear any scheduled cron jobs $timestamp = wp_next_scheduled('myplugin_hourly_sync'); if ($timestamp) { wp_unschedule_event($timestamp, 'myplugin_hourly_sync'); } // Remove rewrite rules (they'll be regenerated if reactivated) flush_rewrite_rules(); // Optionally clear transients delete_transient('myplugin_cache'); } register_deactivation_hook(__FILE__, 'myplugin_deactivate');
Hooks System
Actions concept
Actions are execution points in the WordPress lifecycle where you can inject custom code—they fire at specific moments (like page load, post save, or user login) and don't return values; think of them as "event listeners" where you respond to WordPress events.
┌──────────────────────────────────────────────────────────┐
│ ACTIONS CONCEPT │
├──────────────────────────────────────────────────────────┤
│ │
│ WordPress Core Your Plugin │
│ ─────────────── ─────────── │
│ │
│ do_action('init') ──────► function my_init() { │
│ │ // Your code runs │
│ │ } │
│ ↓ │
│ Continue execution │
│ │
│ ACTION = "Something happened, react if you want" │
│ │
└──────────────────────────────────────────────────────────┘
Filters concept
Filters are hooks that allow you to modify data before WordPress uses or displays it—they receive a value, let you transform it, and must return a value; think of them as a pipeline where data flows through multiple functions that each can modify it.
┌─────────────────────────────────────────────────────────────┐
│ FILTERS CONCEPT │
├─────────────────────────────────────────────────────────────┤
│ │
│ Original Data ──► Filter 1 ──► Filter 2 ──► Final Data │
│ │
│ "Hello" ──► add_smile ──► uppercase ──► "HELLO :)" │
│ │
│ $title = apply_filters('the_title', $title, $post_id); │
│ │ │
│ ▼ │
│ Your filter: │
│ function my_filter($title) { │
│ return $title . ' - My Site'; │
│ } │
│ │
│ FILTER = "Here's data, modify and RETURN it" │
│ │
└─────────────────────────────────────────────────────────────┘
add_action()
Registers a callback function to execute when a specific action hook fires—accepts the hook name, callback, optional priority (default 10), and number of accepted arguments (default 1); lower priority numbers execute earlier.
<?php // Syntax: add_action($hook, $callback, $priority, $accepted_args) // Basic usage add_action('init', 'my_custom_init'); function my_custom_init() { // Runs during WordPress initialization } // With priority and arguments add_action('save_post', 'my_save_handler', 10, 3); function my_save_handler($post_id, $post, $update) { if ($update) { // Post was updated } } // Using anonymous function add_action('wp_footer', function() { echo '<!-- Custom footer content -->'; }, 99); // Using class method add_action('admin_init', [$this, 'admin_setup']); add_action('admin_init', ['MyClass', 'static_method']);
add_filter()
Registers a callback to modify data passing through a filter hook—your function receives the value being filtered, can modify it, and must return it; failing to return will result in empty/null values and broken functionality.
<?php // Syntax: add_filter($hook, $callback, $priority, $accepted_args) // Modify post titles add_filter('the_title', 'prefix_title', 10, 2); function prefix_title($title, $post_id) { if (get_post_type($post_id) === 'news') { return '📰 ' . $title; } return $title; // ALWAYS return! } // Modify content add_filter('the_content', function($content) { if (is_single()) { $content .= '<div class="share-box">Share this post!</div>'; } return $content; }); // Add custom allowed file types add_filter('upload_mimes', function($mimes) { $mimes['svg'] = 'image/svg+xml'; return $mimes; });
remove_action()
Removes a previously registered action hook callback—requires the exact same hook name, callback reference, and priority used in add_action(); commonly used to disable core or third-party plugin functionality.
<?php // Syntax: remove_action($hook, $callback, $priority) // Remove default WordPress actions remove_action('wp_head', 'wp_generator'); // Remove version meta remove_action('wp_head', 'wlwmanifest_link'); // Remove manifest link remove_action('wp_head', 'rsd_link'); // Remove RSD link // Remove must match exact priority (default is 10) remove_action('wp_head', 'print_emoji_detection_script', 7); // Remove from class instance (tricky - need reference) // This WON'T work: remove_action('init', ['SomeClass', 'method']); // You need the exact instance that was used // Proper way in plugins_loaded or init add_action('init', function() { remove_action('woocommerce_after_shop_loop_item', 'woocommerce_template_loop_add_to_cart'); }, 15);
remove_filter()
Removes a previously registered filter hook callback—identical to remove_action() in usage; must match the original hook, callback, and priority exactly to successfully remove.
<?php // Syntax: remove_filter($hook, $callback, $priority) // Remove content auto-formatting remove_filter('the_content', 'wpautop'); remove_filter('the_excerpt', 'wpautop'); // Remove with matching priority remove_filter('the_content', 'wptexturize'); // Remove inside a callback (use current filter context) add_filter('the_content', 'my_one_time_filter'); function my_one_time_filter($content) { // Do something once remove_filter('the_content', 'my_one_time_filter'); return $content . ' (processed)'; } // Removing class method filters requires the exact instance global $some_plugin; remove_filter('the_title', [$some_plugin, 'modify_title'], 10);
do_action()
Executes all callback functions registered to a specific action hook—used by WordPress core and plugins to create extension points; you can pass additional parameters that will be forwarded to all hooked callbacks.
<?php // Syntax: do_action($hook, ...$args) // Creating your own action hooks (in your plugin) function myplugin_process_order($order_id) { $order = get_order($order_id); // Before processing do_action('myplugin_before_process_order', $order); // Process order... $result = process($order); // After processing - pass multiple args do_action('myplugin_after_process_order', $order, $result); } // Other plugins/themes can now hook in: add_action('myplugin_before_process_order', function($order) { log_order_start($order->id); }); add_action('myplugin_after_process_order', function($order, $result) { send_notification($order->email, $result); }, 10, 2);
apply_filters()
Passes a value through all functions registered to a filter hook and returns the final result—this is how you create filterable values in your plugin, allowing other developers to modify your plugin's data or output.
<?php // Syntax: apply_filters($hook, $value, ...$args) // Creating filterable values in your plugin function myplugin_get_settings() { $defaults = [ 'posts_per_page' => 10, 'cache_time' => 3600, 'show_author' => true, ]; // Allow other code to modify settings return apply_filters('myplugin_settings', $defaults); } // Making output filterable function myplugin_render_widget($widget_id) { $output = '<div class="widget">'; $output .= get_widget_content($widget_id); $output .= '</div>'; // Let developers modify output return apply_filters('myplugin_widget_output', $output, $widget_id); } // Usage: others can now filter your values add_filter('myplugin_settings', function($settings) { $settings['posts_per_page'] = 20; return $settings; });
Hook priorities
Priority is a numeric value (default 10) that determines the execution order of hooked callbacks—lower numbers execute first; use priorities to ensure your code runs before or after other callbacks on the same hook.
┌────────────────────────────────────────────────────────┐
│ HOOK PRIORITIES │
├────────────────────────────────────────────────────────┤
│ │
│ Priority Execution Order │
│ ──────── ─────────────── │
│ 1 ▶ First (very early) │
│ 5 ▶ Early │
│ 10 ▶ Default (normal) │
│ 20 ▶ Later │
│ 99 ▶ Very late │
│ 999 ▶ Almost last │
│ PHP_INT_MAX ▶ Absolute last │
│ │
└────────────────────────────────────────────────────────┘
<?php // Run before most other callbacks add_action('wp_head', 'my_early_head', 1); // Run after most other callbacks add_action('wp_footer', 'my_late_footer', 99); // Ensure you override others add_filter('the_content', 'my_final_content_filter', PHP_INT_MAX); // WooCommerce example: remove then re-add at different position remove_action('woocommerce_single_product_summary', 'woocommerce_template_single_price', 10); add_action('woocommerce_single_product_summary', 'woocommerce_template_single_price', 25);
Accepted arguments
The fourth parameter of add_action() and add_filter() specifies how many arguments your callback receives—by default only 1 argument is passed, so you must increase this to access additional parameters provided by the hook.
<?php // Syntax: add_action($hook, $callback, $priority, $accepted_args) // ↑ this one // save_post passes 3 args: $post_id, $post, $update add_action('save_post', 'handle_save', 10, 3); function handle_save($post_id, $post, $update) { // Now you can access all three parameters if (!$update) { // New post created } } // the_title passes 2 args: $title, $post_id add_filter('the_title', 'custom_title', 10, 2); function custom_title($title, $post_id) { return $title . ' (#' . $post_id . ')'; } // Common mistake: forgetting to set accepted_args add_action('save_post', 'broken_handler', 10); // Only gets $post_id! add_action('save_post', 'working_handler', 10, 3); // Gets all 3
has_action()
Checks if any callbacks are registered to a specific action hook—returns false if no callbacks exist, or the priority of the callback if checking for a specific function; useful for conditional logic before adding/removing hooks.
<?php // Syntax: has_action($hook, $callback = false) // Check if ANY callback is registered if (has_action('init')) { // Something is hooked to init } // Check if SPECIFIC callback is registered (returns priority or false) $priority = has_action('wp_head', 'wp_generator'); if ($priority !== false) { echo "wp_generator is hooked at priority: $priority"; } // Practical example: avoid duplicate hooks if (!has_action('wp_footer', 'my_footer_content')) { add_action('wp_footer', 'my_footer_content'); } // Check before removing if (has_action('woocommerce_checkout_order_processed', 'some_callback')) { remove_action('woocommerce_checkout_order_processed', 'some_callback'); }
has_filter()
Checks if any callbacks are registered to a specific filter hook—functionally identical to has_action() since WordPress treats actions and filters similarly internally; returns priority if a specific callback is found, true/false otherwise.
<?php // Syntax: has_filter($hook, $callback = false) // Check if any filter is attached if (has_filter('the_content')) { // Content will be filtered } // Check specific callback exists if (has_filter('the_content', 'wpautop')) { // wpautop is active on content remove_filter('the_content', 'wpautop'); } // Get priority of specific filter $priority = has_filter('body_class', 'my_body_class_filter'); if ($priority !== false) { echo "Filter registered at priority: $priority"; } // Conditional filter application if (!has_filter('excerpt_length')) { add_filter('excerpt_length', function() { return 30; }); }
did_action()
Returns the number of times an action hook has fired—useful for ensuring code runs only once or verifying that certain initialization actions have already occurred; returns 0 if the action hasn't fired yet.
<?php // Syntax: did_action($hook) // Check if action has fired if (did_action('init')) { // WordPress has already initialized } // Ensure plugin setup runs only once function my_plugin_setup() { if (did_action('my_plugin_setup') > 1) { return; // Already ran } // Setup code here } add_action('init', 'my_plugin_setup'); // Debug: count how many times action fired add_action('wp_footer', function() { echo '<!-- wp_head fired ' . did_action('wp_head') . ' time(s) -->'; }); // Wait for dependencies function check_requirements() { if (!did_action('plugins_loaded')) { // Too early, wait add_action('plugins_loaded', 'check_requirements'); return; } // Safe to check for other plugins }
current_filter()
Returns the name of the current hook being executed—works for both actions and filters; extremely useful when a single callback function is attached to multiple hooks and needs to behave differently depending on which hook called it.
<?php // Syntax: current_filter() // Single function handling multiple hooks add_filter('the_title', 'universal_text_handler'); add_filter('the_content', 'universal_text_handler'); add_filter('the_excerpt', 'universal_text_handler'); function universal_text_handler($text) { $hook = current_filter(); switch ($hook) { case 'the_title': return strtoupper($text); case 'the_content': return wpautop($text); case 'the_excerpt': return wp_trim_words($text, 20); default: return $text; } } // Debugging add_action('all', function() { error_log('Hook fired: ' . current_filter()); });
Common Action Hooks
init
Fires after WordPress core is loaded but before any output—this is the most common hook for registering custom post types, taxonomies, shortcodes, and initializing plugin functionality; runs on every page load (frontend and admin).
<?php add_action('init', 'myplugin_init'); function myplugin_init() { // Register custom post type register_post_type('book', [ 'public' => true, 'label' => 'Books', 'supports' => ['title', 'editor', 'thumbnail'], 'has_archive' => true, ]); // Register taxonomy register_taxonomy('genre', 'book', [ 'label' => 'Genres', 'hierarchical' => true, ]); // Register shortcode add_shortcode('greeting', 'render_greeting'); // Start session if needed if (!session_id()) { session_start(); } }
admin_init
Fires after WordPress admin is initialized but before any admin page renders—use for registering settings, adding meta boxes, performing admin-only checks, or redirecting users; runs only in wp-admin context.
<?php add_action('admin_init', 'myplugin_admin_init'); function myplugin_admin_init() { // Register settings register_setting('myplugin_options', 'myplugin_settings'); add_settings_section( 'myplugin_main_section', 'Main Settings', 'section_callback', 'myplugin' ); add_settings_field( 'api_key', 'API Key', 'api_key_field_callback', 'myplugin', 'myplugin_main_section' ); // Restrict admin access by role if (!current_user_can('manage_options') && !wp_doing_ajax()) { wp_redirect(home_url()); exit; } }
wp_loaded
Fires after WordPress, plugins, and themes are fully loaded—occurs after init and before request parsing begins; useful for tasks requiring all components to be available but before determining what content to serve.
<?php add_action('wp_loaded', 'myplugin_wp_loaded'); function myplugin_wp_loaded() { // All plugins and themes are loaded // Good for cross-plugin integrations if (class_exists('WooCommerce')) { // WooCommerce is active, add integration add_filter('woocommerce_product_tabs', 'add_custom_tab'); } // Flush rewrite rules if needed (only once) if (get_option('myplugin_flush_rules')) { flush_rewrite_rules(); delete_option('myplugin_flush_rules'); } }
admin_menu
Fires when WordPress admin menu is being built—use to add custom admin pages, submenus, or modify existing menu items; this is where you register your plugin's settings pages and admin interfaces.
<?php add_action('admin_menu', 'myplugin_admin_menu'); function myplugin_admin_menu() { // Add top-level menu add_menu_page( 'My Plugin Settings', // Page title 'My Plugin', // Menu title 'manage_options', // Capability 'myplugin-settings', // Menu slug 'myplugin_settings_page', // Callback 'dashicons-admin-generic', // Icon 80 // Position ); // Add submenu page add_submenu_page( 'myplugin-settings', // Parent slug 'Advanced Options', // Page title 'Advanced', // Menu title 'manage_options', // Capability 'myplugin-advanced', // Menu slug 'myplugin_advanced_page' // Callback ); // Add under existing menu (Settings) add_options_page( 'Plugin Options', 'My Plugin', 'manage_options', 'myplugin-options', 'render_options_page' ); }
admin_enqueue_scripts
Fires when enqueueing scripts/styles for admin pages—receives the current admin page hook as parameter; always use this to load admin assets and check the page hook to load scripts only where needed.
<?php add_action('admin_enqueue_scripts', 'myplugin_admin_scripts'); function myplugin_admin_scripts($hook) { // Load only on your plugin's admin pages if ($hook !== 'toplevel_page_myplugin-settings') { return; } // Enqueue admin styles wp_enqueue_style( 'myplugin-admin', plugin_dir_url(__FILE__) . 'assets/css/admin.css', [], '1.0.0' ); // Enqueue admin script with localization wp_enqueue_script( 'myplugin-admin', plugin_dir_url(__FILE__) . 'assets/js/admin.js', ['jquery'], '1.0.0', true ); wp_localize_script('myplugin-admin', 'myPluginData', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('myplugin_nonce'), ]); }
wp_enqueue_scripts
Fires when enqueueing scripts/styles for the frontend—this is the only proper place to add CSS and JavaScript for public-facing pages; never use wp_head to directly print script/style tags.
<?php add_action('wp_enqueue_scripts', 'myplugin_frontend_scripts'); function myplugin_frontend_scripts() { // Enqueue CSS wp_enqueue_style( 'myplugin-style', plugin_dir_url(__FILE__) . 'assets/css/style.css', [], '1.0.0' ); // Enqueue JavaScript (in footer) wp_enqueue_script( 'myplugin-script', plugin_dir_url(__FILE__) . 'assets/js/main.js', ['jquery'], '1.0.0', true // In footer ); // Conditional loading if (is_singular('book')) { wp_enqueue_style('myplugin-book-style', '...'); } // Pass data to JavaScript wp_localize_script('myplugin-script', 'MyPlugin', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'homeUrl' => home_url('/'), ]); }
save_post
Fires when a post is created or updated—receives post ID, post object, and update flag; use for saving custom meta boxes, triggering notifications, or syncing with external systems; always include security checks and prevent infinite loops.
<?php add_action('save_post', 'myplugin_save_post', 10, 3); function myplugin_save_post($post_id, $post, $update) { // Skip autosaves if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) { return; } // Verify nonce if (!isset($_POST['myplugin_nonce']) || !wp_verify_nonce($_POST['myplugin_nonce'], 'myplugin_save')) { return; } // Check permissions if (!current_user_can('edit_post', $post_id)) { return; } // Skip revisions if (wp_is_post_revision($post_id)) { return; } // Save custom field if (isset($_POST['myplugin_field'])) { update_post_meta( $post_id, '_myplugin_field', sanitize_text_field($_POST['myplugin_field']) ); } }
delete_post
Fires before a post is deleted from the database—use to clean up associated data like custom table entries, external API records, or files; note this fires for all post types including revisions and attachments.
<?php add_action('delete_post', 'myplugin_delete_post'); function myplugin_delete_post($post_id) { // Get post before it's deleted $post = get_post($post_id); // Only process specific post types if ($post->post_type !== 'book') { return; } // Clean up custom table data global $wpdb; $wpdb->delete( $wpdb->prefix . 'myplugin_book_stats', ['post_id' => $post_id], ['%d'] ); // Delete associated files $file_path = get_post_meta($post_id, '_myplugin_file', true); if ($file_path && file_exists($file_path)) { unlink($file_path); } // Clean up meta (optional, WP does this automatically) delete_post_meta($post_id, '_myplugin_custom_data'); }
wp_head
Fires in the <head> section of the theme—use for outputting meta tags, inline styles, schema markup, or analytics code; avoid using this for enqueuing scripts/styles (use wp_enqueue_scripts instead).
<?php add_action('wp_head', 'myplugin_head'); function myplugin_head() { // Add meta tags echo '<meta name="author" content="My Plugin">' . "\n"; // Add schema markup if (is_singular('product')) { $product = get_queried_object(); ?> <script type="application/ld+json"> { "@context": "https://schema.org", "@type": "Product", "name": "<?php echo esc_js($product->post_title); ?>" } </script> <?php } // Conditional inline styles (small CSS only) $primary_color = get_option('myplugin_primary_color', '#0073aa'); echo "<style>:root { --myplugin-primary: {$primary_color}; }</style>\n"; }
wp_footer
Fires before the closing </body> tag in the theme—ideal for outputting scripts that need the DOM to exist, tracking pixels, modal HTML, or deferred JavaScript; pairs with wp_head for template injection points.
<?php add_action('wp_footer', 'myplugin_footer'); function myplugin_footer() { // Output modal HTML ?> <div id="myplugin-modal" class="myplugin-modal hidden"> <div class="modal-content"> <span class="close">×</span> <div class="modal-body"></div> </div> </div> <?php // Add tracking pixel if (!is_user_logged_in()) { echo '<img src="https://analytics.example.com/pixel.gif" alt="" />'; } // Inline JavaScript (for small snippets) ?> <script> document.addEventListener('DOMContentLoaded', function() { console.log('My Plugin loaded'); }); </script> <?php }
template_redirect
Fires before WordPress determines which template to load—perfect for custom redirects, access control, or serving custom responses; you can exit here to completely bypass the template system.
<?php add_action('template_redirect', 'myplugin_template_redirect'); function myplugin_template_redirect() { // Protect premium content if (is_singular('premium_post') && !current_user_can('access_premium')) { wp_redirect(home_url('/subscribe/')); exit; } // Custom endpoint response if (get_query_var('myplugin_api')) { header('Content-Type: application/json'); echo json_encode(['status' => 'ok']); exit; } // Force HTTPS if (!is_ssl() && !is_admin()) { wp_redirect('https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'], 301); exit; } // Age gate for certain content if (is_singular('adult_content') && !isset($_COOKIE['age_verified'])) { include plugin_dir_path(__FILE__) . 'templates/age-gate.php'; exit; } }
pre_get_posts
Fires after query variables are set but before the query executes—the primary hook for modifying the main query or any WP_Query; always check is_main_query() and the context to avoid breaking admin or secondary queries.
<?php add_action('pre_get_posts', 'myplugin_modify_query'); function myplugin_modify_query($query) { // Never modify admin or secondary queries accidentally if (is_admin() || !$query->is_main_query()) { return; } // Modify blog posts per page if ($query->is_home()) { $query->set('posts_per_page', 12); } // Add custom post type to search if ($query->is_search()) { $query->set('post_type', ['post', 'page', 'book', 'product']); } // Filter archive by custom meta if ($query->is_post_type_archive('book')) { $query->set('meta_key', 'featured'); $query->set('meta_value', '1'); $query->set('orderby', 'title'); $query->set('order', 'ASC'); } // Exclude category from home if ($query->is_home()) { $query->set('category__not_in', [15]); // Exclude category ID 15 } }
widgets_init
Fires after default widgets are registered—use to register custom widget classes, unregister default widgets, or modify widget areas; this is the only proper hook for widget-related setup.
<?php add_action('widgets_init', 'myplugin_widgets_init'); function myplugin_widgets_init() { // Register custom widget register_widget('MyPlugin_Featured_Posts_Widget'); // Unregister default widgets unregister_widget('WP_Widget_Calendar'); unregister_widget('WP_Widget_Meta'); // Register widget area/sidebar register_sidebar([ 'name' => 'Plugin Sidebar', 'id' => 'myplugin-sidebar', 'description' => 'Widget area for My Plugin', 'before_widget' => '<div id="%1$s" class="widget %2$s">', 'after_widget' => '</div>', 'before_title' => '<h3 class="widget-title">', 'after_title' => '</h3>', ]); } // Widget class example class MyPlugin_Featured_Posts_Widget extends WP_Widget { public function __construct() { parent::__construct('myplugin_featured', 'Featured Posts'); } // ... widget(), form(), update() methods }
after_setup_theme
Fires during theme initialization after the theme is loaded—used for theme support features, image sizes, navigation menus, and theme-specific setup; runs on every page load very early, before init.
<?php // Usually in themes, but plugins can use it too add_action('after_setup_theme', 'myplugin_theme_setup'); function myplugin_theme_setup() { // Add theme support (theme feature) add_theme_support('post-thumbnails'); add_theme_support('title-tag'); add_theme_support('html5', ['search-form', 'gallery', 'caption']); add_theme_support('custom-logo', [ 'height' => 100, 'width' => 400, ]); // Register navigation menus register_nav_menus([ 'primary' => 'Primary Menu', 'footer' => 'Footer Menu', ]); // Add custom image sizes add_image_size('featured-large', 1200, 600, true); add_image_size('card-thumb', 400, 300, true); // Load text domain for translations load_plugin_textdomain('myplugin', false, dirname(plugin_basename(__FILE__)) . '/languages'); }
plugins_loaded
Fires after all active plugins are loaded—the earliest hook where you can safely check if other plugins exist and interact with their functions; ideal for cross-plugin integrations and loading translation files.
<?php add_action('plugins_loaded', 'myplugin_plugins_loaded'); function myplugin_plugins_loaded() { // Load translations load_plugin_textdomain( 'myplugin', false, dirname(plugin_basename(__FILE__)) . '/languages' ); // Check for plugin dependencies if (!class_exists('WooCommerce')) { add_action('admin_notices', function() { echo '<div class="error"><p>My Plugin requires WooCommerce!</p></div>'; }); return; } // Safe to use WooCommerce functions here include_once 'includes/class-woo-integration.php'; // Check for other plugins if (defined('ACF_VERSION')) { include_once 'includes/class-acf-integration.php'; } }
shutdown
Fires after PHP execution completes and output is sent—the last hook before PHP shuts down; useful for cleanup tasks, logging, sending async notifications, or performing operations that shouldn't delay response.
<?php add_action('shutdown', 'myplugin_shutdown'); function myplugin_shutdown() { // Log slow page loads $execution_time = microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']; if ($execution_time > 3) { error_log("Slow page: {$_SERVER['REQUEST_URI']} ({$execution_time}s)"); } // Process queued tasks $queue = get_transient('myplugin_task_queue'); if ($queue && !empty($queue)) { foreach ($queue as $task) { // Process task } delete_transient('myplugin_task_queue'); } // Close connections if (isset($GLOBALS['myplugin_db_connection'])) { $GLOBALS['myplugin_db_connection']->close(); } // Flush output buffer if needed if (ob_get_level()) { ob_end_flush(); } }
Common Filter Hooks
the_content
Filters the post content after retrieval from database but before display—one of the most used filters for adding content before/after posts, modifying HTML, injecting ads, or applying custom formatting.
<?php add_filter('the_content', 'myplugin_modify_content'); function myplugin_modify_content($content) { // Only on single posts if (!is_singular('post')) { return $content; } // Add sharing buttons after content $sharing = '<div class="share-buttons"> <a href="https://twitter.com/share">Twitter</a> <a href="https://facebook.com/share">Facebook</a> </div>'; // Add related posts $related = get_related_posts_html(); return $content . $sharing . $related; } // Insert ad after 2nd paragraph add_filter('the_content', function($content) { if (!is_single()) return $content; $ad = '<div class="mid-content-ad">[AD HERE]</div>'; $paragraphs = explode('</p>', $content); if (count($paragraphs) > 2) { array_splice($paragraphs, 2, 0, $ad); } return implode('</p>', $paragraphs); });
the_title
Filters the post title—receives title and post ID as arguments; useful for adding prefixes/suffixes, conditional modifications, or security sanitization before display.
<?php add_filter('the_title', 'myplugin_modify_title', 10, 2); function myplugin_modify_title($title, $post_id = null) { if (!$post_id) { return $title; } // Add prefix for sticky posts if (is_sticky($post_id) && !is_admin()) { $title = '📌 ' . $title; } // Add "NEW" badge for recent posts $post_date = get_the_date('U', $post_id); if ((time() - $post_date) < WEEK_IN_SECONDS) { $title = '<span class="new-badge">NEW</span> ' . $title; } // Indicate premium content if (get_post_meta($post_id, '_is_premium', true)) { $title .= ' 🔒'; } return $title; }
the_excerpt
Filters the post excerpt—allows modification of the auto-generated or manual excerpt before display; often used to change length, add read more links, or customize the excerpt format.
<?php // Change excerpt length add_filter('excerpt_length', function($length) { return 30; // words }); // Change excerpt ending add_filter('excerpt_more', function($more) { return '... <a href="' . get_permalink() . '">Read More →</a>'; }); // Fully customize excerpt add_filter('the_excerpt', function($excerpt) { if (is_search()) { // Highlight search terms $search = get_search_query(); $excerpt = preg_replace( '/(' . preg_quote($search, '/') . ')/i', '<mark>$1</mark>', $excerpt ); } // Wrap in custom div return '<div class="custom-excerpt">' . $excerpt . '</div>'; });
body_class
Filters the CSS classes applied to the <body> tag—receives array of classes; essential for adding conditional classes for styling based on context, user state, or custom conditions.
<?php add_filter('body_class', 'myplugin_body_class'); function myplugin_body_class($classes) { // Add user role class if (is_user_logged_in()) { $user = wp_get_current_user(); $classes[] = 'user-role-' . $user->roles[0]; $classes[] = 'logged-in-user'; } else { $classes[] = 'guest-user'; } // Add custom post type class if (is_singular()) { $classes[] = 'singular-' . get_post_type(); } // Add sidebar presence class if (is_active_sidebar('main-sidebar')) { $classes[] = 'has-sidebar'; } else { $classes[] = 'no-sidebar'; } // Add time-based classes $hour = (int) date('G'); $classes[] = ($hour >= 18 || $hour < 6) ? 'night-mode' : 'day-mode'; return $classes; }
post_class
Filters the CSS classes applied to the post container element—similar to body_class but for individual posts in loops; useful for styling posts based on metadata, categories, or custom fields.
<?php add_filter('post_class', 'myplugin_post_class', 10, 3); function myplugin_post_class($classes, $extra_classes, $post_id) { // Add featured class if (get_post_meta($post_id, '_is_featured', true)) { $classes[] = 'featured-post'; } // Add format-specific classes $format = get_post_format($post_id); if ($format) { $classes[] = 'has-format'; $classes[] = 'format-style-' . $format; } // Add reading time class $content = get_post_field('post_content', $post_id); $word_count = str_word_count(strip_tags($content)); $reading_time = ceil($word_count / 200); if ($reading_time <= 2) { $classes[] = 'quick-read'; } elseif ($reading_time >= 10) { $classes[] = 'long-read'; } return $classes; }
nav_menu_css_class
Filters the CSS classes applied to each navigation menu item—receives classes array, menu item object, menu args, and depth; powerful for adding custom styling based on menu item properties or relationships.
<?php add_filter('nav_menu_css_class', 'myplugin_nav_class', 10, 4); function myplugin_nav_class($classes, $item, $args, $depth) { // Only modify primary menu if ($args->theme_location !== 'primary') { return $classes; } // Add depth class $classes[] = 'menu-depth-' . $depth; // Add icon class based on title $icons = [ 'Home' => 'has-icon-home', 'About' => 'has-icon-info', 'Contact' => 'has-icon-mail', ]; if (isset($icons[$item->title])) { $classes[] = $icons[$item->title]; } // Add active ancestor highlighting if (in_array('current-menu-ancestor', $classes)) { $classes[] = 'active-trail'; } // Mark external links if (strpos($item->url, home_url()) === false) { $classes[] = 'external-link'; } return $classes; }
upload_mimes
Filters the list of allowed file types for upload—receives associative array of extension => mime type; use to add support for additional file types or restrict uploads for security.
<?php add_filter('upload_mimes', 'myplugin_upload_mimes'); function myplugin_upload_mimes($mimes) { // Add SVG support $mimes['svg'] = 'image/svg+xml'; $mimes['svgz'] = 'image/svg+xml'; // Add WebP support (if not already) $mimes['webp'] = 'image/webp'; // Add font types $mimes['woff'] = 'font/woff'; $mimes['woff2'] = 'font/woff2'; // Add JSON files $mimes['json'] = 'application/json'; // Remove dangerous types unset($mimes['exe']); unset($mimes['php']); return $mimes; } // Also need to fix file type checking for SVG add_filter('wp_check_filetype_and_ext', function($data, $file, $filename, $mimes) { $ext = pathinfo($filename, PATHINFO_EXTENSION); if ($ext === 'svg') { $data['ext'] = 'svg'; $data['type'] = 'image/svg+xml'; } return $data; }, 10, 4);
cron_schedules
Filters available cron schedules—receives array of schedules with interval and display name; use to add custom intervals for wp_schedule_event() beyond the default hourly, twicedaily, and daily options.
<?php add_filter('cron_schedules', 'myplugin_cron_schedules'); function myplugin_cron_schedules($schedules) { // Every 5 minutes $schedules['every_5_minutes'] = [ 'interval' => 5 * MINUTE_IN_SECONDS, 'display' => __('Every 5 Minutes', 'myplugin'), ]; // Every 15 minutes $schedules['every_15_minutes'] = [ 'interval' => 15 * MINUTE_IN_SECONDS, 'display' => __('Every 15 Minutes', 'myplugin'), ]; // Weekly $schedules['weekly'] = [ 'interval' => WEEK_IN_SECONDS, 'display' => __('Once Weekly', 'myplugin'), ]; // Monthly (approx) $schedules['monthly'] = [ 'interval' => 30 * DAY_IN_SECONDS, 'display' => __('Once Monthly', 'myplugin'), ]; return $schedules; } // Usage if (!wp_next_scheduled('myplugin_sync_event')) { wp_schedule_event(time(), 'every_15_minutes', 'myplugin_sync_event'); }
login_redirect
Filters the redirect destination after successful login—receives redirect URL, original requested redirect, and user object; use for role-based redirects or custom post-login flows.
<?php add_filter('login_redirect', 'myplugin_login_redirect', 10, 3); function myplugin_login_redirect($redirect_to, $requested, $user) { // Check for errors if (is_wp_error($user)) { return $redirect_to; } // Role-based redirects if (in_array('administrator', $user->roles)) { return admin_url(); } if (in_array('editor', $user->roles)) { return admin_url('edit.php'); } if (in_array('customer', $user->roles)) { return wc_get_account_endpoint_url('dashboard'); } if (in_array('subscriber', $user->roles)) { return home_url('/members-area/'); } // First-time login redirect if (!get_user_meta($user->ID, 'myplugin_first_login', true)) { update_user_meta($user->ID, 'myplugin_first_login', time()); return home_url('/welcome/'); } return $redirect_to; }
authenticate
Filters the user authentication process—receives user object/error, username, and password; powerful for implementing custom authentication methods, adding 2FA, or restricting access based on custom logic.
<?php add_filter('authenticate', 'myplugin_authenticate', 30, 3); function myplugin_authenticate($user, $username, $password) { // Skip if already failed if (is_wp_error($user)) { return $user; } // Skip if not a valid user yet if (!($user instanceof WP_User)) { return $user; } // Block banned users if (get_user_meta($user->ID, 'myplugin_banned', true)) { return new WP_Error( 'account_banned', __('Your account has been suspended.', 'myplugin') ); } // Require email verification if (!get_user_meta($user->ID, 'myplugin_email_verified', true)) { return new WP_Error( 'email_not_verified', __('Please verify your email address.', 'myplugin') ); } // Check IP whitelist for admins if (in_array('administrator', $user->roles)) { $allowed_ips = ['192.168.1.1', '10.0.0.1']; if (!in_array($_SERVER['REMOTE_ADDR'], $allowed_ips)) { return new WP_Error('ip_blocked', 'Access denied from this IP.'); } } return $user; }
wp_mail
Filters all outgoing emails sent through wp_mail()—receives array with keys: to, subject, message, headers, attachments; perfect for email logging, modifying sender info, or routing through external services.
<?php add_filter('wp_mail', 'myplugin_filter_mail'); function myplugin_filter_mail($args) { // Log all outgoing emails $log = sprintf( "[%s] Email to: %s | Subject: %s\n", date('Y-m-d H:i:s'), is_array($args['to']) ? implode(', ', $args['to']) : $args['to'], $args['subject'] ); error_log($log, 3, WP_CONTENT_DIR . '/email.log'); // Add prefix to subject $args['subject'] = '[MySite] ' . $args['subject']; // Ensure headers is array if (!is_array($args['headers'])) { $args['headers'] = []; } // Add custom header $args['headers'][] = 'X-Mailer: MyPlugin/1.0'; // Add BCC for monitoring if (defined('MYPLUGIN_EMAIL_MONITOR')) { $args['headers'][] = 'Bcc: ' . MYPLUGIN_EMAIL_MONITOR; } return $args; } // Also useful filters: add_filter('wp_mail_from', fn($email) => 'noreply@example.com'); add_filter('wp_mail_from_name', fn($name) => 'My Website'); add_filter('wp_mail_content_type', fn($type) => 'text/html');
query_vars
Filters the array of public query variables—use to add custom URL parameters that WordPress will recognize and parse; these variables become accessible via get_query_var() and can be used in pre_get_posts or template logic.
<?php add_filter('query_vars', 'myplugin_query_vars'); function myplugin_query_vars($vars) { // Add custom query variables $vars[] = 'myplugin_filter'; $vars[] = 'myplugin_sort'; $vars[] = 'myplugin_page'; $vars[] = 'author_filter'; return $vars; } // Now use in templates or pre_get_posts add_action('pre_get_posts', function($query) { if (!is_admin() && $query->is_main_query()) { $filter = get_query_var('myplugin_filter'); if ($filter) { $query->set('meta_key', 'category_filter'); $query->set('meta_value', sanitize_text_field($filter)); } $sort = get_query_var('myplugin_sort'); if ($sort === 'popular') { $query->set('orderby', 'meta_value_num'); $query->set('meta_key', 'view_count'); } } }); // URLs like: example.com/blog/?myplugin_filter=featured&myplugin_sort=popular
Shortcodes
add_shortcode()
Registers a shortcode handler function—takes the shortcode tag name and callback; the callback receives attributes array, enclosed content (if any), and the tag name; always return output instead of echoing.
<?php // Syntax: add_shortcode($tag, $callback) add_shortcode('greeting', 'myplugin_greeting_shortcode'); function myplugin_greeting_shortcode($atts, $content = null, $tag = '') { // Always RETURN, never echo return '<p class="greeting">Hello, World!</p>'; } // Usage: [greeting] // Class method registration class MyPlugin_Shortcodes { public function __construct() { add_shortcode('plugin_feature', [$this, 'render_feature']); } public function render_feature($atts, $content = null) { return '<div class="feature">' . esc_html($content) . '</div>'; } } new MyPlugin_Shortcodes();
Shortcode attributes
Shortcodes accept named attributes passed as key-value pairs—these arrive as an associative array in your callback; always use shortcode_atts() to merge with defaults and sanitize all attribute values.
<?php add_shortcode('button', 'myplugin_button_shortcode'); function myplugin_button_shortcode($atts) { // Merge with defaults $atts = shortcode_atts([ 'url' => '#', 'color' => 'blue', 'size' => 'medium', 'target' => '_self', 'text' => 'Click Here', ], $atts, 'button'); // Sanitize $url = esc_url($atts['url']); $color = sanitize_html_class($atts['color']); $size = sanitize_html_class($atts['size']); $target = $atts['target'] === '_blank' ? '_blank' : '_self'; $text = esc_html($atts['text']); return sprintf( '<a href="%s" class="btn btn-%s btn-%s" target="%s">%s</a>', $url, $color, $size, $target, $text ); } // Usage: [button url="https://example.com" color="red" size="large" text="Buy Now"]
Shortcode content
Enclosing shortcodes can wrap content between opening and closing tags—the content is passed as the second parameter to your callback; process nested shortcodes with do_shortcode() if needed.
<?php add_shortcode('highlight', 'myplugin_highlight_shortcode'); function myplugin_highlight_shortcode($atts, $content = null) { $atts = shortcode_atts([ 'color' => 'yellow', ], $atts); // Content is null for self-closing, string for enclosing if (empty($content)) { return ''; } // Process nested shortcodes in content $content = do_shortcode($content); // Apply wpautop for paragraph formatting $content = wpautop($content); return sprintf( '<div class="highlight" style="background-color: %s;">%s</div>', esc_attr($atts['color']), wp_kses_post($content) ); } // Usage: [highlight color="pink"]This text will be highlighted[/highlight]
Nested shortcodes
Shortcodes can be nested inside each other—call do_shortcode() on the content to process inner shortcodes; be aware of recursion limits and avoid creating infinite loops with self-nesting shortcodes.
<?php // Parent shortcode add_shortcode('tabs', function($atts, $content = null) { // Process inner [tab] shortcodes $content = do_shortcode($content); return '<div class="tabs-container">' . $content . '</div>'; }); // Child shortcode add_shortcode('tab', function($atts, $content = null) { $atts = shortcode_atts(['title' => 'Tab'], $atts); $content = do_shortcode($content); // Allow nesting deeper return sprintf( '<div class="tab" data-title="%s">%s</div>', esc_attr($atts['title']), wp_kses_post($content) ); }); // Usage: // [tabs] // [tab title="First"]Content for first tab[/tab] // [tab title="Second"]Content for second tab[/tab] // [/tabs]
┌─────────────────────────────────────────────────┐
│ NESTED SHORTCODES FLOW │
├─────────────────────────────────────────────────┤
│ │
│ [tabs] │
│ ├── [tab title="One"]Tab 1[/tab] │
│ └── [tab title="Two"]Tab 2[/tab] │
│ [/tabs] │
│ │
│ Process Order: │
│ 1. [tabs] callback runs │
│ 2. do_shortcode($content) called │
│ 3. Inner [tab]s processed │
│ 4. Results returned to [tabs] │
│ 5. Final HTML returned │
│ │
└─────────────────────────────────────────────────┘
shortcode_atts()
Merges user-provided shortcode attributes with defaults and filters the result—takes defaults array, user attributes, and optional shortcode tag for filtering; essential for providing default values and enabling attribute filtering by other code.
<?php // Syntax: shortcode_atts($defaults, $atts, $shortcode_tag) add_shortcode('profile', function($atts) { // Define defaults $defaults = [ 'id' => 0, 'show_avatar' => 'yes', 'show_bio' => 'yes', 'avatar_size' => 96, 'class' => '', ]; // Merge with user attributes // Third param enables filter: shortcode_atts_profile $atts = shortcode_atts($defaults, $atts, 'profile'); // Now safely access all attributes $user = get_userdata((int) $atts['id']); if (!$user) return ''; $output = '<div class="profile ' . esc_attr($atts['class']) . '">'; if ($atts['show_avatar'] === 'yes') { $output .= get_avatar($user->ID, (int) $atts['avatar_size']); } if ($atts['show_bio'] === 'yes') { $output .= '<p>' . esc_html($user->description) . '</p>'; } return $output . '</div>'; }); // Others can filter the defaults add_filter('shortcode_atts_profile', function($atts) { $atts['avatar_size'] = 150; // Override default return $atts; });
Enclosing vs self-closing
WordPress shortcodes can be self-closing [shortcode] or enclosing [shortcode]content[/shortcode]—both use the same registration; the difference is whether $content is null or contains the wrapped text.
<?php add_shortcode('message', function($atts, $content = null) { $atts = shortcode_atts(['type' => 'info'], $atts); // Self-closing: [message type="warning"] if ($content === null) { return sprintf( '<div class="message %s">Default message</div>', esc_attr($atts['type']) ); } // Enclosing: [message type="success"]Custom text[/message] return sprintf( '<div class="message %s">%s</div>', esc_attr($atts['type']), do_shortcode($content) // Process nested shortcodes ); });
┌────────────────────────────────────────────────────────────────┐
│ SELF-CLOSING vs ENCLOSING SHORTCODES │
├────────────────────────────────────────────────────────────────┤
│ │
│ SELF-CLOSING: │
│ [gallery ids="1,2,3"] │
│ [contact_form] │
│ [divider style="dashed"] │
│ → $content = null │
│ │
│ ENCLOSING: │
│ [button]Click Me[/button] │
│ [spoiler]Hidden text[/spoiler] │
│ [accordion]...[/accordion] │
│ → $content = "content between tags" │
│ │
│ BOTH WORK WITH SAME add_shortcode() REGISTRATION │
│ │
└────────────────────────────────────────────────────────────────┘
Shortcode best practices
Follow these guidelines for robust shortcodes: always return output (never echo), sanitize and escape all data, use shortcode_atts() for defaults, enable content filtering with do_shortcode(), prefix tag names to avoid conflicts, and load assets only when shortcode is used.
<?php class MyPlugin_Shortcodes { private static $shortcode_used = false; public static function init() { add_shortcode('myplugin_gallery', [__CLASS__, 'gallery']); add_action('wp_footer', [__CLASS__, 'maybe_load_assets']); } public static function gallery($atts, $content = null) { // Mark shortcode as used (for conditional asset loading) self::$shortcode_used = true; // ✅ Use shortcode_atts with defaults $atts = shortcode_atts([ 'columns' => 3, 'ids' => '', ], $atts, 'myplugin_gallery'); // ✅ Sanitize inputs $columns = absint($atts['columns']); $ids = array_map('absint', explode(',', $atts['ids'])); // ✅ Use output buffering for complex HTML ob_start(); ?> <div class="myplugin-gallery columns-<?php echo $columns; ?>"> <?php foreach ($ids as $id): ?> <figure><?php echo wp_get_attachment_image($id, 'medium'); ?></figure> <?php endforeach; ?> </div> <?php // ✅ RETURN, never echo return ob_get_clean(); } // ✅ Load assets only when shortcode is used public static function maybe_load_assets() { if (!self::$shortcode_used) return; wp_enqueue_style('myplugin-gallery', plugin_dir_url(__FILE__) . 'gallery.css'); wp_enqueue_script('myplugin-gallery', plugin_dir_url(__FILE__) . 'gallery.js'); } } MyPlugin_Shortcodes::init();
┌───────────────────────────────────────────────────────┐
│ SHORTCODE BEST PRACTICES │
├───────────────────────────────────────────────────────┤
│ │
│ ✅ DO: │
│ • Return output, never echo │
│ • Use shortcode_atts() for defaults │
│ • Sanitize all user input │
│ • Escape output (esc_html, esc_attr) │
│ • Prefix shortcode names │
│ • Load CSS/JS only when shortcode used │
│ • Support nested shortcodes with do_shortcode() │
│ • Use output buffering for complex HTML │
│ │
│ ❌ DON'T: │
│ • Echo directly in callback │
│ • Trust user attributes blindly │
│ • Use generic names like [button] │
│ • Always load assets on every page │
│ • Create self-nesting shortcodes │
│ │
└───────────────────────────────────────────────────────┘