Skip to content

Static analysis for WordPress hooks. Detect orphaned listeners, unheard hooks, and typos in actions and filters without running WordPress. Faster, safer WP development.

License

Notifications You must be signed in to change notification settings

malikad778/wp-hook-check

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

10 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

wp-hook-check

Static analysis for WordPress hooks. Find broken hook connections before they reach production.

Tests Latest Version PHP Version License: MIT

Demo


The problem

WordPress hooks are silent. When add_action('my_hook', $cb) exists but do_action('my_hook') was renamed or removed, nothing throws. The callback just stops running. You find out in production when a feature breaks.

// v1 - fires a hook before checkout
do_action( 'my_plugin_before_checkout', $order_id );

// Another plugin listens for it
add_action( 'my_plugin_before_checkout', 'apply_discount' );

// v2 - renamed without announcement
do_action( 'my_plugin_checkout_start', $order_id );
// apply_discount() never runs again. No error, no warning.

This tool parses every PHP file in a directory, maps all hook registrations and invocations, and reports mismatches. No WordPress install needed.


Install

Package (Composer)

composer require --dev malikad778/wp-hook-check

PHP 8.2+.

Global (WP-CLI)

Install globally via WP-CLI to scan any site:

wp package install malikad778/wp-hook-check

Usage

As a standalone script

# Scan current directory
vendor/bin/wp-hook-audit audit .

Via WP-CLI

# Scan a specific plugin
wp hook-check ./wp-content/plugins/my-plugin

Scan a plugin

vendor/bin/wp-hook-audit audit ./wp-content/plugins/my-plugin

Scan all plugins at once (hooks are cross-referenced across all files)

vendor/bin/wp-hook-audit audit ./wp-content/plugins


---

## What gets flagged

| Type | Severity | When |
|---|---|---|
| `ORPHANED_LISTENER` | πŸ”΄ HIGH | `add_action/filter` exists, no matching `do_action/apply_filters` found |
| `UNHEARD_HOOK` | 🟑 MEDIUM | `do_action/apply_filters` fired, no listener registered anywhere |
| `HOOK_NAME_TYPO` | πŸ”΄ HIGH | Hook name differs from another by 1–2 characters |
| `DYNAMIC_HOOK` | πŸ”΅ INFO | Hook name is a variable - can't be resolved, skipped by other detectors |

---

## Output formats

### Table (default)

WP HOOK AUDITOR Scanned 47 files in 0.091s ──────────────────────────────────────────────────────────

[HIGH] ORPHANED_LISTENER File : includes/class-checkout.php:234 Hook : my_plugin_before_checkout

add_action('my_plugin_before_checkout') registered (callback: apply_discount)

  • no matching do_action() or apply_filters() found.

Fix: Either remove the add_action() call or add do_action('my_plugin_before_checkout') where it should fire.

────────────────────────────────────────────────────────── SUMMARY 1 HIGH 0 MEDIUM 0 INFO


### JSON (`--format=json`)

```json
{
  "meta": {
    "files_scanned": 47,
    "duration_sec": 0.091,
    "issue_count": 1
  },
  "issues": [
    {
      "type": "orphaned_listener",
      "severity": "high",
      "hook": "my_plugin_before_checkout",
      "file": "includes/class-checkout.php",
      "line": 234,
      "message": "...",
      "safe_alternative": "...",
      "suggestion": null
    }
  ]
}

GitHub Annotations (--format=github)

::error file=includes/class-checkout.php,line=234,title=ORPHANED_LISTENER::...
::warning file=...,title=UNHEARD_HOOK::...
::notice file=...,title=DYNAMIC_HOOK::...

Issues show up as inline annotations on the exact lines in GitHub pull requests.


CLI options

audit / wp hook-check

vendor/bin/wp-hook-audit audit [path] [options]
# OR
wp hook-check [path] [options]
Option Default Description
--format table table, json, or github
--fail-on high Exit 1 if issues at this level exist: high, medium, any, none
--exclude - Comma-separated paths to skip
--ignore-dynamic - Hide INFO dynamic hook notices
--only all Run only these detectors: orphaned, unheard, typo, dynamic
--config wp-hook-audit.json Path to config file

dump

vendor/bin/wp-hook-audit dump [path] [--format=table|json]

Dumps the full hook map - every add_action, do_action, add_filter, apply_filters call, with file, line, and priority. No detectors run. Good for exploring an unfamiliar codebase. (Not currently supported via WP-CLI).


Exit codes

Code Meaning
0 Clean (no issues above threshold)
1 Issues found at or above --fail-on level
2 Parse error, unreadable file, or bad config

Config file

Drop a wp-hook-audit.json in the directory you're scanning, or point to one with --config:

{
    "exclude": ["vendor/", "tests/", "node_modules/"],
    "detectors": {
        "orphaned_listener": true,
        "unheard_hook": true,
        "typo": true,
        "dynamic_hook": false
    },
    "ignore": [
        { "type": "unheard_hook", "hook": "my_plugin_extensibility_point" }
    ],
    "external_prefixes": [
        "wp_", "admin_", "woocommerce_", "init", "shutdown"
    ]
}

external_prefixes

WordPress core fires hundreds of hooks (init, plugins_loaded, save_post, etc.) that live inside WordPress itself, not your plugin. Without this setting, every add_action('init', ...) flags as ORPHANED_LISTENER because the matching do_action('init') is in WordPress core - outside the folder you're scanning.

The defaults already cover 40+ common WP core patterns. Add your own plugin's extensibility hooks here too if you're getting false positives from a third-party plugin you depend on.

See wp-hook-audit.json.example for the full default list.


CI/CD

GitHub Actions

name: Hook Audit
on: [pull_request]

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.2' }
      - run: composer require --dev malikad778/wp-hook-check
      - run: vendor/bin/wp-hook-audit audit . --format=github --fail-on=high

GitLab CI

hook_audit:
  script:
    - composer install
    - vendor/bin/wp-hook-audit audit . --format=json > hook-report.json
  artifacts:
    paths: [hook-report.json]

How it works

Parses PHP files into an AST using nikic/php-parser, walks every function call node, and records any of the 10 tracked WordPress functions. Hook names are extracted from the first argument - string literals are captured as-is, variables and concatenations are marked dynamic and skipped by mismatch detectors. The result is a HookMap keyed by hook name, which the four detectors then query.

Parse errors are non-fatal. A file that fails to parse is skipped with a warning, scan continues.


Tracked functions

Function Role
add_action(), add_filter() Registration - checked for orphaned listeners
do_action(), apply_filters() Invocation - checked for missing listeners
do_action_ref_array(), apply_filters_ref_array() Invocation
remove_action(), remove_filter() Tracked, never flagged
has_action(), has_filter() Counts as invocation - stops false UNHEARD positives

Known gaps

  • Dynamic hook names (variables, string concatenation) are skipped by all mismatch detectors
  • Hooks registered inside conditionals are still tracked - may produce false positives if the condition never runs
  • Closures show as {closure} in the hook map output
  • Hooks from WordPress core or third-party plugins need their prefixes in external_prefixes

License

MIT - see LICENSE

About

Static analysis for WordPress hooks. Detect orphaned listeners, unheard hooks, and typos in actions and filters without running WordPress. Faster, safer WP development.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Languages