Free guide

WordPress PHP 8 upgrade checklist

Switching the PHP version is one click at your host. The work is knowing what breaks when you do. These are the nine decisions that work covers, with a test for each one so you can tell when it is genuinely settled instead of merely hoped for.

WordPress itself has run on PHP 8 for years. Your plugins and theme are the question. The risk is concentrated in a specific place: code nobody updates — a custom theme, a client-specific plugin, a premium plugin whose licence lapsed, a snippet pasted into functions.php in 2016. Everything with an active WordPress.org release gets fixed by updating it. Everything else is yours.

That is also why the order below matters. Most of these decisions are about your own code, and the last one is about the upgrade itself. Each item names what to record and a done when line — a specific, checkable condition. If you cannot meet the done when test, that item is an open risk, not a completed step.

Check your own versions against the source of truth before planning against them: php.net supported versions for what is still receiving security fixes, and wordpress.org requirements for what WordPress currently requires and recommends. Both change on a schedule, and this page is not a substitute for reading them.

1. Know what is installed and who owns it

An upgrade plan that lists "the plugins" is not a plan. What decides your risk is who will fix each piece of code when PHP 8 breaks it, and that splits the install into two very different piles.

Maintained by someone else. Anything with a current release on WordPress.org or from a vendor with an active licence. The fix is to update it before you change PHP, not to read it. If the upgrade breaks it anyway, the vendor owes you a patch.

Maintained by you, whether or not you knew that. Custom plugins, a child theme, a parent theme bought once and never updated, a plugin installed from a ZIP file, a plugin whose WordPress.org page says it was last updated years ago, and functions.php. This pile is the entire scope of decisions 2 through 8.

Record, per plugin and theme: name, version, where it came from, whether an update is available, the date of its last release, whether a support licence is current, and which pile it is in. Note anything active separately — a deactivated plugin cannot break your site, and a mu-plugin cannot be deactivated at all.

Done when: every active plugin and theme sits in one of the two piles with a name against it, and the "yours" pile is a list short enough to read line by line. That list, not the plugin count, is the size of the job.

Our free WordPress PHP 8 upgrade scanner does the code half of this for you, and files every finding under the decision on this page it belongs to. It deliberately skips core and anything carrying a WordPress.org readme, for the reason above: an update replaces those. It runs as a one-file Node script, or inside your editor as the free VS Code extension WordPress PHP 8 Upgrade Inventory.

2. Remove PHP that no longer exists

This is the category that takes a site down rather than filling a log. The code no longer runs at all, and in the worst case the file does not even parse, which takes every function in it with it.

The recurring offenders in old WordPress code, each with the version that removed it:

  • create_function() — removed in PHP 8.0. Extremely common in old plugins as a one-line add_action() or add_filter() callback. Becomes a closure.
  • each() — removed in PHP 8.0, usually inside a while (list(, $v) = each($arr)) loop. Becomes foreach.
  • PHP 4 style constructors — a method named after its class. Removed in PHP 8.0; the method silently stops being a constructor, so the object is built without ever being initialised. Becomes __construct().
  • $string{0}, the curly-brace string offset — removed in PHP 8.0, and a parse error, not a runtime one. Becomes $string[0].
  • The original mysql_* extension — removed in PHP 7.0. Any plugin still calling it never ran on PHP 7. Becomes $wpdb.
  • __autoload(), POSIX regex (ereg*, split()), mcrypt_*, get_magic_quotes_gpc(), (real) and (unset) casts, $HTTP_POST_VARS and friends, parse_str() with one argument, and assert() on a string.

Record, per hit: file, line, which construct, and the replacement. Group by file — one neglected plugin usually supplies a dozen hits, and the decision is often "replace this plugin", not "patch twelve lines".

Done when: this list is empty for every plugin and theme in the "yours" pile, or the ones still on it are scheduled for replacement rather than repair. A single curly-brace offset left in a theme file is enough to produce a white screen.

3. Fix what PHP deprecated but still runs

These do not break the site today. They write a deprecation notice on every request, which costs you three things: log volume that hides real errors, notices leaking into output where a theme prints before headers, and a guaranteed future break on a PHP version you have not chosen yet.

