
PHP 8.x has been a transformative series of releases for the entire PHP ecosystem — and for WordPress developers specifically, understanding these features isn’t optional anymore. With WordPress core now requiring PHP 7.4+ and many premium plugins pushing for 8.0+, writing modern PHP is both a professional requirement and a competitive advantage.
In this deep dive, we’ll go through every major PHP 8.0, 8.1, 8.2, and 8.3 feature that matters for WordPress development, with real-world examples from plugin and theme development.
Named arguments let you pass values to function parameters by name rather than position. In WordPress development, this shines when calling functions with many optional parameters:
// Old way — you need to know parameter order and pass nulls
$posts = get_posts(array(
'post_type' => 'product',
'posts_per_page' => 10,
'orderby' => 'date',
'order' => 'DESC',
));
// PHP 8.0 named arguments
$posts = get_posts(post_type: 'product', posts_per_page: 10, orderby: 'date', order: 'DESC');
// Even better for core functions with many params
$trimmed = array_slice(array: $items, offset: 0, length: 5, preserve_keys: true);
Named arguments are especially powerful when you need to skip optional parameters. Instead of passing null placeholders, you jump straight to the parameter you need.
The match expression is a type-safe, expression-oriented replacement for switch statements. It’s strict by default (no type coercion), returns a value, and throws an UnhandledMatchError if no arm matches.
// Old switch — loose comparison, easy bugs
function get_post_status_label($status) {
switch ($status) {
case 'publish':
return __('Published', 'my-plugin');
case 'draft':
return __('Draft', 'my-plugin');
case 'pending':
return __('Pending Review', 'my-plugin');
default:
return __('Unknown', 'my-plugin');
}
}
// PHP 8.0 match — clean, strict, returns value
function get_post_status_label(string $status): string {
return match($status) {
'publish' => __('Published', 'my-plugin'),
'draft' => __('Draft', 'my-plugin'),
'pending' => __('Pending Review', 'my-plugin'),
'private' => __('Private', 'my-plugin'),
'future' => __('Scheduled', 'my-plugin'),
default => __('Unknown', 'my-plugin'),
};
}
Match expressions also support multiple conditions per arm, which is perfect for grouping related values:
$label_class = match(true) {
in_array($status, ['publish', 'future']) => 'status-active',
in_array($status, ['draft', 'auto-draft']) => 'status-inactive',
$status === 'trash' => 'status-danger',
default => 'status-unknown',
};
The nullsafe operator ?-> is arguably the most practical addition in PHP 8.0 for WordPress developers. WordPress APIs often return null or false, and you frequently need to chain method calls on potentially null objects.
// Before PHP 8.0 — deeply nested null checks
$author_country = null;
$post = get_post($post_id);
if ($post !== null) {
$author = get_userdata($post->post_author);
if ($author !== null) {
$country = get_user_meta($author->ID, 'country', true);
if ($country) {
$author_country = strtoupper($country);
}
}
}
// PHP 8.0 nullsafe operator
$post = get_post($post_id);
$author = $post ? get_userdata($post->post_author) : null;
$author_country = $author
? strtoupper(get_user_meta($author->ID, 'country', true) ?: 'US')
: null;
// Even better in OOP plugins with method chains
$price = $this->getCart()?->getItem($sku)?->getPrice()?->format();
Union types let you declare that a parameter or return value can be one of several types — critical for WordPress functions that return different types based on context.
class PostRepository {
public function find(int|string $id): WP_Post|null {
if (is_string($id)) {
return get_page_by_path($id, OBJECT, 'post');
}
return get_post($id) ?: null;
}
public function save(array|WP_Post $post): int|WP_Error {
$data = $post instanceof WP_Post ? $post->to_array() : $post;
return wp_insert_post($data, true);
}
}
This feature reduces the boilerplate of declaring properties and assigning them in __construct. For WordPress plugins with many service classes, this is a huge quality-of-life improvement:
// Before — lots of repetition
class EmailService {
private MailerInterface $mailer;
private LoggerInterface $logger;
private string $fromEmail;
private string $fromName;
public function __construct(
MailerInterface $mailer,
LoggerInterface $logger,
string $fromEmail,
string $fromName
) {
$this->mailer = $mailer;
$this->logger = $logger;
$this->fromEmail = $fromEmail;
$this->fromName = $fromName;
}
}
// PHP 8.0 — clean and DRY
class EmailService {
public function __construct(
private readonly MailerInterface $mailer,
private readonly LoggerInterface $logger,
private readonly string $fromEmail,
private readonly string $fromName,
) {}
}
You can now use throw as an expression, not just a statement. This enables throwing exceptions in arrow functions, ternaries, and the nullish coalescing operator:
// Validate required settings concisely
$api_key = get_option('my_plugin_api_key')
?: throw new RuntimeException('API key not configured. Please check plugin settings.');
// In arrow functions
$processedItems = array_map(
fn($item) => $item['id'] ?? throw new InvalidArgumentException("Item missing ID"),
$rawItems
);
Enums are one of the most requested PHP features ever — and they’re perfect for WordPress plugin development where you have fixed sets of values like post statuses, user roles, or payment states.
// Basic enum for post types
enum PostType: string {
case Post = 'post';
case Page = 'page';
case Product = 'product';
case Portfolio = 'portfolio';
public function label(): string {
return match($this) {
PostType::Post => 'Blog Post',
PostType::Page => 'Page',
PostType::Product => 'Product',
PostType::Portfolio => 'Portfolio Item',
};
}
public function supportsComments(): bool {
return match($this) {
PostType::Post, PostType::Product => true,
default => false,
};
}
}
// Usage
function register_my_post_types(): void {
foreach (PostType::cases() as $type) {
register_post_type($type->value, [
'label' => $type->label(),
'supports' => $type->supportsComments() ? ['comments'] : [],
'public' => true,
'show_in_rest' => true,
'show_in_graphql' => true,
]);
}
}
add_action('init', 'register_my_post_types');
Pure (non-backed) enums are great for internal state machines in complex plugins like LMS or e-commerce:
enum EnrollmentStatus {
case Pending;
case Active;
case Completed;
case Cancelled;
case Expired;
public function canTransitionTo(self $new): bool {
return match($this) {
self::Pending => in_array($new, [self::Active, self::Cancelled]),
self::Active => in_array($new, [self::Completed, self::Cancelled, self::Expired]),
self::Completed => false,
self::Cancelled => false,
self::Expired => in_array($new, [self::Active]),
};
}
}
Readonly properties can be set only once — perfect for value objects and domain models in WordPress plugins:
class Money {
public function __construct(
public readonly int $amount,
public readonly string $currency,
) {}
public function add(Money $other): static {
if ($this->currency !== $other->currency) {
throw new CurrencyMismatchException($this->currency, $other->currency);
}
return new static($this->amount + $other->amount, $this->currency);
}
public function format(): string {
return number_format($this->amount / 100, 2) . ' ' . $this->currency;
}
}
// Immutable — $price->amount = 9999 would throw Error
$price = new Money(4999, 'USD');
echo $price->format(); // 49.99 USD
While union types say “this OR that”, intersection types say “this AND that” — the parameter must satisfy all types simultaneously. Perfect for repository patterns:
interface Cacheable {
public function getCacheKey(): string;
public function getTtl(): int;
}
interface Serializable {
public function serialize(): string;
public static function deserialize(string $data): static;
}
// Parameter must implement BOTH interfaces
function store_in_cache(Cacheable&Serializable $item): void {
wp_cache_set(
$item->getCacheKey(),
$item->serialize(),
group: 'my_plugin',
expire: $item->getTtl()
);
}
Fibers are low-level concurrency primitives — think coroutines. While not directly applicable to most WordPress plugins today, they’re the foundation for async PHP frameworks. For developers building CLI tools, background processors, or custom REST API aggregators, Fibers open new possibilities:
// Process multiple API requests cooperatively
$fiber = new Fiber(function(): void {
$value = Fiber::suspend('first suspension');
echo "Got: " . $value . PHP_EOL;
});
$result = $fiber->start(); // "first suspension"
$fiber->resume('hello from caller'); // "Got: hello from caller"
PHP 8.2 extends readonly to the class level — every promoted property becomes readonly automatically. This is ideal for DTOs (Data Transfer Objects) that pass data between WordPress hooks and services:
readonly class CreatePostDTO {
public function __construct(
public string $title,
public string $content,
public string $postType,
public string $status = 'draft',
public ?int $authorId = null,
public array $meta = [],
) {}
public static function fromRequest(WP_REST_Request $request): static {
return new static(
title: sanitize_text_field($request->get_param('title')),
content: wp_kses_post($request->get_param('content')),
postType: sanitize_key($request->get_param('post_type') ?? 'post'),
status: sanitize_key($request->get_param('status') ?? 'draft'),
authorId: (int) $request->get_param('author_id') ?: null,
);
}
}
// Usage in a REST controller
class PostController {
public function create(WP_REST_Request $request): WP_REST_Response|WP_Error {
$dto = CreatePostDTO::fromRequest($request);
$postId = $this->postService->create($dto);
return rest_ensure_response(['id' => $postId]);
}
}
DNF types combine union and intersection types: (A&B)|null. This is the most expressive type system PHP has ever had:
function process(
(Countable&Stringable)|null $data
): (Iterator&Countable)|array {
if ($data === null) return [];
// ...
}
class Config {
const VERSION = '2.0.0';
}
$key = 'VERSION';
echo Config::{$key}; // "2.0.0" — dynamic constant access
A long-overdue feature — class constants can now have type declarations:
class PluginConfig {
const string VERSION = '3.0.0';
const int MAX_RETRIES = 3;
const float CACHE_TTL = 3600.0;
const bool DEBUG_MODE = false;
const array SUPPORTED_POST_TYPES = ['post', 'page', 'product'];
}
interface HasVersion {
const string VERSION; // Typed interface constant
}
A new built-in function to validate JSON without needing to decode it first — great for webhook validation:
// WordPress webhook handler
function handle_stripe_webhook(WP_REST_Request $request): WP_REST_Response {
$body = $request->get_body();
if (!json_validate($body)) {
return new WP_REST_Response(['error' => 'Invalid JSON'], 400);
}
$event = json_decode($body, true);
// Process...
return new WP_REST_Response(['received' => true]);
}
While technically landing in 8.4, many developers preview these functions via polyfills. array_find() returns the first element matching a predicate — far cleaner than array_filter() + reset():
// Old way
$first_active = reset(array_filter($items, fn($i) => $i->isActive())) ?: null;
// PHP 8.4 array_find()
$first_active = array_find($items, fn($i) => $i->isActive());
WordPress.org strongly recommends PHP 8.0+ as of 2024. Here’s a realistic compatibility matrix for plugins:
Stricter type coercion: PHP 8.0 changed strpos() and similar functions to throw TypeError instead of deprecation warnings when passed wrong types. Audit your code for:
// This throws TypeError in PHP 8.0
strpos(null, 'search');
// Fix: always pass strings
strpos((string) $value, 'search');
match vs switch: Match throws UnhandledMatchError on no match — add a default arm or handle the error.
Dynamic properties deprecated (8.2): Creating properties on objects that don’t declare them triggers deprecation warnings. Add #[AllowDynamicProperties] to legacy code or declare all properties explicitly.
// Deprecated in PHP 8.2
class MyPlugin {
public function init() {
$this->my_prop = 'value'; // Deprecation warning if not declared
}
}
// Fix: declare the property
class MyPlugin {
private string $my_prop = '';
public function init() {
$this->my_prop = 'value'; // Fine
}
}
PHP 8.0’s JIT (Just-In-Time) compiler gets a lot of marketing attention, but for typical WordPress workloads (I/O-bound, lots of string processing), the JIT doesn’t help much. The real PHP 8.x performance wins come from:
str_contains(), str_starts_with(), str_ends_with()Benchmarks from real WordPress hosting show PHP 8.2 is approximately 15–20% faster than PHP 7.4 for WordPress-style workloads — primarily from internal engine improvements, not JIT.
Ready to modernize your WordPress plugin? Here’s a systematic approach:
composer require --dev szepeviktor/phpstan-wordpress for WordPress-aware rules.str_contains(), str_starts_with(), str_ends_with() instead of strpos() for readability.PHP 8.x is not just incremental improvements — it’s a fundamentally better language with a modern type system, cleaner syntax, and meaningful performance gains. For WordPress developers, adopting these features means more maintainable plugins, better IDE support, fewer runtime bugs, and code that’s genuinely enjoyable to write.
The investment in learning PHP 8.x pays back immediately: fewer null reference errors with the nullsafe operator, self-documenting code with enums, and type-safe APIs with readonly classes. Start with the features that require zero migration cost — match, named arguments, and str_contains() — and work your way up to enums and readonly classes as you refactor.
The WordPress ecosystem is moving forward. Your code should too.