skills/wp-php8-migration/SKILL.md
PHP 8.x migration guide for WordPress — covers PHP 8.0 through 8.3 features, breaking changes, backward compatibility patterns, dynamic properties fixes, and step-by-step migration strategy for themes, plugins, and custom code.
npx skillsauth add miranees/wp-php8-migration-claude-skill wp-php8-migrationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Complete reference for migrating WordPress themes, plugins, and custom code from PHP 7.4 to PHP 8.0, 8.1, 8.2, and 8.3. Covers new features, breaking changes, backward compatibility, and the most common migration patterns.
Use with caution in WordPress hook callbacks. WordPress core functions do not guarantee parameter name stability across versions.
// BEFORE (PHP 7.4)
wp_insert_post( array(
'post_title' => 'My Post',
'post_content' => 'Content here',
'post_status' => 'publish',
'post_type' => 'page',
) );
// AFTER (PHP 8.0) — named arguments in your OWN functions only
function register_custom_block( string $name, string $title, string $icon = 'smiley', string $category = 'widgets' ): void {
// ...
}
register_custom_block( name: 'my-block', title: 'My Block', category: 'layout' );
// WARNING: Do NOT use named arguments with WordPress core functions or hooks.
// Parameter names may change between WP versions and break your code.
// BEFORE (PHP 7.4)
/** @param string|array $classes */
function filter_body_class( $classes ) { ... }
// AFTER (PHP 8.0)
function filter_body_class( string|array $classes ): string|array {
if ( is_array( $classes ) ) {
$classes[] = 'custom-class';
}
return $classes;
}
// BEFORE (PHP 7.4)
$user = wp_get_current_user();
$name = null;
if ( $user !== null ) {
$meta = get_user_meta( $user->ID, 'display_name', true );
if ( $meta !== null ) {
$name = $meta;
}
}
// AFTER (PHP 8.0)
$name = wp_get_current_user()?->display_name;
// BEFORE (PHP 7.4)
switch ( $post->post_status ) {
case 'publish':
$label = 'Published';
break;
case 'draft':
$label = 'Draft';
break;
case 'pending':
$label = 'Pending Review';
break;
default:
$label = 'Unknown';
break;
}
// AFTER (PHP 8.0)
$label = match ( $post->post_status ) {
'publish' => 'Published',
'draft' => 'Draft',
'pending' => 'Pending Review',
default => 'Unknown',
};
// BEFORE (PHP 7.4)
class My_Plugin {
private string $plugin_file;
private string $version;
private bool $debug;
public function __construct( string $plugin_file, string $version, bool $debug = false ) {
$this->plugin_file = $plugin_file;
$this->version = $version;
$this->debug = $debug;
}
}
// AFTER (PHP 8.0)
class My_Plugin {
public function __construct(
private string $plugin_file,
private string $version,
private bool $debug = false,
) {}
}
// BEFORE (PHP 7.4)
if ( strpos( $template, 'single-' ) === 0 ) { ... }
if ( strpos( $content, 'shortcode' ) !== false ) { ... }
if ( substr( $file, -4 ) === '.php' ) { ... }
// AFTER (PHP 8.0)
if ( str_starts_with( $template, 'single-' ) ) { ... }
if ( str_contains( $content, 'shortcode' ) ) { ... }
if ( str_ends_with( $file, '.php' ) ) { ... }
Note: WordPress 5.9+ includes polyfills for these functions. For older WP versions, use wp_polyfill or provide your own.
// BEFORE (PHP 7.4) — string constants scattered everywhere
define( 'POST_STATUS_PUBLISH', 'publish' );
define( 'POST_STATUS_DRAFT', 'draft' );
// AFTER (PHP 8.1)
enum PostStatus: string {
case Publish = 'publish';
case Draft = 'draft';
case Pending = 'pending';
case Private = 'private';
case Trash = 'trash';
public function label(): string {
return match ( $this ) {
self::Publish => __( 'Published', 'my-plugin' ),
self::Draft => __( 'Draft', 'my-plugin' ),
self::Pending => __( 'Pending Review', 'my-plugin' ),
self::Private => __( 'Private', 'my-plugin' ),
self::Trash => __( 'Trashed', 'my-plugin' ),
};
}
}
// Usage with WP_Query
$query = new WP_Query( [ 'post_status' => PostStatus::Publish->value ] );
// BEFORE (PHP 7.4)
class Plugin_Config {
private string $slug;
public function __construct( string $slug ) {
$this->slug = $slug;
}
public function get_slug(): string { return $this->slug; }
}
// AFTER (PHP 8.1)
class Plugin_Config {
public function __construct(
public readonly string $slug,
public readonly string $version,
public readonly string $file,
) {}
}
// $config->slug is publicly readable but cannot be modified after construction.
// BEFORE (PHP 7.4)
add_action( 'init', [ $this, 'register_post_types' ] );
add_filter( 'the_content', [ $this, 'filter_content' ] );
// AFTER (PHP 8.1)
add_action( 'init', $this->register_post_types( ... ) );
add_filter( 'the_content', $this->filter_content( ... ) );
// WARNING: Closure::fromCallable() or the ( ... ) syntax creates a Closure.
// remove_action / remove_filter will NOT work because object identity differs.
// Use first-class callables only when you never need to unhook.
// PHP 8.1 — require multiple interfaces
function process_entity( Countable&Iterator $items ): void {
foreach ( $items as $item ) {
// guaranteed to be both Countable and Iterator
}
}
// PHP 8.2
readonly class Meta_Box_Args {
public function __construct(
public string $id,
public string $title,
public string $screen,
public string $context = 'advanced',
public string $priority = 'default',
) {}
}
This is the single largest PHP 8.2 issue for WordPress. See Section 9 for full remediation.
// PHP 8.2 DEPRECATION — dynamic properties trigger E_DEPRECATED
$obj = new stdClass(); // stdClass is exempt
$obj->foo = 'bar'; // fine on stdClass
class My_Widget extends WP_Widget {
// This triggers deprecation in PHP 8.2:
// $this->custom_prop = 'value';
}
// PHP 8.2
function wp_cache_get_or_set( string $key ): string|false {
$cached = wp_cache_get( $key );
if ( $cached === false ) {
return false; // explicitly typed as false
}
return $cached;
}
// BEFORE (PHP 8.2)
class My_REST_Controller extends WP_REST_Controller {
const NAMESPACE = 'myplugin/v1'; // untyped
}
// AFTER (PHP 8.3)
class My_REST_Controller extends WP_REST_Controller {
const string NAMESPACE = 'myplugin/v1';
const int VERSION = 1;
}
// BEFORE (PHP 8.2)
function is_valid_json( string $data ): bool {
json_decode( $data );
return json_last_error() === JSON_ERROR_NONE;
}
// AFTER (PHP 8.3) — no decoding overhead
if ( json_validate( $raw_meta ) ) {
$meta = json_decode( $raw_meta, true );
}
// PHP 8.3
class Theme_Walker extends Walker_Nav_Menu {
#[\Override]
public function start_el( &$output, $item, $depth = 0, $args = null, $id = 0 ): void {
// If parent signature changes, PHP will throw a compile-time error.
}
}
// PHP 7.4: silently coerces — strlen( [] ) returns null with a warning
// PHP 8.0: throws TypeError for internal function type mismatches
// FIX: Always validate types before passing to internal functions
$length = is_string( $value ) ? strlen( $value ) : 0;
// PHP 7.4: passing null to non-nullable internal parameter gives a warning
// PHP 8.0+: still a warning; PHP 8.1: deprecation notice; PHP 9.0: TypeError
// Common WP offender:
trim( null ); // Deprecated in 8.1
htmlspecialchars( null ); // Deprecated in 8.1
// FIX:
trim( $value ?? '' );
htmlspecialchars( $value ?? '' );
// PHP 8.2 DEPRECATED: ${var} inside strings
$msg = "Hello ${name}"; // deprecated
$msg = "Hello {$name}"; // correct — use this form
$msg = "Hello $name"; // also fine for simple variables
// PHP 8.1 DEPRECATED: implicit narrowing float-to-int
$index = 3.0;
$arr[$index]; // deprecated — use (int) $index explicitly
| WordPress Version | Minimum PHP | Recommended PHP | |---|---|---| | WP 5.9 - 6.2 | 5.6 | 7.4+ | | WP 6.3 - 6.4 | 7.0 | 8.0+ | | WP 6.5+ | 7.2+ | 8.1+ |
Always check the latest readme.html in WP core for the current minimum.
/**
* Plugin Name: My Plugin
* Requires PHP: 8.0
* Requires at least: 6.3
*/
WordPress will prevent activation on incompatible PHP versions when Requires PHP is set.
WordPress Site Health (Tools > Site Health) checks PHP version and reports recommendations. Programmatic check:
$compat = wp_check_php_version();
if ( $compat && isset( $compat['is_acceptable'] ) && ! $compat['is_acceptable'] ) {
add_action( 'admin_notices', 'show_php_upgrade_notice' );
}
Phase 1: Audit
# Install PHPCompatibility coding standard
composer require --dev phpcompatibility/phpcompatibility-wp:"*"
# Configure phpcs
cat > phpcs.xml <<'XML'
<?xml version="1.0"?>
<ruleset name="PHP8Migration">
<rule ref="PHPCompatibilityWP"/>
<config name="testVersion" value="8.0-"/>
<file>./wp-content/themes/oshin_child/</file>
<file>./wp-content/plugins/my-plugin/</file>
</ruleset>
XML
# Run the scan
vendor/bin/phpcs --standard=phpcs.xml --report=full
Phase 2: Fix Deprecations
Address issues in order of severity:
Phase 3: CI Matrix Testing
# GitHub Actions example
strategy:
matrix:
php: ['7.4', '8.0', '8.1', '8.2', '8.3']
wordpress: ['6.3', '6.5', 'latest']
steps:
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
- run: vendor/bin/phpunit
- run: vendor/bin/phpcs --standard=phpcs.xml
Phase 4: Rollout
debug.log for deprecation notices// Function-level polyfill guard
if ( ! function_exists( 'str_contains' ) ) {
function str_contains( string $haystack, string $needle ): bool {
return '' === $needle || false !== strpos( $haystack, $needle );
}
}
// Class-level detection
if ( class_exists( 'WeakMap' ) ) {
$cache = new WeakMap();
} else {
$cache = new SplObjectStorage();
}
// Use PHP 8.1 enums only when available
if ( PHP_VERSION_ID >= 80100 ) {
require_once __DIR__ . '/includes/enums.php';
} else {
require_once __DIR__ . '/includes/enum-compat.php';
}
// PHP 8.0 attributes are not parsed on PHP 7.x (syntax error).
// Use doc-block annotations as fallback with a framework that reads both.
// Or simply require PHP 8.0+ and drop 7.x support.
#[Route('/api/posts')] // PHP 8.0+
/** @Route("/api/posts") */ // PHP 7.x fallback (needs annotation reader)
This is the number one migration issue. Thousands of WordPress plugins and themes use dynamic properties.
// BEFORE — dynamic property usage
class My_Widget extends WP_Widget {
public function __construct() {
parent::__construct( 'my_widget', 'My Widget' );
$this->custom_option = get_option( 'my_widget_opt' ); // DEPRECATED in 8.2
}
}
// AFTER — declare the property
class My_Widget extends WP_Widget {
private string $custom_option;
public function __construct() {
parent::__construct( 'my_widget', 'My Widget' );
$this->custom_option = get_option( 'my_widget_opt' ) ?: '';
}
}
// Quick fix for large classes where full audit is impractical
#[\AllowDynamicProperties]
class Legacy_Plugin_Core {
// Dynamic properties still work without deprecation notices.
// Plan to refactor and remove this attribute.
}
class Extensible_Base {
private array $data = [];
public function __get( string $name ): mixed {
return $this->data[ $name ] ?? null;
}
public function __set( string $name, mixed $value ): void {
$this->data[ $name ] = $value;
}
public function __isset( string $name ): bool {
return isset( $this->data[ $name ] );
}
}
# Find assignments to $this-> that are not declared properties
# Run from plugin/theme root
grep -rn '\$this->[a-z_]*\s*=' --include='*.php' . | \
while read -r line; do
prop=$(echo "$line" | grep -oP '\$this->\K[a-z_]+')
file=$(echo "$line" | cut -d: -f1)
if ! grep -qP "^\s*(public|protected|private|var)\s+.*\\\$$prop" "$file"; then
echo "DYNAMIC: $line"
fi
done
// BEFORE
if ( strpos( $haystack, $needle ) !== false ) { ... }
// AFTER
if ( str_contains( $haystack, $needle ) ) { ... }
// BEFORE
if ( substr( $str, 0, 3 ) === 'wp-' ) { ... }
// AFTER
if ( str_starts_with( $str, 'wp-' ) ) { ... }
// BEFORE
if ( substr( $file, -4 ) === '.php' ) { ... }
// AFTER
if ( str_ends_with( $file, '.php' ) ) { ... }
// BEFORE
$val = isset( $_GET['page'] ) ? $_GET['page'] : 'default';
// AFTER (PHP 7.0+, but still under-used)
$val = $_GET['page'] ?? 'default';
// BEFORE
$avatar = '';
if ( $user && $user->get_avatar_url() ) { $avatar = $user->get_avatar_url(); }
// AFTER
$avatar = $user?->get_avatar_url() ?? '';
// BEFORE
switch ( $type ) { case 'post': return 'Posts'; case 'page': return 'Pages'; default: return 'Items'; }
// AFTER
return match ( $type ) { 'post' => 'Posts', 'page' => 'Pages', default => 'Items' };
// BEFORE
class Service { private Logger $logger; public function __construct( Logger $logger ) { $this->logger = $logger; } }
// AFTER
class Service { public function __construct( private Logger $logger ) {} }
// BEFORE
/** @return string|false */
function my_filter( $val ) { ... }
// AFTER
function my_filter( string $val ): string|false { ... }
// BEFORE (triggers deprecation in 8.1)
$clean = trim( $value ); // $value might be null
// AFTER
$clean = trim( $value ?? '' );
// PHP 8.0 removed array_key_exists() on objects. Use property_exists() instead.
// BEFORE
array_key_exists( 'key', $object ); // Fatal in 8.0
// AFTER
property_exists( $object, 'key' );
// For arrays, array_key_exists() still works fine.
// PHP 8.0: comparison functions must return int, not bool
// BEFORE
usort( $arr, function( $a, $b ) { return $a > $b; } ); // returns bool — broken
// AFTER
usort( $arr, function( $a, $b ) { return $a <=> $b; } ); // spaceship operator
// PHP 8.0 named args work well with long parameter lists
// BEFORE
array_slice( $posts, 0, 10, true );
// AFTER
array_slice( $posts, offset: 0, length: 10, preserve_keys: true );
// BEFORE
class Capability { const EDIT = 'edit_posts'; const DELETE = 'delete_posts'; }
// AFTER (PHP 8.1)
enum Capability: string { case Edit = 'edit_posts'; case Delete = 'delete_posts'; }
// BEFORE
class Settings { private string $option_name; public function get_name(): string { return $this->option_name; } }
// AFTER (PHP 8.1)
class Settings { public function __construct( public readonly string $option_name ) {} }
// PHP 8.1 Fibers — potential for async WP operations
$fiber = new Fiber( function (): void {
$data = Fiber::suspend( 'waiting_for_api' );
update_option( 'api_result', $data );
} );
$fiber->start();
// ... do other work ...
$fiber->resume( wp_remote_get( 'https://api.example.com/data' ) );
// BEFORE
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue' ] );
// AFTER (PHP 8.1) — creates a Closure
add_action( 'wp_enqueue_scripts', $this->enqueue( ... ) );
// PHP 8.1
function render( Renderable&Cacheable $component ): string { ... }
// Disjunctive Normal Form types
function get_post_data( int $id ): (Countable&Traversable)|false {
$result = $wpdb->get_results( "..." );
return $result ?: false;
}
// BEFORE
$decoded = json_decode( $input, true );
if ( json_last_error() !== JSON_ERROR_NONE ) { return new WP_Error(); }
// AFTER
if ( ! json_validate( $input ) ) { return new WP_Error( 'invalid_json' ); }
$decoded = json_decode( $input, true );
// PHP 8.3 — catches signature drift in parent classes after WP updates
class Custom_List_Table extends WP_List_Table {
#[\Override]
public function get_columns(): array { ... }
#[\Override]
public function prepare_items(): void { ... }
}
| Feature | Minimum PHP | |---|---| | Named arguments, union types, match, nullsafe, str_contains | 8.0 | | Enums, readonly props, fibers, first-class callables, intersection types | 8.1 | | Readonly classes, DNF types, deprecated dynamic properties | 8.2 | | Typed constants, json_validate, #[\Override] | 8.3 |
tools
Use when work should span one or more detached tasks but still behave like one job with a single owner context. TaskFlow is the durable flow substrate under authoring layers like Lobster, ACPX, plugins, or plain code. Keep conditional logic in the caller; use TaskFlow for flow identity, child-task linkage, waiting state, revision-checked mutations, and user-facing emergence.
tools
# Lobster Lobster executes multi-step workflows with approval checkpoints. Use it when: - User wants a repeatable automation (triage, monitor, sync) - Actions need human approval before executing (send, post, delete) - Multiple tool calls should run as one deterministic operation ## When to use Lobster | User intent | Use Lobster? | | ------------------------------------------------------ | --------------------------
tools
# Lobster Lobster executes multi-step workflows with approval checkpoints. Use it when: - User wants a repeatable automation (triage, monitor, sync) - Actions need human approval before executing (send, post, delete) - Multiple tool calls should run as one deterministic operation ## When to use Lobster | User intent | Use Lobster? | | ------------------------------------------------------ | --------------------------
tools
A CLI tool for making authenticated requests to the X (Twitter) API. Use this skill when you need to post tweets, reply, quote, search, read posts, manage followers, send DMs, upload media, or interact with any X API v2 endpoint.