The ones that turn up most in WordPress code:

  • strftime() and gmstrftime() — deprecated in PHP 8.1. Common in date formatting written before date_i18n() was well known.
  • utf8_encode() and utf8_decode() — deprecated in PHP 8.2, and almost always the wrong tool anyway: they convert to and from Latin-1, not "to UTF-8".
  • An optional parameter before a required one in a function signature — deprecated in PHP 8.0. Silent for years, then noisy.
  • Implicitly nullable parameter types (function f(string $s = null)) — deprecated in PHP 8.4. Becomes ?string $s = null.
  • "${var}" string interpolation — deprecated in PHP 8.2. Becomes "{$var}".
  • FILTER_SANITIZE_STRING — deprecated in PHP 8.1, and never did what its name implies. Replace with a real escaping or validation call, chosen for the context.
  • E_STRICT, strptime(), date_sunrise() and date_sunset().

Decide, and write down, one policy: fix all of them now, or fix the ones on hot code paths and accept the log noise from the rest. Either is defensible. What is not defensible is not knowing which you chose, because then the log tells you nothing.

Record: the count per plugin, the policy, and the deadline by which the remainder gets fixed — the PHP version that removes it, not "later".

Done when: you can load your ten busiest pages on the target PHP version with deprecation logging on, and account for every line that appears. "Account for" means each one is either fixed or on the accepted list.

4. Replace deprecated WordPress APIs

Deprecated WordPress functions are a separate problem from deprecated PHP, and they are the ones a PHP upgrade tempts you to ignore, because they are not what broke. Do them in the same pass anyway: you are already in the file, and WordPress deprecations are removed far less predictably than PHP's.

The families to look for: pre-2.8 escaping helpers (attribute_escape(), js_escape(), clean_url(), wp_specialchars()), user functions replaced by WP_User and wp_insert_user(), get_settings(), the old admin-menu and screen helpers, get_page(), wp_get_sites(), wp_get_http(), and $wpdb->escape(). In themes, wp_title() in a theme that declares title-tag support is a real conflict, not just a deprecation.

Turn the notices on while you work, in a staging environment only: WP_DEBUG and WP_DEBUG_LOG on, WP_DEBUG_DISPLAY off. Deprecated calls are reported by WordPress itself, with the replacement named in the message.

Record, per hit: file, line, the deprecated function, its documented replacement, and whether the replacement changes behaviour — several of these escape differently rather than identically, and a blind swap can double-escape output.

Done when: a full pass through the site's main templates and admin screens with debug logging on produces no _deprecated_function or _deprecated_argument entries from code in the "yours" pile.

5. Make database access prepared and portable

Direct SQL is legitimate in WordPress, and $wpdb exists for it. Two habits around it are not, and an upgrade is when you find them, because they sit in the same neglected files as everything else on this list.

Unprepared queries. A variable interpolated straight into SQL is an injection risk whatever the PHP version. Use $wpdb->prepare() with placeholders. Note that prepare() does not accept a placeholder for a table or column name — if those are dynamic, they need an allow-list, not escaping.

Hard-coded table prefixes. wp_posts written as a literal breaks on any install that chose a different prefix, and silently reads the wrong site's data in multisite. Use $wpdb->posts, $wpdb->prefix, and $wpdb->get_blog_prefix().

While you are here, check the database engine too. Your host's PHP upgrade and its MySQL or MariaDB upgrade are often the same maintenance window, and utf8 versus utf8mb4 collations, plus strict-mode differences, cause failures that look nothing like PHP errors.

Record, per query: file, line, whether it is prepared, whether the prefix is dynamic, and — for anything writing data — whether it is reachable by an unauthenticated request.

Done when: every query in the "yours" pile either takes no variables at all or goes through prepare(), and no file contains a literal wp_ table name.

6. Stop bypassing WordPress for HTTP, files, and paths

Old plugin code frequently reaches past WordPress to the operating system: curl_init() instead of wp_remote_get(), file_get_contents() on a URL, fopen() and unlink() instead of the filesystem API, a path built with ABSPATH . 'wp-content/...', session_start() for state, or eval().

Each of these works until the environment changes, and a PHP upgrade is an environment change. The WordPress replacements are not stylistic: wp_remote_get() honours proxies, timeouts and filters; wp_upload_dir() and content_url() survive a moved wp-content; WP_Filesystem works where PHP cannot write directly; and PHP sessions do not survive most caching and load-balancing setups. eval() has no replacement because it has no legitimate use in a plugin.

Record: every external call and filesystem write in the "yours" pile, with its replacement, plus any hard-coded absolute path. Flag anything on a request path a visitor can trigger — an unbounded outbound HTTP call in a page render is a hang waiting for a slow third party.

