| name | wp-plugin-hooks |
| description | 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_action, apply_filters, apply_filters_deprecated, do_action_deprecated, apply_filters_ref_array, do_action_ref_array, did_action, did_filter, or hook @since docblocks. |
| author | Soczó Kristóf |
| contact | mailto:lonsdale201@hotmail.com |
| plugin | wordpress |
| plugin-version-tested | 6.5 - 6.9 |
| php-min | 7.4 |
| last-updated | "2026-04-28T00:00:00.000Z" |
| source | https://github.com/Lonsdale201/wp-agent-skills/tree/8684fef5b4c33bc0cd783f9fff7770b1f7f59c57/plugin-scaffold/wp-plugin-hooks |
| source-license | MIT |
| license | MIT |
WordPress plugin: custom hooks (the ones YOU emit)
This skill is about hooks the plugin emits 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 (init, wp_enqueue_scripts, the_content, 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.
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.
When to use this skill
Trigger when ANY of the following is true:
- Adding a
do_action or apply_filters call that other plugins / themes will hook into.
- Reviewing a PR that introduces or modifies a custom hook.
- Evolving a hook signature across plugin versions (adding parameters, renaming, deprecating).
- Removing or renaming an existing hook — read the deprecation section before doing this.
- The diff contains:
do_action, apply_filters, apply_filters_deprecated, do_action_deprecated, or a docblock starting with Fires / Filters above a hook call.
Action vs filter — pick by semantics
Both are events your code emits during execution. The semantic difference:
| Hook type | Question it answers | What listeners do | Return |
|---|
Action (do_action) | "This thing happened — anyone want to react?" | Side effects (log, send email, update meta, schedule cron). Don't return a value. | None |
Filter (apply_filters) | "I have this value. Anyone want to modify it before I use it?" | Take the value, optionally transform it, return it. | The (possibly modified) value |
Concrete examples:
do_action( 'myplugin/form_submitted', $form_id, $submission );
$message = apply_filters( 'myplugin/response_message', $default_message, $form_id );
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.
Naming — prefix everything, pick one separator
WP core mostly uses underscore_style names (pre_get_posts, rest_pre_serve_request). Slash-style names are common in plugin ecosystems for namespaced surfaces (myplugin/before_request), but they are not the dominant core convention. The hard rule: prefix with the plugin slug so collisions are impossible.
do_action( 'myplugin/before_request', $payload );
apply_filters( 'myplugin/api_response', $response, $request );
do_action( 'myplugin_before_request', $payload );
apply_filters( 'myplugin_api_response', $response, $request );
Pick one style and stay consistent across the plugin. Mixing myplugin/before_request and myplugin_after_request in the same plugin is confusing for documentation tools and developers searching the source.
Document every hook with a docblock
The @since + @param block above each hook call is non-negotiable — IDE tooltips, source-search tools (hooks.wp.org, AI assistants suggesting hooks), and humans grepping for hooks all depend on it.
do_action( 'myplugin/before_request', $payload, $context );
$message = apply_filters( 'myplugin/response_message', $message, $form_id, $decision );
Conventions:
Fires for actions, Filters for filters as the opening verb in the description (matches WP core docblocks; documentation generators grep for it).
@since X.Y.Z for the version when the hook first appeared, plus a separate @since line for each later parameter addition.
- Every parameter documented with type and meaning. The first arg in a filter is always "the value being filtered".
- For filters:
@return describes the return type — same shape as the input value (filters MUST preserve type).
Parameter design
Three rules govern parameters that age well:
1. Order: most-likely-to-be-modified first
For filters, the first arg is always the value being filtered. For actions and filters alike, arrange other args in order of likely use — listeners often only want one or two pieces of context. If the form ID is the most useful piece for filtering, put it second.
apply_filters( 'myplugin/should_process', true, $form_id, $submission );
apply_filters( 'myplugin/should_process', true, $submission, $form_id );
2. Pass IDs and primitives, not heavy objects when avoidable
Listeners may only need the form ID; passing the whole Form 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.
3. Keep the parameter count below 4
Beyond 4 args, listeners get unwieldy. If you find yourself wanting 5+ args, bundle them into an associative array:
do_action( 'myplugin/render', $template, $context, $vars, $depth, $strict, $cache_key );
do_action( 'myplugin/render', array(
'template' => $template,
'context' => $context,
'vars' => $vars,
'depth' => $depth,
'strict' => $strict,
'cache_key' => $cache_key,
) );
The trade-off: array-as-arg loses static analysis benefits. For 2-3 strongly-typed args, prefer separate parameters; for 5+ heterogeneous fields, bundle.
do_action_ref_array / apply_filters_ref_array — when args are dynamic
The standard do_action( $hook, ...$args ) and apply_filters( $hook, $value, ...$args ) 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:
$args = array( $payload, $context, $extra );
do_action( 'myplugin/event', $payload, $context, $extra );
do_action_ref_array( 'myplugin/event', $args );
The naming _ref_array is historical. These functions accept the hook arguments as an array and pass that array to WP_Hook; 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 wp-includes/plugin.php apply_filters_ref_array / do_action_ref_array.
99% of plugin code uses the spread variants. Reach for the array variants only when forwarding (apply_filters_deprecated uses them internally for exactly this reason).
The stability promise
Once a hook is documented and shipped:
- Don't change parameter count or order in minor / patch releases. Adding a NEW parameter at the END is OK with proper
@since annotation; reordering or removing is a major version bump.
- Don't change parameter types. Going from
int $form_id to string $form_slug is a breaking change.
- Don't change return-type semantics for filters. A filter that returned
string shouldn't suddenly return string|null.
- Don't move the hook to a different code path that significantly changes timing. Listeners may rely on "this fires before X happens".
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.
Deprecation pathway
When you genuinely must change or remove a hook, deprecate, don't delete. WordPress provides apply_filters_deprecated and do_action_deprecated (wp-includes/plugin.php, since WP 4.6) that fire the hook for any remaining listeners AND emit a _deprecated_hook notice (wp-includes/functions.php).
$message = apply_filters( 'myplugin/response_message', $default, $form_id );
$message = apply_filters_deprecated(
'myplugin/old_response', // old hook name
array( $message, $form_id ),
'1.5.0',
'myplugin/response_message',
'Use myplugin/response_message instead.'
);
For actions:
do_action_deprecated(
'myplugin/old_event',
array( $payload ),
'1.5.0',
'myplugin/new_event'
);
The deprecation helpers short-circuit when no listener is attached (has_filter / has_action 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.
Deprecation policy: keep the deprecated hook for at least one major version. 1.5.0 deprecates → 2.0.0 removes. Document the migration in the changelog.
Critical rules
- Action for events, filter for value transformations. Don't use a filter for side effects or an action for "let me modify this".
- Prefix every custom hook with the plugin slug. Pick
slash/style or underscore_style, stay consistent.
- Docblock every hook —
Fires / Filters opening, @since, every @param typed and described, @return for filters.
- Filters MUST preserve type. A filter receiving
string returns string (or you've designed it badly). Listeners who break the type get to fix their callbacks; you don't change the contract on them.
- Parameter contract is your API. No reordering, no type changes, no removals in non-major releases.
- Deprecate via
apply_filters_deprecated / do_action_deprecated, never silent-delete a public hook.
- Bundle 5+ args into an array instead of growing the parameter list.
Common mistakes
$result = do_action( 'myplugin/transform', $value );
add_filter( 'myplugin/response_message', function ( $message ) {
error_log( $message );
} );
do_action( 'before_save', $data );
apply_filters( 'myplugin/response_message', $msg, $form_id );
apply_filters( 'myplugin/response_message', $msg, $context, $form_id );
apply_filters( 'myplugin/response_message', $msg, $form_id, $context );
$message = apply_filters_deprecated(
'myplugin/old_response',
array( $message, $form_id ),
'1.5.0',
'myplugin/response_message'
);
do_action( 'myplugin/before_request', $payload );
Cross-references
- Run
wp-plugin-architecture — the hook-naming convention is part of broader plugin architecture. Schema/Constants centralization includes hook names for discoverability.
- Run
wp-i18n-audit if any of your hooks pass translatable strings as args — translation timing rules apply to the values, not the hook names.
- Run
wp-security-audit when your hook callback receives user input that downstream listeners will trust — document the sanitization expectation in the @param line.
What this skill does NOT cover
- Using core WP hooks (
init, the_content, save_post, etc.) — every WP plugin does this; not a custom-hook design topic.
- Hook priority gymnastics (
add_action( $hook, $cb, $priority )) — basic WP, not a design topic for the plugin emitter.
- Removing core WP behavior via
remove_filter / remove_action — adjacent topic, separate skill.
- Internal-only "hooks" used as a poor-man's event bus inside a single plugin (use proper service classes / observers instead — see
wp-plugin-architecture).
- The
'all' meta-hook (a debugging tool, not a design pattern).
References