andreas-andersson / wp-specter
CLI tool to find unused code in WordPress projects
Requires
- php: ^8.4
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-08-15 12:12:26 UTC
README
Has your WordPress project grown over the years? Or did you inherit one — thousands of lines of PHP, and nobody left who can tell you what's still actually used?
Deleting code from a WordPress project is scary, because so little of it is called
the way normal PHP is called. A template file isn't included anywhere — WordPress
picks it up from the template hierarchy. A function isn't called by name — it's
hooked to an action by string. So generic dead-code tools either flag half your theme
as unused, or shrug and tell you nothing.
WP Specter is a static analyser that knows those conventions. Point it at a theme, a plugin, or a whole Bedrock project and it reports what's genuinely orphaned:
- Unused functions and classes — defined, never referenced
- Unused methods — resolved per class, not just by name
- Unmatched hooks —
add_action/add_filterfor a tag nothing ever fires - Unused templates — template parts nothing loads, with the template hierarchy
and
block.jsonrender fields accounted for - Orphaned files — PHP that is never included, required or referenced
Example output:
wp-specter — WordPress unused code scanner
Path: /home/user/dev/my-site/wp-content/themes/mytheme/
Mode: Classic theme
Files: 247 PHP files scanned
Unused Functions
✗ user_is_temporarly_banned
/home/user/dev/my-site/wp-content/themes/mytheme/functions.php:524
✗ custom_loginpage_with_return_url
/home/user/dev/my-site/wp-content/themes/mytheme/functions.php:532
✗ acf_location_rules_match_user
/home/user/dev/my-site/wp-content/themes/mytheme/functions.php:647
Unmatched Hooks
⚠ wsl_hook_process_login_before_wp_safe_redirect // not fired within scanned directory
/home/user/dev/my-site/wp-content/themes/mytheme/functions.php:2006
Unused Files
⚠ acf-blocks/featured-pages // not included, required, or referenced anywhere in scanned directory
/home/user/dev/my-site/wp-content/themes/mytheme/acf-blocks/featured-pages.php:1
⚠ acf-blocks/featured-posts // not included, required, or referenced anywhere in scanned directory
/home/user/dev/my-site/wp-content/themes/mytheme/acf-blocks/featured-posts.php:1
⚠ page-templates/sections/parts/feedback-form // not included, required, or referenced anywhere in scanned directory
/home/user/dev/my-site/wp-content/themes/mytheme/page-templates/sections/parts/feedback-form.php:1
Unused Classes
✗ WP_Page_List_Navwalker
/home/user/dev/my-site/wp-content/themes/mytheme/includes/wp_page_list_navwalker.php:3
Unused Methods
⚠ start_lvl
/home/user/dev/my-site/wp-content/themes/mytheme/includes/wp_page_list_navwalker.php:5
⚠ start_el
/home/user/dev/my-site/wp-content/themes/mytheme/includes/wp_page_list_navwalker.php:10
Found: 3 unused function(s), 1 unmatched hook(s), 3 unused file(s), 1 unused class(es), 2 unused method(s)
Contents
- Contents
- Requirements
- Installation
- Usage
- What it checks
- Supported theme types
- Project structure
- Running tests
- License
Requirements
- PHP 8.4 or higher
- Composer
Installation
wp-specter is published on Packagist.
# As a project dev-dependency composer require --dev andreas-andersson/wp-specter # Globally composer global require andreas-andersson/wp-specter
Make sure Composer's global bin dir is on your PATH (composer global config bin-dir --absolute prints it — usually ~/.config/composer/vendor/bin or ~/.composer/vendor/bin).
From source (contributing to wp-specter itself)
git clone https://github.com/andreas-andersson/wp-specter.git
cd wp-specter
composer install
php bin/wp-specter scan ...
Usage
Scan a theme or plugin
wp-specter scan <path> [options]
Point it at a theme or plugin directory. The tool auto-detects whether it is a classic theme, block (FSE) theme, hybrid theme, or plugin based on the files present (style.css, theme.json, Plugin Name: header, etc.).
# Auto-detect target type wp-specter scan ./themes/my-theme # Explicitly declare what you are scanning wp-specter scan ./themes/my-theme --target=theme wp-specter scan ./plugins/my-plugin --target=plugin
Scan a composer-managed project (Bedrock and similar)
Point scan at the project root (or any ancestor of it) instead of a single theme/plugin, and it
auto-detects the rest — same command, no extra flags needed:
wp-specter scan ./my-bedrock-site
If the given path isn't itself a theme or plugin, wp-specter looks for a composer.json above
it. When found, it reads extra.installer-paths to discover every theme/plugin/mu-plugin
directory the project declares, then cross-checks vendor/composer/installed.json to exclude
whatever composer actually installed there (a wpackagist theme, a purchased plugin pulled in via
a private repo, etc.) — only your own custom code gets scanned. Hooks and functions are matched
across all discovered targets together, so a theme registering a hook that its companion plugin
fires isn't reported as unmatched.
Scan options
| Option | Description |
|---|---|
--target=theme|plugin |
Declare the target type (default: auto-detect) |
--type=<types> |
Comma-separated list of checks to run: functions, hooks, templates, files, classes (default: all) |
--stubs=<file> |
JSON stubs file of known hooks to suppress (see generate-stubs) |
--ignore=<globs> |
Comma-separated glob patterns to exclude from scanning |
--verbose |
Show extra detail alongside findings |
--no-color |
Disable ANSI colour output |
--generate-config |
Write resolved scan targets to .wp-specter.config.json and exit (see Project config) |
--generate-baseline |
Save the current findings as suppressions in .wp-specter.config.json and exit (see Baselining existing findings) |
--no-vendor-reflection |
Don't load the scanned project's vendor/autoload.php for the class-contract reflection fallback (see Unused classes) |
Run only specific checks
# Functions only wp-specter scan ./themes/my-theme --type=functions # Hooks and templates, no functions wp-specter scan ./themes/my-theme --type=hooks,templates
The files check flags PHP files that are never include/require'd and never referenced via get_template_part(), page-template registration, or block.json. It covers support directories (inc/, classes/, admin/, etc.) — root-level template-hierarchy files and files under templates/, template-parts/, or parts/ are covered by the templates check instead, so they're skipped here to avoid duplicate findings. Files matching Template Name: (WP Page Templates) and any index.php (directory-listing blockers / sibling autoloaders) are always exempt.
Exit codes
| Code | Meaning |
|---|---|
0 |
No issues found |
1 |
One or more findings |
2 |
Fatal error (bad path, unreadable file, etc.) |
This makes it straightforward to integrate into CI pipelines.
generate-stubs
Scans a directory for all do_action / apply_filters calls and writes the discovered hook names to a JSON file. Use this to suppress false positives when hooks are fired by plugins outside your scanned theme.
wp-specter generate-stubs <path> [--output=<file>]
# Scan your plugins folder and write a stubs file wp-specter generate-stubs ./plugins # ..or set a custom output file wp-specter generate-stubs ./plugins --output=whatever.json # The stubs file will be auto-loaded on your next scan if using the default filename wp-specter scan ./themes/my-theme # ..if using a custom stub-filename you need to pass it with wp-specter scan ./themes/my-theme --stubs=whatever.json
The generated file looks like:
{
"generated": "2026-08-07",
"source": "web/app/plugins",
"hooks": [
"my_plugin_action",
"my_plugin_filter"
],
"prefixes": [
"acf/settings/"
]
}
Some plugins fire a whole family of hooks through one dynamic call site instead of a literal do_action/apply_filters per hook — ACF is a good example: every acf/settings/* filter (save_json, load_json, enable_datastore, ...) routes through a single apply_filters( "acf/settings/{$name}", $value ). No individual tag ever appears as a literal string anywhere in ACF's source, so it can never land in hooks, no matter how thoroughly that source is scanned. generate-stubs detects this shape — an interpolated string or a 'literal' . $var concatenation — and records the resolvable leading segment under prefixes instead. scan treats a prefixes match the same as an exact hooks match: add_filter('acf/settings/save_json', ...) is suppressed because acf/settings/ was seen, not because acf/settings/save_json itself was. The same dynamic-prefix matching also applies within a single scan directly — a project firing its own apply_filters("myplugin/{$type}", ...) suppresses matching registrations without needing a stubs file at all.
You can commit this file alongside your project and update it whenever plugins change.
Project config: .wp-specter.config.json
For projects where auto-detection isn't enough — or you just don't want to type the same paths
every time — drop a .wp-specter.config.json anywhere at or above the path you scan from
(discovered by walking upward, same as composer.json):
{
"targets": ["web/app/themes/sage", "web/app/plugins/my-plugin"],
"stubsFrom": ["web/app/plugins", "web/app/mu-plugins"],
"stubs": ".wp-specter.stubs.json",
"exclude": ["tests", "vendor"]
}
You can write this file by hand, or let wp-specter generate the targets list for you by running
--generate-config from your project root — auto-detection runs as normal (composer discovery
included) and the resolved targets are written out instead of scanned:
# From the project root, not from inside the scanned theme/plugin directory
wp-specter scan --generate-config
All paths are resolved relative to the config file's own directory.
targets— exactly which theme/plugin directories to scan. When present, this replaces auto-detection (including composer discovery) entirely:wp-specter scan(orwp-specter scan <project-root>) scans exactly this list, nothing more, nothing less. Pointingscandirectly at one specific directory still scans just that one directory, config or not —targetsonly takes over when you scan the project as a whole.stubsFrom— directorieswp-specter generate-stubsscans when run with no path argument. Handy when your hook-firing code lives in more than one place (e.g. bothplugins/and a separately-managedextras/plugins/) — onewp-specter generate-stubspicks up all of them instead of one invocation per directory.stubs— overrides where the project stubs file lives (see below). Defaults to.wp-specter.stubs.jsonnext to the config file if omitted.exclude— directory names (or relative paths, e.g."tests/fixtures") pruned from every scan and everygenerate-stubsrun, on top of the always-onvendor/node_modules/.gitdefaults. Unliketargets/stubsFrom, entries aren't resolved to absolute paths — a bare name like"tests"matches a directory with that name anywhere under any scanned target, not just one anchored at the config file's own location. Useful for a plugin project scanned from its own root, where the plugin'stests/directory would otherwise be scanned for unused code alongside the plugin itself.baseline— findings to suppress on every future scan, written by--generate-baseline(see below) rather than edited by hand. Each entry'stypeis one ofunused_function,unmatched_hook,unused_template,unused_file,unused_class,unused_method.
Baselining existing findings
Adopting wp-specter on an established project can turn up a pile of pre-existing findings you
don't want to fix right now. --generate-baseline snapshots the current findings into
.wp-specter.config.json so they're suppressed on every future scan, leaving only new findings to
fail CI. It requires --generate-config to have been run first — --generate-baseline writes
into the same .wp-specter.config.json, it doesn't create one:
# From the project root, once .wp-specter.config.json exists (see above) wp-specter scan --generate-config # first time only wp-specter scan --generate-baseline
Each suppressed finding is recorded under baseline as its type, name, and file path (relative to
the config file, no line number — an unrelated edit above the flagged spot shouldn't silently
break the match):
{
"baseline": [
{ "type": "unused_function", "name": "old_helper", "file": "inc/helpers.php" }
]
}
Commit this alongside your project. As you clean up baselined findings, re-run
--generate-baseline to shrink the list — there's no way to remove individual entries other than
fixing the finding and regenerating.
Full example
Every option together — targets and baseline are normally machine-written (by
--generate-config and --generate-baseline respectively) rather than typed by hand, but this is
everything .wp-specter.config.json supports:
{
"targets": ["web/app/themes/sage", "web/app/plugins/my-plugin"],
"stubsFrom": ["web/app/plugins", "web/app/mu-plugins"],
"stubs": ".wp-specter.stubs.json",
"exclude": ["tests", "vendor"],
"baseline": [
{ "type": "unused_function", "name": "old_helper", "file": "inc/helpers.php" },
{ "type": "unmatched_hook", "name": "legacy/deprecated_hook", "file": "inc/hooks.php" }
]
}
Project stubs: .wp-specter.stubs.json
Same file generate-stubs writes, but placed at (or above) the path you scan from, scan loads
it automatically — no --stubs= flag needed. An explicit --stubs=<file> still works and is
additive on top of the auto-loaded one (both only ever suppress findings, so loading both is
always safe).
# Once, from the project root (or anywhere with stubsFrom configured): wp-specter generate-stubs # → writes .wp-specter.stubs.json # Every scan after that picks it up with no extra flags: wp-specter scan web/app/themes/sage
What it checks
Unused functions
PHP functions defined anywhere in the scanned directory that are never called within the same directory. Class methods are excluded — only standalone functions are checked.
Functions with common WordPress prefixes (wp_, get_, the_, is_, has_, do_, apply_) and magic methods (__construct, __get, etc.) are automatically skipped.
Unmatched hooks
add_action / add_filter registrations whose hook tag is never fired by do_action / apply_filters within the scanned directory.
WordPress core hooks (~400 known actions and filters) are silently ignored via a built-in stub list — core is never part of what you scan, so there's no other way to know about them.
Third-party plugin hooks (ACF, ElasticPress, WooCommerce, etc.) aren't hardcoded — if wp-specter can scan the plugin's actual code (composer project mode already does this for anything under your project's plugins/ directory), it sees the do_action/apply_filters calls directly and matches them for real. For plugins outside the scan (bundled/vendored elsewhere, or you're scanning just a theme), use generate-stubs against their source, or a stubsFrom entry in .wp-specter.config.json to keep it current automatically.
Dynamic hook tags with no resolvable literal prefix (a bare variable, a function call result) are skipped entirely. A dynamic tag that does have a resolvable prefix — "acf/settings/{$name}", 'acf/settings/' . $name — still contributes that prefix as a match for any registration in the same family; see generate-stubs.
Unused templates
Template files (in template-parts/, templates/, parts/) that are never referenced by get_template_part(), get_header(), get_footer(), get_sidebar(), or an include/require call within the scanned directory.
WordPress template hierarchy files (single.php, archive.php, page.php, etc.) and their custom variants (single-{post-type}.php, page-{slug}.php, archive-{cpt}.php) are automatically exempt since WordPress loads them directly based on URL routing.
In block/FSE themes, templates declared in block.json render fields are also treated as referenced.
Unused classes
Classes defined anywhere in the scanned directory that are never referenced — no new Foo(), Foo::method()/Foo::CONST/Foo::class, instanceof Foo, or extends/implements Foo anywhere in the scanned files.
A class only ever instantiated dynamically ($class = 'Foo'; new $class()) or referenced purely as a string (register_widget('Foo')) won't be recognized as used — this check only sees syntactic references, not string literals.
Unused methods
Class methods that are never called anywhere in the scanned directory.
Calls with a statically-known receiver are matched per class, not just by name: $this->method(), self::method(), parent::method(), static::method(), and Class::method() with a literal class name all resolve to a specific declaring class. So does the array-callback shape add_action/add_filter registrations usually take, as long as the receiver is one of those same resolvable forms — [$this, 'method'], [Class::class, 'method'], ['Class', 'method'], in both [...] and legacy array(...) syntax. Two unrelated classes that happen to share a method name (e.g. render()) no longer suppress each other's findings just because one of them is called this way.
$obj->method() on a plain variable also resolves, as long as that variable was directly assigned new ClassName(...) earlier in the same function/method — $service = new My_Service(); $service->render(); scopes to My_Service::render for the rest of that function's body. Reassigning the variable to anything else ($service = some_factory();) invalidates the tracked type rather than leaving it stale, and the tracking never leaks across function/method boundaries — a variable of the same name in a different function starts unknown. It's also not control-flow aware: if ($cond) { $x = new A(); } else { $x = new B(); } $x->method(); only tracks whichever assignment appears last in source order, not "could be either."
A variable whose type can't be determined this way — a constructor parameter, a property, a loop variable, the result of a function call — still falls back to a name-only match across the whole scanned directory. That's the remaining source of imprecision, and why findings are reported at Warning certainty (not Error): a method that looks unused might still be reachable through one of these unscoped paths.
Magic methods (__construct, __toString, etc.) are always excluded. Methods required by a handful of common contracts are excluded too, but only when the declaring class itself directly implements/extends that contract — not further up an inheritance chain:
- PHP SPL interfaces:
ArrayAccess,Iterator,IteratorAggregate,Countable,JsonSerializable,Serializable - WordPress base classes:
WP_Widget(widget/form/update),WP_REST_Controller(register_routes),Walker(start_lvl/end_lvl/start_el/end_el)
Supported theme types
| Mode | Detection |
|---|---|
| Classic theme | style.css with Theme Name: header |
| Block (FSE) theme | theme.json present, no functions.php |
| Hybrid theme | Both theme.json and functions.php present |
| Plugin | PHP file with Plugin Name: header |
Project structure
src/
Analyzer/ FunctionAnalyzer, HookAnalyzer, TemplateAnalyzer
Detector/ WpModeDetector
Enum/ WpMode
Finding/ Finding, FindingType, FindingCertainty
Parser/ PhpTokenParser and value objects
Reporter/ TerminalReporter
Scanner/ FileScanner, ScanResult
Stubs/ WpCoreHooks, StubRegistry
Application.php
Config.php
bin/
wp-specter
tests/
unit/
integration/
fixtures/
Running tests
composer test
License
MIT — see LICENSE.
