{"slug":"wp-plugin-hooks","title":"wp-plugin-hooks","summary":"Design custom action/filter hooks emitted by a plugin: action vs filter semantics, prefixed names, docblocks, parameter stability, *_ref_array forwarding, and deprecated hook migration. Use when adding, reviewing, evolving, or deprecating a public hook surface. Triggers on do_act","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-09-16T14:52:03.957792Z","repo":{"url":"https://github.com/Lonsdale201/wp-agent-skills","stars":22,"forks":2,"license":"MIT","updatedAt":"2026-09-21T19:53:59Z"},"bodyHtml":"<hr>\n<h2>name: wp-plugin-hooks\ndescription: &gt;-\nDesign custom action/filter hooks emitted by a plugin:\naction vs filter semantics, prefixed names, docblocks, parameter\nstability, *_ref_array forwarding, and deprecated hook migration. Use\nwhen adding, reviewing, evolving, or deprecating a public hook surface.\nTriggers on do_action, apply_filters, apply_filters_deprecated,\ndo_action_deprecated, apply_filters_ref_array, do_action_ref_array,\ndid_action, did_filter, or hook @since docblocks.\nmetadata:\nwp-skills-author: \"Soczó Kristóf\"\nwp-skills-contact: \"mailto:lonsdale201@hotmail.com\"\nwp-skills-plugin: \"wordpress\"\nwp-skills-plugin-version-tested: \"6.5 - 7.1\"\nwp-skills-wp-version-tested: \"7.1\"\nwp-skills-php-min: \"7.4\"\nwp-skills-last-updated: \"2026-08-20\"</h2>\n<h1>WordPress plugin: custom hooks (the ones YOU emit)</h1>\n<p>This skill is about hooks the plugin <strong>emits</strong> as its public extension surface — the actions and filters other developers wire into to modify or react to the plugin's behavior. Using core WP hooks (<code>init</code>, <code>wp_enqueue_scripts</code>, <code>the_content</code>, etc.) is basic WP and out of scope; designing your own hooks well is what separates a plugin people can extend from one they have to fork.</p>\n<p>A custom hook is part of your plugin's API contract. Once a third party builds on it, breaking the signature in a minor release is a backwards-incompatibility bug — same as renaming a public PHP method.</p>\n<h2>When to use this skill</h2>\n<p>Trigger when ANY of the following is true:</p>\n<ul>\n<li>Adding a <code>do_action</code> or <code>apply_filters</code> call that other plugins / themes will hook into.</li>\n<li>Reviewing a PR that introduces or modifies a custom hook.</li>\n<li>Evolving a hook signature across plugin versions (adding parameters, renaming, deprecating).</li>\n<li>Removing or renaming an existing hook — read the deprecation section before doing this.</li>\n<li>The diff contains: <code>do_action</code>, <code>apply_filters</code>, <code>apply_filters_deprecated</code>, <code>do_action_deprecated</code>, or a docblock starting with <code>Fires</code> / <code>Filters</code> above a hook call.</li>\n</ul>\n<h2>Action vs filter — pick by semantics</h2>\n<p>Both are events your code emits during execution. The semantic difference:</p>\n<table>\n<thead>\n<tr>\n<th>Hook type</th>\n<th>Question it answers</th>\n<th>What listeners do</th>\n<th>Return</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>Action</strong> (<code>do_action</code>)</td>\n<td>\"This thing happened — anyone want to react?\"</td>\n<td>Side effects (log, send email, update meta, schedule cron). Don't return a value.</td>\n<td>None</td>\n</tr>\n<tr>\n<td><strong>Filter</strong> (<code>apply_filters</code>)</td>\n<td>\"I have this value. Anyone want to modify it before I use it?\"</td>\n<td>Take the value, optionally transform it, return it.</td>\n<td>The (possibly modified) value</td>\n</tr>\n</tbody>\n</table>\n<p>Concrete examples:</p>\n<pre><code>// ACTION — \"the user just submitted the form\"; downstream side effects\ndo_action( 'myplugin/form_submitted', $form_id, $submission );\n// Listeners: send Slack notification, log to audit table, dispatch webhook.\n\n// FILTER — \"here's the response message; anyone want to override?\"\n$message = apply_filters( 'myplugin/response_message', $default_message, $form_id );\n// Listeners: replace the message based on form_id, append \"(internal)\" prefix.\n</code></pre>\n<p>If listeners might want to MUTATE the data flow, it's a filter. If they want to REACT to an event, it's an action. When in doubt: would removing all listeners change the plugin's output? Yes → filter. No → action.</p>\n<h2>Naming — prefix everything, pick one separator</h2>\n<p>WP core mostly uses <code>underscore_style</code> names (<code>pre_get_posts</code>, <code>rest_pre_serve_request</code>). Slash-style names are common in plugin ecosystems for namespaced surfaces (<code>myplugin/before_request</code>), but they are not the dominant core convention. The hard rule: <strong>prefix with the plugin slug</strong> so collisions are impossible.</p>\n<pre><code>// Slash-separated - visually clear that this is plugin-namespaced\ndo_action( 'myplugin/before_request', $payload );\napply_filters( 'myplugin/api_response', $response, $request );\n\n// Underscore-separated - matches WP core convention\ndo_action( 'myplugin_before_request', $payload );\napply_filters( 'myplugin_api_response', $response, $request );\n</code></pre>\n<p>Pick one style and stay consistent across the plugin. Mixing <code>myplugin/before_request</code> and <code>myplugin_after_request</code> in the same plugin is confusing for documentation tools and developers searching the source.</p>\n<h2>Document every hook with a docblock</h2>\n<p>The <code>@since</code> + <code>@param</code> block above each hook call is non-negotiable — IDE tooltips, source-search tools (<code>hooks.wp.org</code>, AI assistants suggesting hooks), and humans grepping for hooks all depend on it.</p>\n<pre><code>/**\n * Fires before the AI request is sent.\n *\n * @since 1.2.0\n *\n * @param array  $payload The request payload.\n * @param string $context Reason for the request: 'verdict' or 'enrichment'.\n */\ndo_action( 'myplugin/before_request', $payload, $context );\n\n/**\n * Filters the AI response message that will be shown to the user.\n *\n * @since 1.0.0\n * @since 1.3.0 Added the `$decision` parameter.\n *\n * @param string $message  The response message.\n * @param int    $form_id  ID of the form being processed.\n * @param bool   $decision The AI's TRUE/FALSE verdict.\n *\n * @return string Possibly modified message.\n */\n$message = apply_filters( 'myplugin/response_message', $message, $form_id, $decision );\n</code></pre>\n<p>Conventions:</p>\n<ul>\n<li><strong><code>Fires</code> for actions, <code>Filters</code> for filters</strong> as the opening verb in the description (matches WP core docblocks; documentation generators grep for it).</li>\n<li><strong><code>@since X.Y.Z</code></strong> for the version when the hook first appeared, plus a separate <code>@since</code> line for each later parameter addition.</li>\n<li><strong>Every parameter documented</strong> with type and meaning. The first arg in a filter is always \"the value being filtered\".</li>\n<li><strong>For filters: <code>@return</code></strong> describes the return type — same shape as the input value (filters MUST preserve type).</li>\n</ul>\n<h2>Parameter design</h2>\n<p>Three rules govern parameters that age well:</p>\n<h3>1. Order: most-likely-to-be-modified first</h3>\n<p>For filters, the first arg is always the value being filtered. For actions and filters alike, <strong>arrange other args in order of likely use</strong> — listeners often only want one or two pieces of context. If the form ID is the most useful piece for filtering, put it second.</p>\n<pre><code>// Better — form_id is more often useful than the full submission array\napply_filters( 'myplugin/should_process', true, $form_id, $submission );\n\n// Worse — listener has to accept submission they don't need to skip past\napply_filters( 'myplugin/should_process', true, $submission, $form_id );\n</code></pre>\n<h3>2. Pass IDs and primitives, not heavy objects when avoidable</h3>\n<p>Listeners may only need the form ID; passing the whole <code>Form</code> object means every listener carries the full object even if they ignore it. When the work to fetch the object is cheap (small DB hit), pass the ID; when listeners always need the object anyway, pass it.</p>\n<h3>3. Keep the parameter count below 4</h3>\n<p>Beyond 4 args, listeners get unwieldy. If you find yourself wanting 5+ args, bundle them into an associative array:</p>\n<pre><code>// 6-arg action — listeners must accept all six in order\ndo_action( 'myplugin/render', $template, $context, $vars, $depth, $strict, $cache_key );\n\n// Better — single context array, listeners pick what they need\ndo_action( 'myplugin/render', array(\n    'template'  =&gt; $template,\n    'context'   =&gt; $context,\n    'vars'      =&gt; $vars,\n    'depth'     =&gt; $depth,\n    'strict'    =&gt; $strict,\n    'cache_key' =&gt; $cache_key,\n) );\n</code></pre>\n<p>The trade-off: array-as-arg loses static analysis benefits. For 2-3 strongly-typed args, prefer separate parameters; for 5+ heterogeneous fields, bundle.</p>\n<h2><code>do_action_ref_array</code> / <code>apply_filters_ref_array</code> — when args are dynamic</h2>\n<p>The standard <code>do_action( $hook, ...$args )</code> and <code>apply_filters( $hook, $value, ...$args )</code> use variadic spread (PHP 5.6+). When you DON'T know the args at compile time — typically when proxying / forwarding hook calls — use the array variants:</p>\n<pre><code>$args = array( $payload, $context, $extra );\n\n// Spread variant — only when args are statically known\ndo_action( 'myplugin/event', $payload, $context, $extra );\n\n// Array variant — args are an array dynamically\ndo_action_ref_array( 'myplugin/event', $args );\n</code></pre>\n<p>The naming <code>_ref_array</code> is historical. These functions accept the hook arguments as an array and pass that array to <code>WP_Hook</code>; they are primarily \"args-as-array\" variants. References only matter if the array elements themselves are references, so do not reach for these functions as a generic \"make callback args mutable\" tool. Verified in <code>wp-includes/plugin.php</code> <code>apply_filters_ref_array</code> / <code>do_action_ref_array</code>.</p>\n<p>99% of plugin code uses the spread variants. Reach for the array variants only when forwarding (<code>apply_filters_deprecated</code> uses them internally for exactly this reason).</p>\n<h2>The stability promise</h2>\n<p>Once a hook is documented and shipped:</p>\n<ul>\n<li><strong>Don't change parameter count or order</strong> in minor / patch releases. Adding a NEW parameter at the END is OK with proper <code>@since</code> annotation; reordering or removing is a major version bump.</li>\n<li><strong>Don't change parameter types.</strong> Going from <code>int $form_id</code> to <code>string $form_slug</code> is a breaking change.</li>\n<li><strong>Don't change return-type semantics for filters.</strong> A filter that returned <code>string</code> shouldn't suddenly return <code>string|null</code>.</li>\n<li><strong>Don't move the hook to a different code path</strong> that significantly changes timing. Listeners may rely on \"this fires before X happens\".</li>\n</ul>\n<p>Track public hooks in your plugin's documentation / README under a \"Hooks\" section. Treat them with the same rigor as the public methods of a class.</p>\n<h2>Deprecation pathway</h2>\n<p>When you genuinely must change or remove a hook, deprecate, don't delete. WordPress provides <code>apply_filters_deprecated</code> and <code>do_action_deprecated</code> (<code>wp-includes/plugin.php</code>, since WP 4.6) that fire the hook for any remaining listeners AND emit a <code>_deprecated_hook</code> notice (<code>wp-includes/functions.php</code>).</p>\n<pre><code>// OLD HOOK (now deprecated): myplugin/old_response\n// NEW HOOK: myplugin/response_message\n\n// Step 1: emit BOTH hooks so existing listeners keep working.\n$message = apply_filters( 'myplugin/response_message', $default, $form_id );\n\n// Fire the deprecated hook with the same args; emits _deprecated_hook notice.\n$message = apply_filters_deprecated(\n    'myplugin/old_response',           // old hook name\n    array( $message, $form_id ),       // args (as array)\n    '1.5.0',                           // version when deprecated\n    'myplugin/response_message',       // replacement\n    'Use myplugin/response_message instead.' // optional message\n);\n</code></pre>\n<p>For actions:</p>\n<pre><code>do_action_deprecated(\n    'myplugin/old_event',\n    array( $payload ),\n    '1.5.0',\n    'myplugin/new_event'\n);\n</code></pre>\n<p>The deprecation helpers short-circuit when no listener is attached (<code>has_filter</code> / <code>has_action</code> returns false), so they're cheap when nobody's listening. The notice fires only when someone IS still using the old hook — exactly when you want them informed.</p>\n<p>Deprecation policy: keep the deprecated hook for at least one major version. <code>1.5.0</code> deprecates → <code>2.0.0</code> removes. Document the migration in the changelog.</p>\n<h2>Critical rules</h2>\n<ul>\n<li><strong>Action for events, filter for value transformations.</strong> Don't use a filter for side effects or an action for \"let me modify this\".</li>\n<li><strong>Prefix every custom hook</strong> with the plugin slug. Pick <code>slash/style</code> or <code>underscore_style</code>, stay consistent.</li>\n<li><strong>Docblock every hook</strong> — <code>Fires</code> / <code>Filters</code> opening, <code>@since</code>, every <code>@param</code> typed and described, <code>@return</code> for filters.</li>\n<li><strong>Filters MUST preserve type.</strong> A filter receiving <code>string</code> returns <code>string</code> (or you've designed it badly). Listeners who break the type get to fix their callbacks; you don't change the contract on them.</li>\n<li><strong>Parameter contract is your API.</strong> No reordering, no type changes, no removals in non-major releases.</li>\n<li><strong>Deprecate via <code>apply_filters_deprecated</code> / <code>do_action_deprecated</code></strong>, never silent-delete a public hook.</li>\n<li><strong>Bundle 5+ args into an array</strong> instead of growing the parameter list.</li>\n</ul>\n<h2>Common mistakes</h2>\n<pre><code>// WRONG — action used as a filter (return value lost)\n$result = do_action( 'myplugin/transform', $value ); // do_action returns void\n\n// WRONG — filter that doesn't return the value\nadd_filter( 'myplugin/response_message', function ( $message ) {\n    error_log( $message );        // side effect\n    // missing: return $message;\n} );\n// Other listeners receive the previous value or null; chain breaks.\n\n// WRONG — bare hook name, collides with the world\ndo_action( 'before_save', $data );\n\n// WRONG — adding a parameter in the middle, breaking existing listeners\n// v1.0\napply_filters( 'myplugin/response_message', $msg, $form_id );\n// v1.1\napply_filters( 'myplugin/response_message', $msg, $context, $form_id ); // BUG — breaks existing listeners\n\n// RIGHT — append at the end, document with @since\napply_filters( 'myplugin/response_message', $msg, $form_id, $context );\n\n// WRONG — silent removal\n// (the hook just stops firing, listeners get no warning)\n\n// RIGHT — deprecate first\n$message = apply_filters_deprecated(\n    'myplugin/old_response',\n    array( $message, $form_id ),\n    '1.5.0',\n    'myplugin/response_message'\n);\n\n// WRONG — undocumented hook\ndo_action( 'myplugin/before_request', $payload );\n// IDE / hooks search tools / AI assistants can't find or describe this hook.\n</code></pre>\n<h2>Cross-references</h2>\n<ul>\n<li>Run <strong><code>wp-plugin-architecture</code></strong> — the hook-naming convention is part of broader plugin architecture. Schema/Constants centralization includes hook names for discoverability.</li>\n<li>Run <strong><code>wp-i18n-audit</code></strong> if any of your hooks pass translatable strings as args — translation timing rules apply to the values, not the hook names.</li>\n<li>Run <strong><code>wp-security-audit</code></strong> when your hook callback receives user input that downstream listeners will trust — document the sanitization expectation in the <code>@param</code> line.</li>\n</ul>\n<h2>What this skill does NOT cover</h2>\n<ul>\n<li>Using core WP hooks (<code>init</code>, <code>the_content</code>, <code>save_post</code>, etc.) — every WP plugin does this; not a custom-hook design topic.</li>\n<li>Hook priority gymnastics (<code>add_action( $hook, $cb, $priority )</code>) — basic WP, not a design topic for the plugin emitter.</li>\n<li>Removing core WP behavior via <code>remove_filter</code> / <code>remove_action</code> — adjacent topic, separate skill.</li>\n<li>Internal-only \"hooks\" used as a poor-man's event bus inside a single plugin (use proper service classes / observers instead — see <code>wp-plugin-architecture</code>).</li>\n<li>The <code>'all'</code> meta-hook (a debugging tool, not a design pattern).</li>\n</ul>\n<h2>References</h2>\n<ul>\n<li>Plugins Hooks Handbook: <a href=\"https://developer.wordpress.org/plugins/hooks/\">developer.wordpress.org/plugins/hooks/</a></li>\n<li><code>apply_filters</code> / <code>do_action</code>: <code>wp-includes/plugin.php</code></li>\n<li><code>apply_filters_deprecated</code> / <code>do_action_deprecated</code>: <code>wp-includes/plugin.php</code> (since WP 4.6)</li>\n<li><code>_deprecated_hook</code> (the underlying notice trigger): <code>wp-includes/functions.php</code></li>\n<li><code>did_action</code> / <code>did_filter</code>: <code>wp-includes/plugin.php</code> — useful in tests / debugging to assert a hook fired.</li>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/reference/functions/apply_filters/\">https://developer.wordpress.org/reference/functions/apply_filters/</a></li>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/reference/functions/do_action/\">https://developer.wordpress.org/reference/functions/do_action/</a></li>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/reference/functions/apply_filters_deprecated/\">https://developer.wordpress.org/reference/functions/apply_filters_deprecated/</a></li>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/reference/functions/do_action_deprecated/\">https://developer.wordpress.org/reference/functions/do_action_deprecated/</a></li>\n</ul>\n","files":[{"path":"SKILL.md","sizeBytes":15257,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-09-16T14:57:03.038538Z","sha256":"24A6E50D4AD3971FA10F169A89C377953A5293C86903ED919EFE627647793190","sizeBytes":5952},"review":null,"source":{"repositoryUrl":"https://github.com/Lonsdale201/wp-agent-skills","path":"plugin-scaffold/wp-plugin-hooks","license":"MIT","commit":"8820ff3c301066297e696611e3bc4ebeb47d1851","subtreeSha":"1C114F6DD56BDAB5A3F0773D7F4A0BAD871241EDE134C9FA6D2FA14A8C28F6CF","lastSyncedAt":"2026-09-22T13:51:11.366991Z"},"reviewedAt":"2026-09-16T15:14:48.512353Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/plugin-scaffold/wp-plugin-hooks"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart"},{"target":"git","command":"git clone https://github.com/Lonsdale201/wp-agent-skills.git"}]}