AΒ·R
EN SR

Modern PHP Features Worth Using in 2026

Concrete before-and-after code examples of match expressions, constructor property promotion, readonly properties, enums, fibers, and other PHP 8.x features that make WordPress and PHP development cleaner and more robust.

Modern PHP Features Worth Using in 2026

Match Expressions vs Switch

PHP 8.0 introduced match as a replacement for switch. The differences are significant: match returns a value, uses strict comparison (===), and does not fall through. Here is a real example from a plugin settings handler:

// Before: switch with fall-through bugs and loose comparison
function get_post_type_label(string $post_type): string {
    switch ($post_type) {
        case 'post':
            $label = 'Article';
            break;
        case 'page':
            $label = 'Static Page';
            break;
        case 'product':
            $label = 'Product';
            break;
        default:
            $label = 'Content';
            break;
    }
    return $label;
}

// After: match β€” returns directly, no breaks, strict comparison
function get_post_type_label(string $post_type): string {
    return match ($post_type) {
        'post'    => 'Article',
        'page'    => 'Static Page',
        'product' => 'Product',
        default   => 'Content',
    };
}

The match expression evaluates to a single value, so you can use it inline: $label = match($type) { ... };. With switch, forgetting a break creates a fall-through bug. With match, that class of bug is impossible. The strict comparison means match (0) { 'a' => 1 } does not match β€” 0 === 'a' is false. With switch, 0 == 'a' evaluates to true, a common source of subtle bugs.

Multiple conditions can match the same arm:

$http_status = match ($code) {
    200, 201, 204 => 'Success',
    301, 302, 307 => 'Redirect',
    400, 401, 403, 404 => 'Client Error',
    500, 502, 503 => 'Server Error',
    default => 'Unknown',
};

Named Arguments

Named arguments, introduced in PHP 8.0, let you pass parameters by name rather than position. This is especially useful for functions with many optional parameters:

// Before: counting commas to figure out which parameter is which
wp_insert_post([
    'post_title'   => 'My Post',
    'post_content' => 'Content here',
    'post_status'  => 'publish',
    'post_author'  => 1,
    'post_type'    => 'post',
], false, false);

// After: named arguments with a custom function
function create_post(
    string $title,
    string $content,
    string $status = 'publish',
    int $author = 1,
    string $type = 'post',
    bool $wp_error = false,
    bool $fire_after_hooks = true
): int|WP_Error {
    return wp_insert_post([
        'post_title'   => $title,
        'post_content' => $content,
        'post_status'  => $status,
        'post_author'  => $author,
        'post_type'    => $type,
    ], $wp_error, $fire_after_hooks);
}

// Call with named arguments β€” skip defaults, specify only what matters
$post_id = create_post(
    title:   'Product Update',
    content: $rendered_html,
    type:    'product_update',
    status:  'pending',
);

Named arguments also work with built-in functions and class constructors. When refactoring, named arguments protect against parameter order changes β€” if a library reorders its parameters, named calls continue working without modification.

Combine named arguments with variadic parameters for flexible APIs:

class QueryBuilder {
    public function __construct(
        private string $table,
        private array $select = ['*'],
        private array $where = [],
        private string $orderBy = 'id',
        private string $direction = 'ASC',
        private int $limit = 100,
    ) {}
}

// Instantiate with only the fields that differ from defaults
$query = new QueryBuilder(
    table:   'wp_posts',
    where:   ['post_status' => 'publish'],
    orderBy: 'post_date',
    limit:   50,
);

Constructor Property Promotion

PHP 8.0 eliminated the boilerplate of declaring properties and then assigning them in the constructor. A class that previously required 15 lines now needs 5:

// Before PHP 8.0: declare property, add parameter, assign in constructor
class AdminNotice {
    private string $message;
    private string $type;
    private bool $dismissible;

    public function __construct(string $message, string $type, bool $dismissible) {
        $this->message = $message;
        $this->type = $type;
        $this->dismissible = $dismissible;
    }
}

// PHP 8.0+: properties declared and assigned in the constructor signature
class AdminNotice {
    public function __construct(
        private string $message,
        private string $type = 'info',
        private bool $dismissible = true,
    ) {}

    public function render(): void {
        printf(
            '<div class="notice notice-%s%s"><p>%s</p></div>',
            esc_attr($this->type),
            $this->dismissible ? ' is-dismissible' : '',
            esc_html($this->message)
        );
    }
}

// Usage
$notice = new AdminNotice(message: 'Settings saved.', type: 'success');

The visibility modifier (private, protected, public) on the constructor parameter tells PHP to create the property and assign the value automatically. You can still add default values, type hints, and even attributes to promoted properties. Readonly properties (covered next) work naturally with promotion.

Readonly Properties for Immutable Objects

