We developers start every day standing at a crossroads. On one side, there is the allure of the latest and greatest – the shiny new features, the RFCs that just dropped, and the tools that promise to revolutionise our workflow. On the other side lies reality: a growing list of bug fixes, looming deadlines, and the day job that actually pays the bills.
It is a common scenario for all of us. I look around at conferences and see rooms full of people who have traveled far and wide, enthusiastic to learn, yet we all face the same blockers when we return to our desks. Despite our best intentions to stay current, the pressure to ship often forces us down the path of least resistance. It is safer, and often faster, to copy a familiar pattern from elsewhere in the codebase – even if it’s five years old – than to research and implement a new language feature.
This leads to a subtle ossification of our applications and us as developers. We end up in a situation where we might have 20 years of experience, but it looks suspiciously like the same year of experience repeated 20 times! This reality is reflected in the PHP ecosystem statistics, where sticky older versions like PHP 7.4 persist in the usage charts. Even those of us lucky enough to be running PHP 8.3 or 8.4 are often guilty of writing “PHP 7 code in a PHP 8 world”.
But modernisation doesn’t require a “stop the world” rewrite. By adopting specific, high-impact features introduced in recent versions, we can achieve what we all want: a strong, stable core for our applications. This article explores these features. This is not an exhaustive list of every shiny new toy, but more a curated guide to small changes that yield big wins for your applications and your overall quality of life!
Cementing the Foundation: Data Integrity
When building a house, you start with the foundation. For developers, our foundation is our data. Is it behaving the way we think it is? Is it validated correctly? Can we rely on it?
The Tyranny of Magic Strings
Let’s look at a pattern we have all written a thousand times. You have an Article object with a status property. You write a conditional to handle the flow:
if ($article->status === 'published') {
publishToFeed($article);
} elseif ($article->status === 'revieew') { // Oops
sendToEditor($article);
} else {
logError('Invalid status: ' . $article->status);
}
If you look closely at the code above, you might notice the very eccentric spelling of ‘review’. Because this is a magic string – a string literal with special meaning but no formal definition – PHP happily accepts my fat-fingered typo. Your IDE can’t help you because, as far as it knows, you meant to type ‘revieew‘. The result is a subtle bug where articles that should be under review silently fall into the else block and trigger an error state.
These magic strings are fragile. They are hard to maintain, and they offer zero support from our tooling.
The Solution: Backed Enums (PHP 8.1)
Enums (Enumerated Types) are often misunderstood as just a fancy way to group constants. While they do group constants, their real power lies in the fact that they are a type understood natively by PHP.
By defining an ArticleStatus Enum, we replace fragile strings with robust types:
enum ArticleStatus: string {
case Draft = 'draft';
case Review = 'review';
case Published = 'published';
}
Now we can type-hint our methods. Let’s consider the case of setting the status on an article, based on a form a user has submitted.
$status = $_POST['status'] ?? null;
// Some validation on $status, then...
$article->setStatus($status);
public function setStatus(string $status): void
{
// Do we need to validate $status again, just in case?
...
$this->status = $status;
}
Instead of accepting a generic string $status, we accept ArticleStatus $status. If we try to pass a typo or an invalid string to a function expecting this Enum, PHP throws a TypeError immediately.
$status = ArticleStatus::tryFrom($_POST['status] ?? null);
$article->setStatus($status);
public function setStatus(ArticleStatus $status): void
{
$this->status = $status;
}
The “Marketing Manager” Problem
Enums become even more powerful when we use them to centralise domain logic. Consider the presentation layer. Let’s say we have a search filter dropdown iterating over our statuses, capitalising the first letter to create a label: “Draft”, “Review”, “Published”.
<select name="status">
@foreach (['draft', 'review', 'published'] as $status)
{{ ucfirst($status) }}
@endforeach
</select>
Then, a new Marketing Manager joins the team. They decide that “Draft” isn’t punchy enough. They want it to say “Pre-publication”.
A developer picks up the ticket, finds the specific loop in the search filter, and hard-codes the change. But they miss the status pill on the article detail page. Suddenly, you have the same data concept labeled differently across your app. This leads to the kind of “Inconsistent Labels” Jira ticket that absolutely ruins your Friday afternoon.
Remember that Enums are more than just a collection of constants. We can solve this by adding a method directly to the Enum itself:
public function label(): string {
return match($this) {
self::Draft => 'Pre-publication',
self::Review => 'Awaiting Review',
self::Published => 'Live',
};
}
Now, the presentation layer doesn’t need to know what the label is; it just asks the Enum for its label. We update it in one place, and consistency flows through the entire application.
<!-- Our filter -->
<select name="status">
@foreach (ArticleStatus::cases() as $status)
<option value="{{ $status->value }}">
{{ $status->label() }}
</option>
@endforeach
</select>
<!-- Article detail page -->
<div class="pill">
{{ $article->status->label() }}
</div>
Validating Input with tryFrom
Input validation is another area where Enums shine. Typically, you might check if a submitted string exists within an array of valid options using in_array(). This leads to validation logic scattered throughout controllers, and inevitably, somebody updating the “magic list” in one place but not the others!
$status = $_POST['status'] ?? null;
// Check the magic list of accepted values..
if (!in_array($status, ['draft', 'review', 'published'])) {
throw new InvalidArgumentException('Invalid status = '.$status);
}
With Enums, you can use the tryFrom() method:
$status = ArticleStatus::tryFrom($_POST['status'] ?? '');
If the input matches a valid backing value, you get the Enum instance. If not, it returns null. This allows you to fail fast and eliminates the need for manual validation logic. You check it once, convert it to an Enum, and from that point forward, your application relies on a strict type rather than a vague string.
Immutable Data: Readonly Properties and Classes
We often encounter data that should not change once it is created, such as a Data Transfer Object (DTO) or a Value Object representing a physical address.
Consider a standard Address class with public properties. You might instantiate an address for “123 Main St, Dublin, Ireland.” Ten lines later, a developer creates a bug by accidentally reassigning the country:
$address = new Address('123 Main St', 'Dublin', 'Ireland');
// ... some logic ...
$address->country = 'United Kingdom';
Taking the geopolitical implications of moving Dublin to the UK aside, this is a code issue we want to avoid. We don’t want the state of our objects to change unexpectedly after instantiation.
The Fix: Read-only (PHP 8.1/8.2)
By adding the readonly modifier to the property (or the class in PHP 8.2), we instruct PHP to enforce immutability.
readonly class Address {
public function __construct(
public string $street,
public string $city,
public string $country
) {}
}
Now, if code attempts to modify the $country property after initialisation, PHP throws a loud, fatal error: Cannot modify readonly property. We have converted a potential silent logic failure – one that could corrupt tax calculations or shipping routes – into an immediate crash that forces us to fix the bug. This is part of a broader trend in these PHP updates – helping protect us from ourselves, and the previously silent failures which have tripped so many of us up over the years.
The “Gotcha”: Internal Mutability
There is a nuance to readonly that can trip developers up: Objects within readonly properties can still be mutated unless they are also immutable.
Consider an Article class with a public readonly DateTime $publishedAt property. If you try to replace $publishedAt with a new DateTime object, PHP will stop you. However, if you call a modifier method on the object itself, PHP will allow it:
// This throws an error
$article->publishedAt = new DateTime(...);
// But this is allowed!
$article->publishedAt->modify('+1 month');
Even though the property is read-only, the internal state of the DateTime object is not. It is not “frozen” in stone. To achieve true immutability, you must ensure the types you use are also immutable, such as using DateTimeImmutable instead of DateTime. If you switch to DateTimeImmutable, the modify method returns a new object rather than changing the existing one, effectively locking down the state.
Framing the Structure: Control Flow & Safety
Once we have a stable core of data, we need to frame the structure of our application logic. We want to keep out the wind and the rain; we want to avoid ambiguity and silent failures that let bugs slip through unnoticed.
Precision with Union & Intersection Types
One challenge we have battled for years involves systems that look up data by ID. Originally, your find($id) function accepted an integer. Then, an SEO audit happened, and suddenly, you needed to support slugs. So, you removed the type hint and updated the DocBlock to say @param int|string $id. But as we know, comments aren’t contracts.
A DocBlock is a hint, not a guarantee. In a legacy codebase, nothing stops a developer from passing a float, an array, or a null into that function. The application won’t crash at the entry point, but will crash deep inside the logic, creating another type of silent failure that is painful to debug.
Union Types (PHP 8.0) allow us to move that logic from the comment into the code:
function find(int|string $id)
{
// ...
}
Now, the contract is enforced by the engine. If you pass an array, it explodes immediately.
Intersection Types (PHP 8.1) handle the opposite problem. Sometimes, you don’t care what an object is, only what it can do.
function handle(Cacheable & Responder $component)
{
$key = $component->getCacheKey();
$response = $component->respond();
// ...
}
Here, we aren’t forcing the component to inherit from a specific parent class. We are saying, “I don’t care what class you are, as long as you satisfy the Cacheable AND Responder contracts.” It eliminates ambiguity and creates precise, enforceable boundaries in your application structure.
The “Switch” Trap
The switch statement is a common source of bugs. Many developers have a mental model of it as being similar to an if/else, when in reality it is essentially a glorified goto statement. It suffers from two major issues:
- Fall through: If you forget a break, execution continues into the next case.
- Type Coercion: switch uses loose comparison (==).
Consider a switch statement, checking a status. If case ‘review‘ matches but you forget the break, the code falls through and executes case ‘published‘ immediately after. You end up with an article that is theoretically “Under Review” but is actually labeled “Published”.
$status = "review";
switch ($status) {
case "draft":
$label = "Draft";
case "review":
$label = "Under Review";
case "published":
$label = "Published";
}
echo $label;
// Expecting: "Under Review", but result:
// Published
To fix this in a switch statement, we have to litter the code with break statements, making the statement about 33% longer just to manage the boilerplate.
$status = "review";
switch ($status) {
case "draft":
$label = "Draft";
break;
case "review":
$label = "Under Review";
break;
case "published":
$label = "Published";
break;
}
echo $label; // "Under Review"!
The Solution: Match Expressions (PHP 8.0)
The match expression addresses these flaws head-on. It uses strict comparison (===) and prevents fall-through automatically.
$label = match ($status) {
'draft' => 'Draft',
'review' => 'Under Review',
'published' => 'Published',
};
Perhaps most importantly, match must return a value. In a switch statement, if no case matches and there is no default, the code simply proceeds, potentially leaving variables undefined. With match, if no condition is met, PHP throws an UnhandledMatchError.
This turns a silent failure mode into something loud, aggressive, and in-your-face. While a fatal error sounds scary, it prevents those “niggly paper cuts” where invalid states persist in your database for months because the code silently failed to handle a specific case.
Refactoring Tip: match(true)
A powerful pattern is match(true). Instead of matching a value, you match the boolean true against a series of expressions. The first expression that evaluates to true wins. This is excellent for replacing complex if/elseif chains, such as determining age ranges:
$result = match (true) {
$age >= 65 => 'senior',
$age >= 25 => 'adult',
default => 'kid',
};
This syntax flips the logic around and makes complex conditionals much easier to scan. The PHP Docs include a nice example of using this structure to solve FizzBuzz, which is worth checking out.
Named Arguments: Solving the “Mystery Boolean”
We have all seen legacy functions that have grown “Christmas tree” ornaments over time – optional parameters tacked onto the end.
sendNotification($user, 'Subject', 'Body', true, false, true);
What do those booleans do? Is the first one “urgent”? Is the second one “send email”? Who knows? You have to dig into the function definition to find out.
Named arguments (PHP 8.0) solve this readability issue:
sendNotification(
user: $user,
subject: 'New Comment',
body: 'Someone replied!',
urgent: true,
ccTeam: false,
addTrackableLinks: true
);
This is self-documenting. It also makes your code refactor-safe. If the parameter order changes in the function definition, your named arguments will still work perfectly because they are bound by name, not position.
This is also helpful when you are dealing with a parameter that has picked up a lot of optional arguments over the years, and you only care about setting the last one. PHP historically doesn’t let you set a later optional argument and leave an earlier one empty, so often you’d end up copying in default values for the earlier parameters just to get to the one you care about. Then later on, someone changes the default, and your code is now setting values it didn’t really care about in the first place. Note $subject in the example below – we only ever wanted to set $metadata, but had to set a default subject, which has since changed in the constructor.
readonly class ArticleDTO
{
public function __construct(
public string $title,
public ArticleStatus $status,
public UserRole $authorRole,
public string $subject = 'Updated default subject',
public array $metadata = []
) {}
}
$article = new ArticleDTO(
'Modern PHP Features',
ArticleStatus::Published,
UserRole::Editor,
'Default subject', <-- Don't care, but needed to fill it ['key' => 'val'] <-- The one we care about setting!
);
With named parameters, because the order doesn’t matter, there’s no longer a need to set earlier optional values in the argument list. We can omit them altogether and only set the values we care about.
$article = new ArticleDTO(
title: 'Modern PHP Features',
status: ArticleStatus::Published,
authorRole: UserRole::Editor,
metadata: ['key' => 'val']
);
A word of warning: don’t overuse this for simple functions with one or two arguments, or your controllers will start to look like YAML soup! But for those complex legacy functions, it can be a lifesaver and a great help for readability.
Making it a Home: Quality of Life Improvements
Finally, we want to make our codebase a nice place to live. We want to plant a garden, paint the walls, and generally reduce the cognitive load required to work in the application.
Boilerplate Reduction: Constructor Property Promotion (PHP 8.0)
Historically, creating a simple class in PHP involved a lot of boilerplate: defining properties, writing the constructor, and assigning arguments to properties. It was repetitive and prone to drift if you changed a variable name in one place but missed another.
class Article
{
private string $title;
private string $author;
private bool $published;
public function __construct(
string $title,
string $author,
bool $published
) {
$this->title = $title;
$this->author = $author;
$this->published = $published;
}
}
Constructor Property Promotion collapses this entire dance into a single definition:
class Article
{
public function __construct(
private string $title,
private string $author,
private bool $published
) {}
}
It is cleaner, shorter, and removes the noise. Personally, deleting 20 lines of boilerplate and replacing them with just 4 or 5 gives me a significant dopamine hit! We’re declaring the variables and their visibility in one go, while also allowing them to be automagically assigned.
A Note on AI: I recently ran an experiment asking several AI coding assistants (ChatGPT, Claude, Gemini) to generate a simple PHP class. Interestingly, almost all of them defaulted to the old, verbose, pre-PHP 8.0 syntax. When I challenged them on why they didn’t use promoted properties, they admitted they knew the better way, but “defaulted to the traditional way out of habit”. It was a fascinating Turing Test moment – the AI proved it was just as prone to bad habits as a human developer who hasn’t updated their knowledge in five years! This makes sense, with the AIs using statistical models – there are way more examples out there of older code than new. However, it serves as a reminder that we cannot blindly rely on AI to modernise our code. We have to know what features exist so we can ask for them explicitly.
Property Hooks (PHP 8.4)
A brand-new feature, Property Hooks, allows us to define get and set logic directly on a property. This eliminates the need for verbose getter and setter methods that clutter up our classes.
public string $fullName {
get => $this->first . ' ' . $this->last;
set => [$this->first, $this->last] = explode(' ', $value);
}
This keeps the logic for a property co-located with the definition of the property itself. It feels very similar to computed properties in languages like Swift or C#, showing how PHP continues to evolve by learning from other ecosystems.
The #[Override] Attribute (PHP 8.3)
In object-oriented PHP, it is easy to accidentally break an application when refactoring a parent class. If you rename a method in a parent class, but a child class was overriding that method, the child class’s method is now technically a new method, not an override. The link is broken silently, and the parent method starts executing instead of the child’s logic.
I recently encountered this with a subtle typo: a child class implemented handelRequest (misspelled), while the parent had handleRequest. There was no syntax error, just a silent failure where the wrong function ran.
By adding the #[Override] attribute, you explicitly tell the PHP engine: “I intend for this to override a parent method.”
#[Override]
public function handleRequest() { ... }
If the parent method is renamed or removed (or if you have a typo), PHP will complain, throwing a fatal error at compile time. This turns another quiet mistake into a loud one, allowing you to catch inheritance bugs instantly during development. As of PHP 8.5, this attribute can now be applied to properties, not just methods.
New Array Helpers (PHP 8.4)
For 25 years, PHP developers have struggled to remember the specific invocation for reset(), end(), or array_shift() just to get the first or last item of an array. Is it passed by reference? Does it modify the array? I’ve been writing PHP for decades, and I still have to look it up!
PHP 8.4 introduces clear, descriptive helper functions: array_find, array_first, array_last, and array_any. While it’s easy to dismiss these changes as “syntactic sugar,” I like to think of it as the kind of sugar you get from fresh fruit, not the artificial stuff in a Diet Coke! It makes the code inherently more readable and reduces the cognitive load required to understand what an array operation is actually doing.
The Pipe Operator: Cleaning Up the “Inside-Out” Read
We have all written code that looks like this:
$result = str_shuffle(strtoupper(trim($input)));
To understand what is happening here, your brain has to work backwards. You start in the middle ($input), read out to trim, then out to strtoupper, and finally to str_shuffle. It is “inside-out” logic. We often try to fix this by putting each function on a new line, but then you are reading right-to-left and bottom-to-top.
PHP 8.5 introduces the Pipe Operator (|>), which allows us to structure this sequentially:
$result = $input
|> trim(...)
|> strtoupper(...)
|> str_shuffle(...);
Now, the code flows from top to bottom, left to right – exactly how we read text.
The “How” of Upgrading: Bridging the Gap
I realise many of you might be reading this thinking, “This looks great, Paul, but my production server is still running PHP 7.4, and there is no upgrade in sight. How will I ever get to use any of this new stuff in my app?”
The good news is that you can still use many of these features today via Polyfills. The Symfony team maintains a robust set of polyfills that backport modern PHP functions and classes to older versions. For example, if you want to use the new array_first() function but you are on PHP 8.0, you can install the polyfill. It checks if the function exists natively; if not, it provides a PHP userland implementation.
This allows you to write “future-proof” code right now. When your server eventually upgrades to the latest version, the polyfill steps aside, and your code uses the native, optimised implementation automatically. It is a seamless way to bridge the gap and start modernising your codebase incrementally without waiting for a massive infrastructure overhaul.
Conclusion: The Evolution of a Language
It is easy to look at features like Property Hooks (inspired by C#) or the Pipe Operator (common in F# and Elixir) and think that PHP is losing its identity. But the opposite is true.
Think of the English language. It famously borrows vocabulary from other languages. “Kindergarten” is German. “Government” is French. “Rodeo” is Spanish. English didn’t lose its identity by adopting these words; it became richer and more expressive by integrating concepts that worked well elsewhere.
PHP is doing the exact same thing. It is a mature, pragmatic language. It isn’t dogmatic. It observes what works well in the broader ecosystem – whether that’s immutability, type safety, or ergonomic syntax – and it adopts those features with a distinctly PHP flavor.
Modernising your legacy application is about embracing this evolution. It doesn’t require a “stop the world” rewrite. It happens in the small moments: replacing a list of constants with an Enum, adding readonly to a DTO, or swapping a fragile switch for a robust match expression.
These features – Enums, Readonly, Named Arguments, Match, Property Hooks, and Attributes – are just a small set of the goodies made available in recent PHP releases, but they provide clearer intent, safer defaults, and expressive data. They turn quiet, insidious bugs into loud, fixable errors. They reduce boilerplate and let us focus on the business logic that actually matters.
So, here is the challenge: Pick one feature from this article. In your next pull request, try to replace a set of constants with an Enum, or use named arguments in a confusing function call. Start small. You will find that these small upgrades accumulate into big quality-of-life wins for you and your team.
Enjoy a better app, and an easier life!
Author
🔍 Frequently Asked Questions (FAQ)
1. What are the risks of using "magic strings" in PHP?
Magic strings are untyped string literals with implicit meaning. They offer no IDE support or compile-time validation, leading to bugs when typos occur. PHP treats these strings as valid, making bugs silent and hard to detect.
2. How do Enums improve code safety in PHP 8.1?
Enums replace unstructured magic strings with type-safe, native constructs. They allow strict type hinting, meaning invalid values like typos cause immediate errors. This shifts validation from runtime to compile time.
3. How can PHP Enums centralize domain logic for better UI consistency?
PHP Enums can include methods like label(), which return consistent display values across UI components. This reduces scattered label logic and prevents mismatches when terms change.
4. What is the benefit of readonly properties and classes in PHP 8.1/8.2?
Readonly enforces immutability after object construction. This prevents accidental mutations of objects, catching logic errors that could otherwise silently corrupt application state.
5. Why are Union and Intersection Types important in modern PHP?
Union types allow functions to accept multiple types explicitly, while intersection types require parameters to satisfy multiple interfaces. These enforce precise, self-documenting contracts and reduce silent failures from incorrect input.
6. What problems do switch statements introduce in PHP?
Traditional switch statements allow fall-through and use loose comparisons, leading to subtle bugs. Missing break statements or unintended type coercions can cause incorrect logic flow.
7. How does the match expression improve control flow in PHP 8.0?
Match uses strict comparison (===) and enforces exhaustive handling. It avoids fall-through bugs and guarantees that unmatched cases result in a compile-time error, making failures obvious and fixable.
8. What do named arguments solve in legacy PHP function calls?
Named arguments improve readability and refactor-safety by allowing parameters to be passed by name. This eliminates ambiguity, especially in functions with many optional or boolean parameters.
9. How does Constructor Property Promotion reduce boilerplate in PHP 8.0?
This feature combines property declaration and constructor assignment into a single line. It makes code more concise and reduces the likelihood of errors from duplicated variable declarations.
10. What is the purpose of the #[Override] attribute in PHP 8.3?
#[Override] ensures that a method is truly overriding a parent method. If the method in the parent is missing or renamed, PHP throws a fatal error, preventing silent logic failures due to inheritance bugs.




