Most WordPress code I inherit is written as if PHP stopped at 5.6: string constants, associative arrays for everything, switch statements, no types. WordPress itself has to support old PHP; your theme and plugin code does not. The examples below are taken from a real custom theme I refactored this year; each one made the code shorter, safer and easier for the next developer.
Enums instead of magic strings
Before: post types, statuses and "kind" values as strings scattered through the codebase, misspelled in at least one place.
<?php
// Before
function umm_card_color(string $type): string {
switch ($type) {
case 'case-study': return 'blue';
case 'insight': return 'emerald';
case 'news': return 'amber';
default: return 'slate';
}
}
$color = umm_card_color(get_post_type()); // silently returns 'slate' on a typo
<?php
// After: PHP 8.1 backed enum with behaviour attached
enum ContentType: string {
case CaseStudy = 'case-study';
case Insight = 'insight';
case News = 'news';
public function color(): string {
return match ($this) {
self::CaseStudy => 'blue',
self::Insight => 'emerald',
self::News => 'amber',
};
}
public function label(): string {
return match ($this) {
self::CaseStudy => __('Case study', 'umm'),
self::Insight => __('Insight', 'umm'),
self::News => __('News', 'umm'),
};
}
public static function fromPost(WP_Post|int $post): ?self {
return self::tryFrom(get_post_type($post) ?: '');
}
}
$type = ContentType::fromPost(get_the_ID());
$color = $type?->color() ?? 'slate';
match has no fall-through and throws UnhandledMatchError if a case is missing, so adding case Podcast to the enum makes every incomplete match fail loudly in development instead of silently in production. That is the PHP equivalent of the exhaustive-switch trick from my TypeScript patterns post.
Readonly value objects with constructor promotion
Template parts that receive a dozen loosely-typed array keys are where WordPress bugs hide. A small readonly class makes the contract explicit:
<?php
// Before: get_template_part('template-parts/card', null, ['title' => ..., 'url' => ..., 'img' => ...]);
// and every template does isset($args['img']) ? $args['img'] : '' ...
// After (PHP 8.2 readonly class)
final readonly class CardData {
public function __construct(
public string $title,
public string $url,
public ContentType $type,
public ?string $imageId = null,
public ?string $excerpt = null,
public ?DateTimeImmutable $date = null,
) {}
public static function fromPost(WP_Post $post): self {
return new self(
title: get_the_title($post),
url: (string) get_permalink($post),
type: ContentType::fromPost($post) ?? ContentType::Insight,
imageId: get_post_thumbnail_id($post) ?: null,
excerpt: get_the_excerpt($post) ?: null,
date: new DateTimeImmutable($post->post_date_gmt, new DateTimeZone('UTC')),
);
}
}
// In the loop:
get_template_part('template-parts/card', null, ['card' => CardData::fromPost($post)]);
// template-parts/card.php
/** @var CardData $card */
$card = $args['card'];
?>
<article class="card card--<?php echo esc_attr($card->type->color()); ?>">
<?php if ($card->imageId): ?>
<?php echo wp_get_attachment_image($card->imageId, 'card', false, ['loading' => 'lazy']); ?>
<?php endif; ?>
<h3><a href="<?php echo esc_url($card->url); ?>"><?php echo esc_html($card->title); ?></a></h3>
</article>
Named arguments (title: …) make the constructor call readable even with six parameters, and readonly means nothing downstream can accidentally do $card->url = ''. The ?-> nullsafe operator and ?: replace three lines of isset each.
First-class callable syntax for hooks
<?php
final class ThemeSetup {
public function register(): void {
add_action('after_setup_theme', $this->supports(...)); // PHP 8.1: no more [$this, 'supports']
add_filter('excerpt_length', $this->excerptLength(...));
add_filter('body_class', $this->bodyClass(...));
}
private function supports(): void { add_theme_support('title-tag'); add_theme_support('post-thumbnails'); }
private function excerptLength(int $len): int { return 24; }
private function bodyClass(array $classes): array { return [...$classes, 'umm-theme']; } // spread with string keys works in 8.1+
}
(new ThemeSetup())->register();
PHP 8.4: property hooks and asymmetric visibility
Settings objects are the classic getter/setter swamp. Property hooks let you validate on write and compute on read while keeping plain property syntax for callers:
<?php
final class SiteSettings {
// Public to read, private to write: no more setX() just to protect invariants
public private(set) int $postsPerPage = 12;
public string $accentColor {
get => $this->accentColor;
set (string $value) {
if (!preg_match('/^#[0-9a-f]{6}$/i', $value)) {
throw new InvalidArgumentException("Invalid colour: {$value}");
}
$this->accentColor = strtolower($value);
}
}
public string $accentColorRgb { // virtual property, computed on read
get {
[$r, $g, $b] = sscanf($this->accentColor, '#%02x%02x%02x');
return "{$r} {$g} {$b}";
}
}
public static function load(): self {
$s = new self();
$s->accentColor = get_theme_mod('accent_color', '#0071e3');
return $s;
}
}
$settings = SiteSettings::load();
echo "<style>:root{--accent:{$settings->accentColor};--accent-rgb:{$settings->accentColorRgb}}</style>";
Check your host's PHP version before shipping hooks; as of this writing most managed WordPress hosts default to 8.2 or 8.3 with 8.4 available on request. Everything else in this post works on 8.2.
Fibers for parallel remote requests
A theme that calls three external APIs sequentially (weather, exchange rates, a CRM) adds their latencies together. WordPress' Requests library can already run requests concurrently, but Fibers (8.1) let you write concurrent code that still reads top to bottom. In practice I reach for the built-in Requests::request_multiple() first and Fibers only for complex orchestration:
<?php
// Simplest concurrency win in WordPress: batch the requests
$responses = Requests::request_multiple([
['url' => 'https://api.example.com/weather?city=Aligarh'],
['url' => 'https://api.example.com/rates?base=INR'],
], ['timeout' => 4]);
// ~max(latency) instead of sum(latency); cache the result in a transient
Strict types and typed properties
Add declare(strict_types=1); to every file you own. WordPress functions still return loosely typed values, so cast at the boundary ((int) get_option(...), (string) get_permalink(...)) and let your own code be strict from there. Typed properties turn "why is this null" from a runtime mystery into a TypeError with a stack trace.
<?php
declare(strict_types=1);
final class ProductPrice {
public function __construct(private int $cents, private string $currency = 'INR') {}
public function format(): string {
return number_format($this->cents / 100, 2) . ' ' . $this->currency;
}
}
// Boundary cast: WooCommerce returns strings
$price = new ProductPrice((int) round((float) $product->get_price() * 100));
The performance side
On the refactored theme, PHP 8.3 with opcache and JIT enabled rendered the archive template 22% faster than the same theme on PHP 7.4, measured with ab against a warm page cache disabled. Typed code helps opcache optimise; readonly classes avoid defensive cloning; match compiles tighter than switch. None of that matters as much as object caching or FastCGI caching, but it is free.
- Set
Requires PHP: 8.2in your theme or plugin header so WordPress refuses to activate it on older versions. - Run
phpcswithPHPCompatibilityWPto catch removed functions and deprecated dynamic properties. - Enable
WP_DEBUGlocally: PHP 8.2+ deprecations are exactly the warnings you want before a host upgrade. - Prefer
composerautoloading (PSR-4) over a chain ofrequire_once; it makes the class-based code above trivial to organise.
Modern PHP is a different language from the one WordPress' reputation is built on. Combined with a build pipeline (see Tailwind v4 with Vite) and containers (Docker for WordPress), a custom theme can be as pleasant to work on as any Node project, and considerably faster to serve.