Free tool · MIT licensed
WordPress PHP 8 upgrade scanner
Point it at a WordPress codebase. It reports what in your plugins and themes stops working on a modern PHP, what still runs but warns, and what works today and is worth fixing while you are in there — with a file, a line and a column for every hit. One file, no dependencies, no signup, no network access.
Or fetch and run it in one go:
curl -O https://practicalmodules.com/tools/wp-scan.mjs
node wp-scan.mjs /path/to/wordpress
It also takes a single plugin or theme directory, which is the quickest way to try it:
node wp-scan.mjs /path/to/wp-content/plugins/my-plugin
node wp-scan.mjs /path/to/wordpress --markdown > upgrade-inventory.md
node wp-scan.mjs /path/to/wordpress --json
node wp-scan.mjs --rules
Three severities, because "deprecated" covers two very different problems
A PHP upgrade inventory that lumps everything together is not a work plan. Findings are grouped by what actually happens when you upgrade:
- This stops working. The code no longer runs at all —
create_function(),each(), themysql_*extension,$string{0}, a PHP 4 style constructor, or a jQuery method removed in 3.0. A curly-brace string offset is a parse error, which takes the whole file with it. - This still runs, and warns. A deprecation notice today and a removal later:
strftime(),utf8_encode(), an optional parameter before a required one,$wpdb->escape(),wp_title()in a theme that declares title-tag support. - This works, and is a problem worth fixing now. Portability and security issues that an upgrade is the cheapest moment to deal with: a variable interpolated into SQL with no
prepare(), a hard-codedwp_table prefix,curl_init()instead of the HTTP API,session_start(),eval(), a superglobal echoed without escaping, a plugin file with noABSPATHguard.
Nine decisions, 53 rules, each citing the version that broke it
Inside each severity, findings are filed under one of nine decisions, so the output reads as a plan rather than a wall of grep hits. Every rule names the PHP or WordPress version that removed or deprecated the thing it matches, so you can check a finding against the upstream changelog instead of taking it on trust. --rules prints the whole table.
| Decision | Rules |
|---|---|
| 1. Know what is installed and who owns it | from file headers |
| 2. Remove PHP that no longer exists | 14 |
| 3. Fix what PHP deprecated but still runs | 9 |
| 4. Replace deprecated WordPress APIs | 9 |
| 5. Make database access prepared and portable | 3 |
| 6. Stop bypassing WordPress for HTTP, files, and paths | 9 |
| 7. Escape output and guard direct file access | 5 |
| 8. Decide the jQuery and front-end upgrade | 4 |
| 9. Set the target versions and rehearse the upgrade | from file headers |
Decisions 1 and 9 carry no rules on purpose. They are answered from the plugin and theme headers — the Requires PHP and Tested up to lines, or their absence — not from pattern matching, and the report says so rather than quietly dropping them.
It skips what an update replaces anyway
Counting a create_function() inside somebody else's plugin buries the lines that are actually yours, so the scanner does not count it.
- WordPress core —
wp-admin/,wp-includes/, and thewp-*.phpfiles at the root. Core is replaced wholesale by its next version. - Distributed plugins and themes — anything carrying a WordPress.org
readme.txtwith aStable tag:line. An update replaces those too. They are listed separately with theirRequires PHPandTested up toheaders, because those are a decision: whether the update exists and what it supports. wp-content/uploads,node_modules,vendor, and minified bundles.wp-config.phpis never opened. Nor is anything else that normally holds credentials. The report says the file exists and moves on.
What the output looks like
The top of a report on a small site — this one is the synthetic install in the project's own test suite:
WHAT IS HERE
3 components of your own (8 PHP files, 119 lines), 1 distributed
32 signals: 9 that stop working, 10 that raise deprecation notices,
13 portability or security problems
9 of 9 decisions have evidence behind them
3 core files skipped (wp-admin, wp-includes, wp-*.php)
2 files skipped inside distributed plugins and themes
1 file never opened (credentials): wp-config.php
YOUR CODE
Acme Legacy Tools [plugin] wp-content/plugins/acme-legacy
v1.4.2, no Requires PHP header
24 signals in 5 files
Acme Theme [theme] wp-content/themes/acme-theme
v2.0.1, requires PHP 7.4, tested up to WP 5.9
6 signals in 2 files
And a finding, with the line it matched printed underneath so you can judge it without opening the file:
THIS STOPS WORKING
Decision 2. Remove PHP that no longer exists
------------------------------------------------------------------
create_function() — 1 hit in 1 file
stops working · PHP 8.0 (deprecated 7.2)
Removed outright. Each call becomes a closure, and callbacks
registered this way stop firing.
wp-content/plugins/acme-legacy/acme-legacy.php:11:21
add_action( 'init', create_function( '', 'return true;' ) );
THIS WORKS, AND IS A PROBLEM WORTH FIXING NOW
Decision 5. Make database access prepared and portable
------------------------------------------------------------------
SQL with an interpolated variable and no prepare() — 1 hit in 1 file
works, but is a portability or security problem
· always wrong; $wpdb->prepare() exists for it
A variable dropped straight into the query string. This is the
classic WordPress SQL injection, and an upgrade is the cheapest
time to fix it.
wp-content/plugins/acme-legacy/includes/db.php:7:12
$rows = $wpdb->get_results( "SELECT * FROM wp_posts
WHERE post_status = '$status'" );
Add --markdown for a version you can paste straight into a ticket, or --json for the full structured report.
What it does not do
- It does not parse PHP. This is regex-and-line matching with WordPress-shaped patterns. That buys speed, zero dependencies, and the ability to scan a codebase that does not even run — and it costs precision. Treat it as "here is where to look", not "here is the complete list".
- A clean report is not a guarantee. Deprecations that depend on runtime types are not text-detectable and are not reported: passing
nullto a non-nullable internal parameter (PHP 8.1), implicit float-to-int precision loss (8.1), dynamic property creation (8.2). No amount of pattern matching finds those. - It can be wrong about a line. The rules are tuned against false positives where that trade-off exists — the direct-access-guard check treats any mention of
ABSPATHas a guard, and theme template files are exempt from it entirely — which means it will sometimes stay quiet about something real. - It is not a substitute for running the code. It tells you where to look and how much there is. Rehearsing the upgrade on a copy is still the test.
- It never writes, and never touches the network. It reads the directory you give it and prints a report.
How it is tested
39 checks against a synthetic WordPress install written for the purpose. Beyond counting the expected findings, the suite asserts the exact set of rules allowed to fire, so a new rule that quietly matches fails the build; gives every regex alternative a sample line and checks it survives the hint prefilter first, which has already caught two alternatives that could never have fired; asserts that known false-positive shapes — wp_mail() against mail(), curl_exec() against exec(), maybe_unserialize() against unserialize() — do not match; and re-reads every reported file to confirm the text at the stated line and column really is what the rule matched.
If you are here for Drupal instead
The Drupal 7 migration inventory scanner is the same idea for a Drupal 7 codebase, and it has a free kickoff checklist and a migration workbook behind it.
There is no equivalent WordPress guide or workbook yet. This page ends at the free tool, and it is free, MIT licensed, and stands on its own.