PHP 8.1 introduced the readonly modifier. A readonly property can be initialized once β€” either at declaration or in the constructor β€” and never modified afterward:

class DatabaseConfig {
    public function __construct(
        public readonly string $host,
        public readonly int $port,
        public readonly string $database,
        private readonly string $password,
    ) {}

    public function getDsn(): string {
        return "mysql:host={$this->host};dbname={$this->database};port={$this->port}";
    }
}

// Usage
$config = new DatabaseConfig('localhost', 3306, 'wordpress', 'secret');
echo $config->host;        // 'localhost' β€” works, property is public
echo $config->getDsn();    // mysql:host=localhost;dbname=wordpress;port=3306
$config->host = 'other';   // Fatal Error: Cannot modify readonly property

Readonly properties work with any type except uninitialized. Once initialized, they are immutable. This replaces the pattern of private properties with getter-only methods, reducing boilerplate while maintaining encapsulation. For value objects β€” DTOs, configuration objects, API responses β€” readonly with constructor promotion is the correct pattern.

Limitation: readonly properties cannot have a default value. They must be initialized in the constructor:

// This works β€” initialized in constructor
class ApiResponse {
    public function __construct(public readonly int $status, public readonly array $data) {}
}

// This does NOT work β€” readonly with default value
class Broken {
    public readonly string $version = '1.0.0'; // Compile error
}

Enums with Methods

PHP 8.1 enums replace the pattern of class constants representing a fixed set of values:

// Before: string constants with no type safety
class OrderStatus {
    public const PENDING    = 'pending';
    public const PROCESSING = 'processing';
    public const COMPLETED  = 'completed';
    public const CANCELLED  = 'cancelled';
}

// After: backed enum with type safety and methods
enum OrderStatus: string {
    case Pending    = 'pending';
    case Processing = 'processing';
    case Completed  = 'completed';
    case Cancelled  = 'cancelled';

    public function label(): string {
        return match($this) {
            self::Pending    => 'Pending Payment',
            self::Processing => 'Being Prepared',
            self::Completed  => 'Completed',
            self::Cancelled  => 'Cancelled',
        };
    }

    public function canTransitionTo(self $newStatus): bool {
        return match($this) {
            self::Pending    => in_array($newStatus, [self::Processing, self::Cancelled], true),
            self::Processing => in_array($newStatus, [self::Completed, self::Cancelled], true),
            self::Completed  => false,
            self::Cancelled  => false,
        };
    }

    public function color(): string {
        return match($this) {
            self::Pending    => '#f0ad4e',
            self::Processing => '#5bc0de',
            self::Completed  => '#5cb85c',
            self::Cancelled  => '#d9534f',
        };
    }
}

// Usage
$status = OrderStatus::from('pending');
echo $status->label();      // 'Pending Payment'
echo $status->value;        // 'pending' β€” the backed string value
echo $status->color();      // '#f0ad4e'

// Type-safe parameter
function updateOrderStatus(int $orderId, OrderStatus $status): void {
    // $status is guaranteed to be a valid OrderStatus case
}

The OrderStatus::from() method validates the input string and returns the corresponding enum case, throwing ValueError for invalid values. The tryFrom() method returns null instead of throwing. The canTransitionTo() method encodes business logic directly on the enum, eliminating scattered validation code.

Pure enums (without backing values) are also useful:

enum CacheDriver {
    case Redis;
    case Memcached;
    case Array; // In-memory, for testing

    public function isPersistent(): bool {
        return match($this) {
            self::Redis, self::Memcached => true,
            self::Array => false,
        };
    }
}

Fibers for Cooperative Multitasking

PHP 8.1 introduced Fibers, which allow cooperative multitasking without the complexity of async/await syntax. Fibers let you pause and resume code execution, making them ideal for concurrent HTTP requests or streaming operations:

function fetch_multiple_urls(array $urls): array {
    $fibers = [];
    $results = [];

    foreach ($urls as $key => $url) {
        $fibers[$key] = new Fiber(function() use ($url, &$results, $key) {
            $ch = curl_init($url);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_TIMEOUT, 30);

            // Start the request
            curl_exec($ch);

            // Suspend the fiber while waiting β€” in real usage,
            // you would suspend after starting the request and 
            // resume when the response is ready
            Fiber::suspend($key);

            $results[$key] = curl_multi_getcontent($ch);
            curl_close($ch);
        });
        $fibers[$key]->start();
    }

    // Resume fibers as their responses complete
    foreach ($fibers as $fiber) {
        while (!$fiber->isTerminated()) {
            $fiber->resume();
        }
    }

    return $results;
}

Fibers require careful error handling β€” an exception thrown inside a fiber must be caught when the fiber is resumed. For WordPress development, the most practical use case is replacing sequential external API calls with concurrent ones in background processing jobs.

Practical example with WordPress HTTP API:

use ARDevMyPluginAPIExternalService;

class BatchApiClient {
    /** @var Fiber[] */
    private array $pending = [];

    public function schedule(string $endpoint, array $params = []): void {
        $this->pending[] = new Fiber(function() use ($endpoint, $params) {
            Fiber::suspend(); // Suspend immediately, execute later

            $response = wp_remote_get($endpoint, [
                'timeout' => 30,
                'body'    => $params,
            ]);

            return is_wp_error($response) 
                ? ['error' => $response->get_error_message()]
                : json_decode(wp_remote_retrieve_body($response), true);
        });
    }

    public function execute(): array {
        $results = [];

        foreach ($this->pending as $fiber) {
            $fiber->start();
        }

        foreach ($this->pending as $index => $fiber) {
            if ($fiber->isSuspended()) {
                $fiber->resume();
            }
            $results[$index] = $fiber->isTerminated() ? $fiber->getReturn() : null;
        }

        $this->pending = [];
        return $results;
    }
}

Union Types

PHP 8.0 introduced union types, allowing parameters and return values to accept multiple types:

// Before: mixed type with manual validation
function get_option_value($key) {
    if (!is_string($key) && !is_int($key)) {
        throw new TypeError('Key must be string or int');
    }
    // ...
}

// After: union type enforced by the engine
function get_option_value(string|int $key): string|int|bool|null {
    return get_option($key);
}

// Return a value or a specific error object
function find_user(int $id): WP_User|WP_Error {
    $user = get_user_by('id', $id);
    return $user ?: new WP_Error('not_found', 'User does not exist');
}

// Nullable unions (null must be explicit)
function parse_date(string $input): DateTimeImmutable|false|null {
    if ($input === '') {
        return null;
    }
    $parsed = DateTimeImmutable::createFromFormat('Y-m-d', $input);
    return $parsed ?: false;
}

Union types replace @param mixed docblocks with enforceable type declarations. They work with properties, parameters, and return types. You cannot use redundant combinations like string|String or int|false|null with ? syntax β€” use the full union instead.

Nullsafe Operator

The nullsafe operator (?->), introduced in PHP 8.0, short-circuits property access and method calls when the left side is null:

// Before: nested null checks
$city = null;
if ($order !== null && $order->getShippingAddress() !== null) {
    $address = $order->getShippingAddress();
    if ($address->getCity() !== null) {
        $city = $address->getCity();
    }
}

// After: nullsafe operator
$city = $order?->getShippingAddress()?->getCity();

// Works in assignment context too
$state = $user?->getProfile()?->getAddress()?->state ?? 'Unknown';

// Safe chained method calls
$orderDate = $order?->getMetadata()?->getCreatedAt()?->format('Y-m-d') ?? 'N/A';

The nullsafe operator short-circuits the entire chain. If $order is null, getShippingAddress() is never called. This eliminates the entire class of “call to a member function on null” fatal errors. The operator works on any number of chained accesses and combines naturally with the null coalescing operator (??).

Attributes (Annotations)

PHP 8.0 attributes replace docblock annotations with native, reflection-accessible metadata:

// Before: parsing @Route annotations from docblocks
/**
 * @Route("/api/v1/posts", methods={"GET"})
 * @Middleware("auth")
 */
class PostsController { }

// After: native PHP attributes
#[Route('/api/v1/posts', methods: ['GET'])]
#[Middleware('auth')]
class PostsController {
    public function __construct(
        private PostRepository $repository,
    ) {}

    #[Route('/api/v1/posts/{id}', methods: ['GET'])]
    public function show(int $id): array {
        return $this->repository->find($id)?->toArray() ?? ['error' => 'Not found'];
    }

    #[Route('/api/v1/posts', methods: ['POST'])]
    #[Middleware('auth')]
    #[Validate(PostRequest::class)]
    public function store(array $data): array {
        return $this->repository->create($data)->toArray();
    }
}

// Reading attributes via reflection
$reflector = new ReflectionClass(PostsController::class);
$classAttributes = $reflector->getAttributes();

foreach ($classAttributes as $attribute) {
    $instance = $attribute->newInstance();
    echo $instance->path;      // '/api/v1/posts'
    print_r($instance->methods); // ['GET']
}

Attributes are classes marked with #[Attribute]. They are instantiated through reflection and provide type-safe metadata:

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
class Route {
    public function __construct(
        public readonly string $path,
        public readonly array  $methods = ['GET'],
    ) {}
}

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
class Middleware {
    public function __construct(public readonly string $name) {}
}

#[Attribute(Attribute::TARGET_METHOD)]
class Validate {
    public function __construct(public readonly string $requestClass) {}
}

Attributes are stored in opcode cache alongside the class, so reading them via reflection is fast. They are particularly useful for plugin architectures that register hooks, routes, or scheduled events based on class metadata.

