wp-hooks/generator
Generate WordPress hook documentation from your PHP source code. This tool scans plugins, themes, and packages to produce structured JSON files of all actions and filters, powering searchable hook references and API docs.
wp-hooks/generator is a framework-agnostic Composer library that statically parses PHP source (via nikic/php-parser) and emits a JSON description of every do_action() / apply_filters() call it finds, enriched with the docblock above the call. It is the engine behind the official WordPress hooks reference, so its output shape is the de-facto standard for "documented hooks" in the WP ecosystem.
Install it as a dev dependency — you almost never want it in production:
composer require --dev wp-hooks/generator
First place to look: the WP_Hooks\Generator\Generator class. Everything is fluent configuration on that object, then a single terminal call. The second place is the wp-hooks/schema repo, which defines the JSON contract you'll be consuming; third is wp-hooks/wordpress-core-hooks, which is the canonical reference implementation of a generation pipeline.
Minimal first run — point it at a directory, write an artifact:
<?php
require __DIR__ . '/vendor/autoload.php';
use WP_Hooks\Generator\Generator;
$generator = new Generator();
$hooks = $generator
->withFiles( [ __DIR__ . '/src' ] ) // recursive scan
->withIgnoredFiles( [ __DIR__ . '/src/vendor' ] )
->generate();
file_put_contents(
__DIR__ . '/build/hooks.json',
json_encode( $hooks, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES )
);
The first genuinely useful use case is: "our plugin exposes 40 hooks and they're documented in three different READMEs, all stale." Generate hooks.json, commit it, and now integrators (and your own docs site) have one machine-readable source of truth.
Confirm the return type of generate() against the version you pin — older tags return an array of hook records, newer ones may hand back a collection object you serialize yourself.
Artisan command wrapper. The generator has zero Laravel coupling, so bind it thinly:
class GenerateHooksCommand extends Command
{
protected $signature = 'hooks:generate {--path=} {--out=build/hooks.json}';
public function handle(): int
{
$hooks = (new Generator())
->withFiles([ $this->option('path') ?: base_path('src') ])
->withIgnoredFiles([ base_path('vendor'), base_path('tests') ])
->generate();
File::ensureDirectoryExists(dirname($this->option('out')));
File::put($this->option('out'), json_encode($hooks, JSON_PRETTY_PRINT));
$this->info(sprintf('Wrote %d hooks.', count($hooks)));
return self::SUCCESS;
}
}
CI drift check. The highest-value pattern by far: regenerate in CI and fail if the artifact changed.
php artisan hooks:generate
git diff --exit-code build/hooks.json || \
(echo "Hooks changed — commit the regenerated build/hooks.json" && exit 1)
This forces every new apply_filters() to ship with a docblock, because an undocumented hook produces a visibly different JSON entry.
Docblock-first authoring. The generator reads the comment block immediately above the call, so make that the contract:
/**
* Filters the final order total before it is persisted.
*
* @since 1.4.0
*
* @param float $total The calculated total.
* @param int $order_id The order being processed.
*/
$total = apply_filters( 'acme/order_total', $total, $order_id );
For hooks that take an array, use the nested @param array $args { @type ... } form — the parser understands it and expands array keys into individual named arguments in the JSON.
Upgrade auditing. Because output is deterministic JSON, you can diff two versions of a third-party plugin to see what hooks it added, removed, or re-typed between releases. Useful before a client site upgrade.
Merged index. Concatenate your generated file with wp-hooks/wordpress-core-hooks' JSON to build a single searchable hooks reference (core + your product) behind an internal endpoint.
Cache by commit. For large monorepos, run generation in a queued job keyed on git rev-parse HEAD and store the JSON in cache/S3. Regeneration is fast per-file but scales linearly; don't do it on every request.
apply_filters( $name, ... ) and concatenated names like 'acme/' . $key cannot be resolved statically. The generator will skip or emit an unhelpful entry. If you need a hook documented, it must be a single quoted literal at the call site — wrap dynamic plumbing in one thin helper function that contains the literal.// comment, or an attribute between /** ... */ and the do_action() call detaches the docblock and you silently lose the description. Same for @param names that don't match the actual argument variable names — they'll be dropped from the args map without error.if ( is_admin() ) are reported even though they never fire in a front-end request. That's correct for documentation, wrong for runtime introspection — pair with Query Monitor or a $wp_filter dump when you actually need to know what fired.file field contains backslashes, producing noisy diffs. Normalize with str_replace('\\', '/', $file) before writing, or run generation on Linux CI only.nikic/php-parser major bumps have historically changed how nodes are traversed and therefore the JSON. Lock the dev dependency and regenerate deliberately, never as a floating ^ surprise in CI.@internal entries) in your own pipeline.How can I help you explore Laravel packages today?