A lot of production PHP still reads like it was written for PHP 5.6. It runs on PHP 8, but it ignores most of what PHP 8 added. This guide covers the modern PHP 8 features from 8.0 to 8.3 that I think belong in production code. Each one gets a before/after example and an honest note on when not to use it.
Nothing here is experimental. Every feature below is stable and well supported by static analysers like PHPStan and Psalm. Most of them remove boilerplate or move a bug from runtime to compile time.
Constructor property promotion: less boilerplate in PHP 8.0
Before PHP 8.0, every injected dependency was written three times: as a property, as a constructor parameter, and as an assignment.
// Before
class InvoiceMailer
{
private Mailer $mailer;
private LoggerInterface $logger;
public function __construct(Mailer $mailer, LoggerInterface $logger)
{
$this->mailer = $mailer;
$this->logger = $logger;
}
}
// After (PHP 8.0+)
class InvoiceMailer
{
public function __construct(
private Mailer $mailer,
private LoggerInterface $logger,
) {}
}
When not to use it: if the constructor has to validate or transform a value before storing it, a promoted parameter gets assigned before your validation runs. That can hide what is really happening. In that case, use a normal property and an explicit assignment.
Readonly properties and readonly classes for immutable value objects
PHP 8.1 added readonly properties. A readonly property can be set once, from inside the class, and never changed after that. PHP 8.2 went further with readonly classes, which make every property readonly and forbid dynamic properties.
final readonly class Money
{
public function __construct(
public int $amountInPaise,
public string $currency,
) {}
public function add(Money $other): self
{
if ($other->currency !== $this->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
return new self($this->amountInPaise + $other->amountInPaise, $this->currency);
}
}
$price = new Money(49900, 'INR');
$price->amountInPaise = 0; // Error: Cannot modify readonly property
Value objects, DTOs, configuration objects and API responses are the right fit. You no longer need getters just to stop other code from writing to a property.
When not to use it: readonly properties must be typed, and they cannot declare a default value on the property itself (a promoted constructor parameter can still have a default). Some older ORMs and serializers hydrate objects by writing properties after creation, and they will not work with readonly. Before PHP 8.3 you also could not reinitialise a readonly property inside __clone(), so “wither” methods had to build a new instance. For entities that change state, ordinary private properties with methods are still the honest design.
Enums replace magic strings and class constants
Enums arrived in PHP 8.1 and are the feature I would least want to give up. A backed enum gives you a closed set of values, type safety, and a clean way to map to and from the database.
// Before: any string slips through
const STATUS_DRAFT = 'draft';
const STATUS_PAID = 'paid';
function markAs(string $status) { /* ... */ }
// After (PHP 8.1+)
enum InvoiceStatus: string
{
case Draft = 'draft';
case Sent = 'sent';
case Paid = 'paid';
public function label(): string
{
return match ($this) {
self::Draft => 'Draft',
self::Sent => 'Awaiting payment',
self::Paid => 'Paid',
};
}
}
$status = InvoiceStatus::from($row['status']); // throws ValueError if invalid
$maybe = InvoiceStatus::tryFrom($_GET['s'] ?? ''); // returns null if invalid
Enums can have methods, constants and interfaces, and InvoiceStatus::cases() gives you every case for building a select menu.
When not to use it: if admins create the values at runtime (for example product categories), they belong in the database, not in an enum. Changing an enum means deploying code.
The match expression: strict and exhaustive
match (PHP 8.0) is what switch should have been. It returns a value, compares with ===, never falls through, and throws UnhandledMatchError when nothing matches. A switch would just fall out of the block silently.
// Before
switch ($code) {
case 200:
case 201:
$result = 'ok';
break;
case 404:
$result = 'missing';
break;
default:
$result = 'error';
}
// After
$result = match ($code) {
200, 201 => 'ok',
404 => 'missing',
default => 'error',
};
// Range checks with match(true)
$band = match (true) {
$score >= 90 => 'A',
$score >= 75 => 'B',
default => 'C',
};
When not to use it: each arm can hold only one expression. If a branch needs several statements, a switch or an if/elseif chain will read better than squeezing the work into a closure.
Named arguments, nullsafe operator and first-class callables
Named arguments (8.0)
Named arguments make calls with boolean flags or long optional parameter lists readable, and they let you skip defaults you don’t care about.
// Before: what do these values mean?
setcookie('session', $token, 0, '/', '', true, true);
// After
setcookie(
name: 'session',
value: $token,
path: '/',
secure: true,
httponly: true,
);
When not to use it: named arguments make parameter names part of your public API. If you maintain a library, renaming a parameter now breaks callers. Inside an application they are fine. In a published package, decide whether you want to promise stable names.
Nullsafe operator (8.0)
// Before
$country = null;
if ($order !== null) {
$customer = $order->customer();
if ($customer !== null) {
$address = $customer->address();
if ($address !== null) {
$country = $address->country;
}
}
}
// After
$country = $order?->customer()?->address()?->country;
If any link in the chain is null, the rest of the chain is skipped and the whole expression evaluates to null. When not to use it: if a null here means a bug, failing loudly is better than carrying a null forward. Long nullsafe chains often point to a design that should guarantee those objects exist.
First-class callable syntax (8.1)
// Before
$slugs = array_map([$this, 'slugify'], $titles);
$lengths = array_map('strlen', $titles);
// After
$slugs = array_map($this->slugify(...), $titles);
$lengths = array_map(strlen(...), $titles);
The (...) syntax creates a real Closure. IDEs and static analysers can follow it, and refactoring tools will rename it correctly. Callable strings and arrays hide the reference from those tools.
Safer code in PHP 8.1 to 8.3: never, typed constants, #[Override] and json_validate()
The never type and typed class constants
PHP 8.0 added union types (int|string) and mixed. PHP 8.1 added the never return type for functions that always throw or exit:
function abortWithJson(int $status, array $body): never
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($body);
exit;
}
Static analysers then know that code after abortWithJson() can’t be reached, which improves dead-code and null-safety checks.
PHP 8.3 finally lets class, interface and enum constants have types. A child class can no longer override a constant with a value of a different type:
interface HasCacheTtl
{
const int CACHE_TTL = 3600;
}
class ReportService implements HasCacheTtl
{
const int CACHE_TTL = 600; // fine
// const string CACHE_TTL = '1h'; // Fatal error in PHP 8.3
}
The Override attribute (8.3)
Mark a method with #[Override] to say “this is meant to override a parent or interface method”. If the parent method is renamed or removed later, PHP throws a fatal error when the class is loaded. Without the attribute, your method would quietly become dead code.
class CachedRepository extends Repository
{
#[Override]
public function findById(int $id): ?Product
{
return $this->cache->remember("product:$id", fn () => parent::findById($id));
}
}
I add it to every intentional override in new code. It costs nothing and catches a class of bug that is easy to miss after refactoring.
json_validate() (8.3)
// Before: decode just to check validity
json_decode($payload);
$isValid = json_last_error() === JSON_ERROR_NONE;
// After (PHP 8.3)
$isValid = json_validate($payload);
json_validate() checks the syntax without building the PHP arrays and objects, so it uses less memory on large payloads. When not to use it: if you will decode the data anyway, validating first just parses it twice. Call json_decode($payload, true, flags: JSON_THROW_ON_ERROR) and catch JsonException instead.
Other modern PHP 8 features worth knowing
str_contains(),str_starts_with(),str_ends_with()(8.0): no morestrpos(...) !== false.- Throw as an expression (8.0):
$user = $repo->find($id) ?? throw new NotFoundException(); newin initializers (8.1): default parameter values can be objects, e.g.Logger $logger = new NullLogger().array_is_list()(8.1): checks whether an array has sequential 0-based keys.- Deprecated dynamic properties (8.2): assigning an undeclared property now raises a deprecation. Declare your properties, or use
#[AllowDynamicProperties]only as a temporary fix in legacy code. #[SensitiveParameter](8.2): keeps passwords and tokens out of stack traces and logs.
PHP 8.4 and 8.5 go further with property hooks, asymmetric visibility and the pipe operator. Those deserve their own article, and you can only use them if your hosting has caught up.
When not to use modern PHP 8 features at all
The main constraint is your runtime, not your taste. If you ship a WordPress plugin or theme to the public, it has to run on the oldest PHP version you support. WordPress core still supports PHP versions older than 8.0, so a public plugin that uses enums or readonly properties needs a clear minimum version in its header. Client sites and SaaS apps where you control the server are a different story: use everything.
Also, don’t refactor working legacy code just to use new syntax. Adopt these features in code you are already changing, and let tools like Rector handle bulk upgrades with tests to back them up. If you are working in WordPress specifically, my guide to custom post types and ACF in a custom theme shows several of these patterns in a real theme.
Key takeaways
- Use constructor promotion and readonly classes for services, DTOs and value objects.
- Replace status strings and constant groups with backed enums.
- Prefer match over switch when each branch returns a value.
- Use named arguments freely in apps, carefully in public libraries.
- Add typed constants and #[Override] on PHP 8.3 to catch bugs at load time.
- Check your minimum PHP version before using any of these in distributed code.
Frequently asked questions
Which PHP 8 version should I run in production?
Run a version that still receives active or security support, which you can check on php.net’s supported versions page. For new projects, use the newest version your host and dependencies support, and plan an upgrade before your version reaches end of life.
Are PHP enums slower than class constants?
Enum cases are singleton objects created once per request, so the overhead is negligible in real applications. The safety you gain from a closed set of values is worth far more than any micro-difference.
Can I use readonly properties with Laravel or Doctrine?
Yes, for the right objects. Readonly works well for DTOs, value objects and Doctrine embeddables, and recent Doctrine ORM versions support readonly entity properties. Eloquent models rely on magic attributes rather than declared properties, so readonly does not apply to them.
Do modern PHP 8 features work in WordPress?
Yes, if the server runs a PHP version that supports them. For client sites you control, use them freely. For plugins or themes distributed publicly, declare a “Requires PHP” version that matches the features you use.
If you need a PHP codebase upgraded to PHP 8, or a new build written in clean, modern PHP from day one, get in touch and tell me about your project.