String Interpolation Improvements

PHP 8.2+ allows embedding variables directly in double-quoted strings without the dollar-brace wrapper for simple cases, and PHP 8.0+ added the "{$object->property}" syntax improvements:

$name = 'WordPress';
$version = '6.5';

// All of these are valid and equivalent in modern PHP
$message = "Running {$name} version {$version}";
$message = "Running $name version $version";
$message = "Running {$name} version {$version}";

// Embedding expressions (PHP 8.0+)
$userCount = 42;
$message = "There are {$userCount} user" . ($userCount !== 1 ? 's' : '');

// Heredoc with embedded variables
$sql = <<prefix}posts 
    WHERE post_status = 'publish' 
    AND post_date > '{$cutoff_date}'
SQL;

// The nullsafe operator works in interpolation too
$username = $currentUser?->display_name ?? 'Guest';
$greeting = "Hello, {$username}!";

str_contains, str_starts_with, str_ends_with

PHP 8.0 added these three functions, replacing the strpos() !== false pattern that every PHP developer has written a thousand times:

// Before: strpos() with !== false check
if (strpos($haystack, $needle) !== false) {
    // found
}
if (strpos($haystack, $needle) === 0) {
    // starts with
}
if (substr($haystack, -strlen($needle)) === $needle) {
    // ends with
}

// After: readable, intent-revealing functions
if (str_contains($haystack, $needle)) {
    // found
}
if (str_starts_with($haystack, $needle)) {
    // starts with
}
if (str_ends_with($haystack, $needle)) {
    // ends with
}

// Null-safe usage β€” all three accept null as first argument from PHP 8.0
$filename = $_GET['file'] ?? '';
if (str_ends_with($filename, '.php')) {
    reject_upload(); // Never allow .php uploads
}

// Case-insensitive variants (still need manual implementation or mb_stripos)
// But for ASCII-only checks, convert and compare:
if (str_contains(strtolower($input), 'wordpress')) { ... }

These functions are null-safe β€” passing null as the first argument produces a deprecation warning in PHP 8.1+ but still evaluates correctly. Use them for all string containment checks. They are marginally faster than strpos() because they are implemented as single-opcode functions in the engine.

Throw Expressions

PHP 8.0 made throw an expression rather than a statement. This means you can throw exceptions in contexts that previously required a separate statement:

// Before: throw required a separate statement
$value = get_option('required_setting');
if ($value === false) {
    throw new RuntimeException('Required setting is not configured');
}

// After: throw in expression context
$value = get_option('required_setting') 
    ?? throw new RuntimeException('Required setting is not configured');

// In a match expression
$driver = match ($config['cache_driver'] ?? null) {
    'redis'     => new RedisCache(),
    'memcached' => new MemcachedCache(),
    null        => throw new InvalidArgumentException('Cache driver is required'),
    default     => throw new InvalidArgumentException("Unknown driver: {$config['cache_driver']}"),
};

// In a ternary
$apiKey = !empty($config['api_key']) 
    ? $config['api_key'] 
    : throw new RuntimeException('API key is required');

// In arrow functions
$validatePositive = fn(int $value): int => $value > 0 
    ? $value 
    : throw new InvalidArgumentException('Value must be positive');

Throw expressions eliminate the pattern of checking a value and then immediately throwing. They make validation logic compact and place the error condition at the point of decision rather than after it. The throw expression evaluates to never β€” the PHP 8.1+ return type that indicates a function or expression never completes normally.

Practical Checklist for Adoption

Not every project can upgrade immediately. Here is a pragmatic adoption order for existing WordPress codebases:

  1. Start with str_contains, str_starts_with, str_ends_with β€” these are safe, readable, and available in WordPress’s PHP 8.0+ polyfills.
  2. Replace switch with match where you are returning a single value from a conditional. This eliminates an entire category of fall-through bugs.
  3. Use constructor property promotion on all new classes. It reduces code by 60% for simple data classes.
  4. Add readonly to value objects and DTOs that should never change after construction. This replaces getter-only private properties.
  5. Introduce enums for status fields, type identifiers, and any set of related constants. The type safety alone catches bugs at call sites.
  6. Use named arguments for functions with more than three parameters, especially when calling external libraries.
  7. Apply union types to function signatures that currently use mixed or lack type hints. Start with internal utility functions.
  8. Use nullsafe operator (?->) to replace deep null-check chains. Every ?-> eliminates a potential fatal error.
  9. Consider attributes if you are building a plugin framework with hook registration, routing, or dependency injection.
  10. Use throw expressions in validation code to make failure conditions explicit at the point of checking.

Each feature reduces code volume, increases type safety, or eliminates a class of runtime errors. Apply them incrementally β€” every modernized function signature makes the codebase more maintainable.

Leave a Reply

Your email address will not be published. Required fields are marked *