# Wp Plugin Hooks

> 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.

- Skill: `lonsdale201/wp-plugin-hooks` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lonsdale201/wp-plugin-hooks`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lonsdale201/wp-plugin-hooks/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Lonsdale201 (https://skillmd.com/u/lonsdale201)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/lonsdale201/wp-plugin-hooks

---


# 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:

```php
// ACTION — "the user just submitted the form"; downstream side effects
do_action( 'myplugin/form_submitted', $form_id, $submission );
// Listeners: send Slack notification, log to audit table, dispatch webhook.

// FILTER — "here's the response message; anyone want to override?"
$message = apply_filters( 'myplugin/response_message', $default_message, $form_id );
// Listeners: replace the message based on form_id, append "(internal)" prefix.
```

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.

```php
// Slash-separated - visually clear that this is plugin-namespaced
do_action( 'myplugin/before_request', $payload );
apply_filters( 'myplugin/api_response', $response, $request );

// Underscore-separated - matches WP core convention
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.

```php
/**
 * Fires before the AI request is sent.
 *
 * @since 1.2.0
 *
 * @param array  $payload The request payload.
 * @param string $context Reason for the request: 'verdict' or 'enrichment'.
 */
do_action( 'myplugin/before_request', $payload, $context );

/**
 * Filters the AI response message that will be shown to the user.
 *
 * @since 1.0.0
 * @since 1.3.0 Added the `$decision` parameter.
 *
 * @param string $message  The response message.
 * @param int    $form_id  ID of the form being processed.
 * @param bool   $decision The AI's TRUE/FALSE verdict.
 *
 * @return string Possibly modified message.
 */
$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.

```php
// Better — form_id is more often useful than the full submission array
apply_filters( 'myplugin/should_process', true, $form_id, $submission );

// Worse — listener has to accept submission they don't need to skip past
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:

```php
// 6-arg action — listeners must accept all six in order
do_action( 'myplugin/render', $template, $context, $vars, $depth, $strict, $cache_key );

// Better — single context array, listeners pick what they need
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:

```php
$args = array( $payload, $context, $extra );

// Spread variant — only when args are statically known
do_action( 'myplugin/event', $payload, $context, $extra );

// Array variant — args are an array dynamically
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`).

```php
// OLD HOOK (now deprecated): myplugin/old_response
// NEW HOOK: myplugin/response_message

// Step 1: emit BOTH hooks so existing listeners keep working.
$message = apply_filters( 'myplugin/response_message', $default, $form_id );

// Fire the deprecated hook with the same args; emits _deprecated_hook notice.
$message = apply_filters_deprecated(
    'myplugin/old_response',           // old hook name
    array( $message, $form_id ),       // args (as array)
    '1.5.0',                           // version when deprecated
    'myplugin/response_message',       // replacement
    'Use myplugin/response_message instead.' // optional message
);
```

For actions:

```php
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

```php
// WRONG — action used as a filter (return value lost)
$result = do_action( 'myplugin/transform', $value ); // do_action returns void

// WRONG — filter that doesn't return the value
add_filter( 'myplugin/response_message', function ( $message ) {
    error_log( $message );        // side effect
    // missing: return $message;
} );
// Other listeners receive the previous value or null; chain breaks.

// WRONG — bare hook name, collides with the world
do_action( 'before_save', $data );

// WRONG — adding a parameter in the middle, breaking existing listeners
// v1.0
apply_filters( 'myplugin/response_message', $msg, $form_id );
// v1.1
apply_filters( 'myplugin/response_message', $msg, $context, $form_id ); // BUG — breaks existing listeners

// RIGHT — append at the end, document with @since
apply_filters( 'myplugin/response_message', $msg, $form_id, $context );

// WRONG — silent removal
// (the hook just stops firing, listeners get no warning)

// RIGHT — deprecate first
$message = apply_filters_deprecated(
    'myplugin/old_response',
    array( $message, $form_id ),
    '1.5.0',
    'myplugin/response_message'
);

// WRONG — undocumented hook
do_action( 'myplugin/before_request', $payload );
// IDE / hooks search tools / AI assistants can't find or describe this hook.
```

## 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

- Plugins Hooks Handbook: [developer.wordpress.org/plugins/hooks/](https://developer.wordpress.org/plugins/hooks/)
- `apply_filters` / `do_action`: `wp-includes/plugin.php`
- `apply_filters_deprecated` / `do_action_deprecated`: `wp-includes/plugin.php` (since WP 4.6)
- `_deprecated_hook` (the underlying notice trigger): `wp-includes/functions.php`
- `did_action` / `did_filter`: `wp-includes/plugin.php` — useful in tests / debugging to assert a hook fired.
- Official documentation: <https://developer.wordpress.org/reference/functions/apply_filters/>
- Official documentation: <https://developer.wordpress.org/reference/functions/do_action/>
- Official documentation: <https://developer.wordpress.org/reference/functions/apply_filters_deprecated/>
- Official documentation: <https://developer.wordpress.org/reference/functions/do_action_deprecated/>