Done when: nothing in the "yours" pile makes a network or filesystem call outside the WordPress APIs, or the exceptions are written down with a reason. Any eval() is gone, not documented.

7. Escape output and guard direct file access

The two cheapest security defects to find in old WordPress code, and both are one line each to fix.

Unescaped output. A superglobal or an option echoed straight into a page is cross-site scripting. Pick the escaping function by where the value lands, not by habit: esc_html() in text, esc_attr() in an attribute, esc_url() in href or src, esc_js() inside script, wp_kses_post() when limited markup is genuinely required.

No ABSPATH guard. A PHP file in a plugin or theme directory with no if (!defined('ABSPATH')) exit; at the top can be requested directly by URL and will run outside WordPress, with none of its functions loaded and none of its access control applied.

These are on a PHP upgrade checklist for a practical reason rather than a moral one: the files that are missing them are exactly the unmaintained files you are already editing for decisions 2 and 3, and you will not open them again for a long time.

Record: every echoed value with no escaping function, with the context that determines which one it needs; and every plugin or theme PHP file with no direct-access guard.

Done when: every file in the "yours" pile has a guard, and every dynamic value printed by it passes through an escaping function chosen for its context.

8. Decide the jQuery and front-end upgrade

This one is not PHP at all, and it is on the list because it breaks at the same moment and gets blamed on the PHP upgrade. WordPress ships a jQuery version with core; jQuery 3.0 removed methods that jQuery 1.x supported, and old admin and theme scripts use them.

The removed methods that appear most: .live() and .die() (use .on()), .size() (use .length), .andSelf() (use .addBack()), jQuery.browser, .load(), .unload() and .error() as event shorthands, plus .bind() and .delegate(), which still exist but are deprecated.

A failing script fails silently for visitors and loudly in the browser console, which is why this is found after launch rather than during it. Check the console on the pages that matter, logged in and logged out, and check the admin screens your editors use.

Record: the jQuery calls that no longer exist, with the file and the replacement, and whether the script is enqueued on the front end, the admin, or both. Note anything depending on jquery-migrate, which is a diagnostic aid rather than a fix.

Done when: the browser console is clean on your key front-end templates and the admin screens your team uses, in both logged-in and logged-out states.

9. Set the target versions and rehearse the upgrade

Everything above is preparation. This is the decision that makes it an upgrade.

Name one target PHP version and write down why. "The newest one" and "whatever the host defaults to" are both how a site ends up on a version nobody chose. Check the version you pick against php.net's supported versions — a version already out of security support is not a destination — and against what your plugins and theme state they support.

Rehearse on a copy. A staging or local copy of the real site, with the real plugin set and real content, switched to the target version. Not a clean install, which shares none of your problems. Then walk the paths that generate the errors people notice: a page render, a search, a form submission, the checkout if there is one, a scheduled task, an editor saving a post, a media upload, and any REST or admin-ajax endpoint your own code registers. WP-CLI on the target version is a fast way to surface fatal errors in code that only runs in cron.

Plan the rollback before the window, not during it. Most hosts can switch the PHP version back in one click — confirm that yours can, and confirm it while you are calm. Know what the fallback is for the plugin most likely to break.

Record: the target version and the reason, the date of the last rehearsal, the list of paths exercised with a pass or fail against each, the errors found and their status, the rollback steps, and who will be watching the logs for the first day afterwards.

Done when: the full path list has been exercised on the target version on a copy of the real site with no unexplained errors, deprecation logging is on and accounted for, and the rollback step has been tested rather than assumed.

Find most of this automatically

Decisions 2 through 8 are all pattern-matching over code you did not write, which is a job for a tool. Our free WordPress PHP 8 upgrade scanner checks 53 rules across these nine decisions, each one naming the PHP or WordPress version that removed or deprecated what it matches, and reports a file, line, and column for every hit.

It is one Node file with no dependencies, it makes no network calls, it never writes to your codebase, and it never opens wp-config.php. Or install it in your editor and read the findings in the Problems panel, one click from the code.

Get the free scanner

Free, MIT licensed, no signup. A clean report is not a guarantee: a rule-based scanner cannot see dynamic calls, and decisions 1 and 9 are yours to do by hand. There is no paid WordPress product here — the workbook this site sells is for Drupal 7 migrations, and it will not help you with PHP 8.