Use PHP 8.1 Enums, Readonly Properties, and Intersection Types in WordPress Plugins

PHP 8.1 introduced three features that significantly improve the type safety and expressiveness of WordPress plugin code: enums, readonly properties, and intersection types — all supported in WordPress 6.1+ which raised the minimum PHP requirement to 7.4 but allows plugin authors to target 8.1 features when declaring their own minimum PHP version in the plugin header. Enums replace the pattern of defining sets of related constants as plain strings or integers (e.g., define('STATUS_DRAFT', 'draft')) with a first-class type that is self-documenting, prevents invalid values at the call site, and supports methods and interface implementation. Backed enums (enum Status: string) associate each case with a scalar value for database storage or API serialization — Status::from('publish') creates an enum from a string and throws a ValueError for invalid input, while Status::tryFrom('invalid') returns null instead. Readonly properties (declared as public readonly string $slug) are assigned once in the constructor and are then immutable — any subsequent write throws an Error, making value objects like post data transfer objects (DTOs) safer without requiring a custom __set() guard. Intersection types (function process(Countable&Traversable $items)) express that a parameter must implement multiple interfaces simultaneously — before PHP 8.1, this required a doc-block comment that static analysis tools could check but the runtime ignored. First-class callable syntax (strlen(...) instead of 'strlen') eliminates a class of typo bugs in array functions like array_map and hook registrations like add_filter('the_title', htmlspecialchars(...)). Fibers, PHP 8.1’s cooperative multitasking primitive, allow writing asynchronous-style code (a coroutine that can be paused and resumed) — relevant for WordPress plugins built on async HTTP libraries like Amp or ReactPHP. The PHPUnit post shows how to write unit tests for classes that use these 8.1 features with data providers testing enum cases.

Problem: A WordPress plugin defines post status, order state, and notification type as string constants scattered across multiple files — type errors are silent (a typo passes as a valid string), refactoring is fragile (global string search/replace misses dynamic usages), and IDEs cannot autocomplete or validate the values.

Solution: Replace constant groups with PHP 8.1 backed enums, use readonly properties for DTO objects that carry post data between layers, and leverage intersection types for functions that require multiple interface contracts — converting runtime string errors into compile-time type errors caught by static analysis.

 __( 'Draft',   'myplugin' ),
            self::Publish => __( 'Published','myplugin' ),
            self::Private => __( 'Private', 'myplugin' ),
            self::Trash   => __( 'Trashed', 'myplugin' ),
            self::Pending => __( 'Pending', 'myplugin' ),
        };
    }

    /** Whether this status is visible to non-authenticated users */
    public function isPubliclyVisible(): bool {
        return $this === self::Publish;
    }
}

// Safe creation from untrusted input (returns null instead of throwing)
function parse_post_status( string $raw ): ?PostStatus {
    return PostStatus::tryFrom( sanitize_key( $raw ) );
}

$status = parse_post_status( 'publish' );  // PostStatus::Publish
$status?->label();                          // "Published"
$status?->isPubliclyVisible();              // true

// ── 2. Readonly properties in a Post DTO ─────────────────────────────────
final class PostData {
    public function __construct(
        public readonly int        $id,
        public readonly string     $title,
        public readonly PostStatus $status,
        public readonly \DateTimeImmutable $publishedAt,
    ) {}
}

function get_post_data( int $post_id ): ?PostData {
    $post = get_post( $post_id );
    if ( ! $post ) return null;

    $status = PostStatus::tryFrom( $post->post_status );
    if ( ! $status ) return null;

    return new PostData(
        id:          $post->ID,
        title:       sanitize_text_field( $post->post_title ),
        status:      $status,
        publishedAt: new \DateTimeImmutable( $post->post_date_gmt . ' UTC' ),
    );
}

$data = get_post_data( 42 );
// $data->id = 99;  // → Error: Cannot modify readonly property PostData::$id

// ── 3. Intersection types for multi-interface parameters ──────────────────
interface Cacheable {
    public function getCacheKey(): string;
    public function getTtl(): int;
}

interface Serializable {
    public function serialize(): string;
}

// Function requires an object that implements BOTH interfaces
function cache_and_store( Cacheable&Serializable $item ): void {
    set_transient(
        $item->getCacheKey(),
        $item->serialize(),
        $item->getTtl()
    );
}

// ── 4. First-class callable syntax ───────────────────────────────────────
$titles = array_map( sanitize_text_field( ... ), $raw_titles );   // safe reference
$ints   = array_filter( $values, is_int( ... ) );

add_filter( 'the_title', htmlspecialchars( ... ), 10, 1 );  // instead of 'htmlspecialchars'

NOTE: When using PHP 8.1 features in a WordPress plugin, declare the minimum PHP version in the plugin header as Requires PHP: 8.1 — WordPress checks this during activation and displays an error rather than activating the plugin on an incompatible server. Pair this with a runtime check in the plugin bootstrap: if ( version_compare( PHP_VERSION, '8.1', '<' ) ) { /* deactivate and show admin notice */ } as a defensive fallback for hosts that bypass the version check.