PHP 8 New Features Explained — Match, JIT, Nullsafe & More
PHP powers over 75% of the web's server-side code, yet for years developers quietly envied Python and JavaScript for their expressive, readable syntax. PHP 8 — released November 2020 — was the biggest leap the language had taken in a decade. It wasn't just bug fixes; it was a philosophical shift toward clarity, safety, and raw speed.
Before PHP 8, common tasks were riddled with boilerplate. Checking whether a deeply nested object property existed meant writing three levels of isset() calls. Comparing a value against a list of options required a verbose switch block. And performance tuning PHP was largely out of the developer's hands. Every one of these pain points got a targeted solution in PHP 8.
By the end of this article you'll understand not just the syntax of PHP 8's headline features — match expressions, the nullsafe operator, named arguments, union types, and the JIT compiler — but exactly when and why to reach for each one. You'll write cleaner application code, avoid the three gotchas that catch most developers, and be able to answer the interview questions that separate candidates who've actually used PHP 8 from those who just read the release notes.
Match Expressions — Switch Without the Foot-Guns
The switch statement has been in PHP since version 3, but it carries two dangerous quirks: loose type comparison and fall-through behaviour. Miss a break and execution silently bleeds into the next case. Use switch to compare '0' against 0 and they'll match because switch uses ==, not ===.
match fixes both problems at once. It uses strict equality (===) by default, returns a value directly (so you can assign it), and throws an UnhandledMatchError if no arm matches — making silent failures impossible.
Think of switch as a multi-lane motorway with no crash barriers. match is the same road with concrete dividers between every lane. The trip is the same, but an accident can't cascade.
Use match any time you're mapping a single value to a result. It's especially powerful for HTTP status code labels, role-based permissions, or state-machine transitions — anywhere your logic is a clean one-to-one mapping rather than complex conditional logic.
<?php // Simulating an order status coming from the database $orderStatus = 3; // OLD WAY: switch — loose comparison, easy to forget break, no return value // This is the kind of code that causes 2am on-call incidents. switch ($orderStatus) { case 1: $label = 'Pending'; break; case 2: $label = 'Processing'; break; case 3: $label = 'Shipped'; break; default: $label = 'Unknown'; } echo "Switch result: $label" . PHP_EOL; // NEW WAY: match — strict comparison, returns a value, exhaustive by default $label = match($orderStatus) { 1 => 'Pending', 2 => 'Processing', 3, 4 => 'Shipped', // multiple conditions share one arm — switch can't do this cleanly 5 => 'Delivered', default => 'Unknown', }; echo "Match result: $label" . PHP_EOL; // Demonstrating strict type safety — this is the killer feature $statusFromUrl = '3'; // user input is always a string $labelFromUrl = match($statusFromUrl) { 1 => 'Pending', 2 => 'Processing', 3 => 'Shipped', // '3' (string) does NOT match 3 (int) — strict comparison default => 'Type mismatch caught safely', }; echo "Strict check result: $labelFromUrl" . PHP_EOL; // What happens with no match and no default? try { $result = match(99) { 1 => 'One', 2 => 'Two', // no default — PHP 8 throws UnhandledMatchError instead of silently returning null }; } catch (\UnhandledMatchError $e) { echo "Caught: " . $e->getMessage() . PHP_EOL; }
Match result: Shipped
Strict check result: Type mismatch caught safely
Caught: Unhandled match case
Nullsafe Operator — Stop Writing Nested isset() Pyramids
Here's a scenario every PHP developer knows: you have a User object, which has an optional Address, which has an optional Country, and you need the country's ISO code. Before PHP 8, this required a defensive ladder of if checks or a chained nest of isset() calls that made the actual intent — 'give me the country code' — completely invisible.
The nullsafe operator ?-> short-circuits the entire chain the moment it hits a null. If any link in the chain is null, the whole expression returns null instead of throwing a fatal Trying to get property of non-object error.
The real-world parallel: imagine calling a phone tree. Instead of the automated system crashing when it can't find an extension, it just says 'not available' and hangs up gracefully. That's ?->.
Use the nullsafe operator when traversing optional relationships — exactly the kind you model with nullable foreign keys in a database. It's not a substitute for proper null handling in all cases; if a null result is unexpected, you still want to know about it via an exception rather than silently propagating null.
<?php // Domain model — typical in any Laravel or Symfony application class Country { public function __construct( public readonly string $name, public readonly string $isoCode ) {} public function getDiallingCode(): string { return match($this->isoCode) { 'GB' => '+44', 'US' => '+1', 'DE' => '+49', default => 'unknown', }; } } class Address { public function __construct( public readonly string $street, public readonly ?Country $country = null // country is optional ) {} } class User { public function __construct( public readonly string $name, public readonly ?Address $shippingAddress = null // address is optional ) {} } // --- Scenario 1: A fully populated user --- $user = new User( name: 'Elena Vasquez', shippingAddress: new Address( street: '12 Baker Street', country: new Country('United Kingdom', 'GB') ) ); // BEFORE PHP 8 — readable nightmare if ($user !== null && $user->shippingAddress !== null && $user->shippingAddress->country !== null) { $isoCodeOldWay = $user->shippingAddress->country->isoCode; } else { $isoCodeOldWay = null; } echo "Old way ISO code: " . ($isoCodeOldWay ?? 'N/A') . PHP_EOL; // PHP 8 nullsafe — one expression, same result, intent is crystal clear $isoCode = $user?->shippingAddress?->country?->isoCode; echo "Nullsafe ISO code: " . ($isoCode ?? 'N/A') . PHP_EOL; // Chaining method calls also works — not just property access $diallingCode = $user?->shippingAddress?->country?->getDiallingCode(); echo "Dialling code: " . ($diallingCode ?? 'N/A') . PHP_EOL; // --- Scenario 2: A guest user with no address --- $guestUser = new User(name: 'Guest'); // The chain short-circuits at ->shippingAddress (which is null) // No error thrown — just returns null cleanly $guestIsoCode = $guestUser?->shippingAddress?->country?->isoCode; echo "Guest ISO code: " . ($guestIsoCode ?? 'N/A') . PHP_EOL;
Nullsafe ISO code: GB
Dialling code: +44
Guest ISO code: N/A
Named Arguments and Union Types — APIs That Are Hard to Use Wrong
Two of PHP 8's quieter features have an outsized impact on code maintainability: named arguments and union types. They attack the same root problem from opposite directions — making function signatures impossible to misuse.
Named arguments let you pass values to a function by parameter name rather than position. This is transformative for functions with many optional parameters (think array_slice, htmlspecialchars, or any function you've ever had to look up just to remember what that third boolean does). It also means you can skip optional parameters in the middle without passing dummy values.
Union types let you declare that a parameter or return value can be one of several explicit types. Before PHP 8, developers used docblock comments like @param int|string $id — which IDEs respected but PHP itself ignored. Now the runtime enforces it, catching type errors at the boundary rather than somewhere deep in your logic where the original mistake is hard to trace.
Together, these features make your function signatures self-documenting and runtime-verified — a combination that eliminates entire categories of bugs.
<?php // Union types in a real repository pattern // The function genuinely accepts either an int ID or a string slug function findProduct(int|string $identifier, bool $includeArchived = false): array|null { // In a real app this would hit a database $products = [ ['id' => 1, 'slug' => 'wireless-keyboard', 'name' => 'Wireless Keyboard', 'archived' => false], ['id' => 2, 'slug' => 'usb-hub', 'name' => 'USB Hub', 'archived' => true], ]; foreach ($products as $product) { // Match by either integer ID or string slug — the union type makes both valid $matchesId = is_int($identifier) && $product['id'] === $identifier; $matchesSlug = is_string($identifier) && $product['slug'] === $identifier; if ($matchesId || $matchesSlug) { // Respect the archived filter if ($product['archived'] && !$includeArchived) { return null; // found it but it's hidden } return $product; } } return null; } // --- Named arguments in action --- // Old positional call — what does 'true' mean here? You have to read the signature. $result1 = findProduct('usb-hub', true); echo "Positional call: " . ($result1['name'] ?? 'Not found') . PHP_EOL; // Named argument call — reads like plain English, order doesn't matter $result2 = findProduct( identifier: 'usb-hub', includeArchived: true // the intent is immediately obvious ); echo "Named argument call: " . ($result2['name'] ?? 'Not found') . PHP_EOL; // Named arguments let you skip to only the params you care about // Without named args you'd need: findProduct(1, false) — the false is redundant noise $result3 = findProduct(identifier: 1); // includeArchived uses its default echo "Skip-to-param call: " . ($result3['name'] ?? 'Not found') . PHP_EOL; // Union type enforcement — PHP 8 will throw TypeError if you pass a float try { $invalid = findProduct(3.14); // float is not int|string } catch (\TypeError $e) { // The error is caught HERE, at the boundary, not buried in your logic echo "Type enforced at boundary: " . $e->getMessage() . PHP_EOL; } // Built-in functions work with named arguments too — this is genuinely useful $paddedId = str_pad(string: '42', length: 8, pad_string: '0', pad_type: STR_PAD_LEFT); echo "Padded ID: $paddedId" . PHP_EOL;
Named argument call: USB Hub
Skip-to-param call: Wireless Keyboard
Type enforced at boundary: findProduct(): Argument #1 ($identifier) must be of type int|string, float given
Padded ID: 00000042
JIT Compiler and Constructor Promotion — Speed and Less Boilerplate
The JIT (Just-In-Time) compiler is PHP 8's most talked-about feature and its most misunderstood. JIT compiles hot code paths to native machine instructions at runtime, bypassing the opcode cache step. For CPU-bound tasks — image processing, scientific calculations, complex algorithms — it can yield dramatic speedups. For typical web applications doing I/O (database queries, API calls), you'll see modest gains because the bottleneck was never CPU cycles.
Think of it this way: if your code is a chef cooking a meal, the opcode cache is having the recipe pre-printed. JIT is having the chef memorise the recipe so deeply they can cook it with their eyes closed, 20% faster. But if the bottleneck is waiting for ingredients to be delivered (I/O), the faster chef doesn't help much.
Constructor promotion is the unglamorous hero of PHP 8. It collapses the three-step ritual of declaring a property, adding a constructor parameter, and assigning it — into a single declaration in the constructor signature. Real projects with dozens of value objects see immediate and dramatic code reduction with zero behaviour change.
<?php // ============================================================ // CONSTRUCTOR PROMOTION — before and after // ============================================================ // BEFORE PHP 8 — the three-step ritual every developer got tired of class MoneyLegacy { private int $amountInPence; // Step 1: declare the property private string $currencyCode; private string $formattedSymbol; public function __construct( int $amountInPence, // Step 2: constructor parameter string $currencyCode, string $formattedSymbol ) { $this->amountInPence = $amountInPence; // Step 3: assign it $this->currencyCode = $currencyCode; $this->formattedSymbol = $formattedSymbol; } public function format(): string { return $this->formattedSymbol . number_format($this->amountInPence / 100, 2); } } // PHP 8 CONSTRUCTOR PROMOTION — same result, 60% less code // The 'private readonly' in the constructor IS the property declaration AND the assignment class Money { public function __construct( private readonly int $amountInPence, // declared, parameter, and assigned in one shot private readonly string $currencyCode, private readonly string $formattedSymbol ) {} public function format(): string { return $this->formattedSymbol . number_format($this->amountInPence / 100, 2); } public function add(Money $other): Money { // Ensures currencies match before adding — real-world guard if ($this->currencyCode !== $other->currencyCode) { throw new \InvalidArgumentException( "Cannot add {$this->currencyCode} to {$other->currencyCode}" ); } return new Money( amountInPence: $this->amountInPence + $other->amountInPence, currencyCode: $this->currencyCode, formattedSymbol: $this->formattedSymbol ); } } $price = new Money(amountInPence: 1999, currencyCode: 'GBP', formattedSymbol: '£'); $shipping = new Money(amountInPence: 499, currencyCode: 'GBP', formattedSymbol: '£'); $total = $price->add($shipping); echo "Item price: " . $price->format() . PHP_EOL; echo "Shipping: " . $shipping->format() . PHP_EOL; echo "Total: " . $total->format() . PHP_EOL; // Demonstrating the currency guard try { $usdPrice = new Money(amountInPence: 2000, currencyCode: 'USD', formattedSymbol: '$'); $price->add($usdPrice); // should throw } catch (\InvalidArgumentException $e) { echo "Caught: " . $e->getMessage() . PHP_EOL; } // ============================================================ // JIT — you enable it in php.ini, not in code // These settings are what you'd add to php.ini or a Docker config // ============================================================ // opcache.enable=1 // opcache.jit_buffer_size=100M // opcache.jit=tracing <- 'tracing' is the recommended mode for web apps // // Verify JIT is active at runtime: $jitStatus = opcache_get_status()['jit'] ?? null; if ($jitStatus) { echo "JIT enabled: " . ($jitStatus['enabled'] ? 'Yes' : 'No') . PHP_EOL; } else { echo "JIT status: OPcache not available in this environment" . PHP_EOL; }
Shipping: £4.99
Total: £24.98
Caught: Cannot add GBP to USD
JIT status: OPcache not available in this environment
| Feature | Before PHP 8 | PHP 8 Approach |
|---|---|---|
| Value comparison in branching | `switch` with loose `==`, no return value, needs `break` | `match` with strict `===`, returns a value, throws on no match |
| Null chain navigation | Nested `if ($obj !== null && $obj->prop !== null)` | `$obj?->prop?->subProp` — short-circuits to null cleanly |
| Multi-type parameters | Docblock `@param int|string` — honoured by IDEs, ignored by runtime | Native `int|string` union type — enforced by PHP engine at call time |
| Skip optional params | Must pass all positional args: `func(1, null, null, true)` | Named args: `func(first: 1, fourth: true)` — skips middle params |
| Value object boilerplate | Declare property + constructor param + `$this->x = $x` (3 steps) | Constructor promotion: `public function __construct(private int $x)` |
| CPU-bound performance | Interpreted via opcode cache — no native compilation | JIT compiler converts hot paths to native machine code at runtime |
🎯 Key Takeaways
matchuses strict===comparison and throwsUnhandledMatchErroron no match — this turns silent wrong-answer bugs into loud, traceable exceptions.- The nullsafe operator
?->is for expected nulls (optional relationships); don't use it to suppress errors that indicate a genuine bug in your logic. - Named arguments are tied to parameter names, not positions — renaming a parameter in a function that callers invoke with named syntax is a breaking change, even if the function is in your own codebase.
- Constructor promotion with
readonlyis the fastest path to bulletproof immutable value objects — the boilerplate reduction is real and it has zero runtime cost.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using
matchwith uncast user input — Symptom: your code always falls through todefaulteven when the value looks correct — Fix: user input from$_GET,$_POST, and URLs is always a string. Cast it before matching:match((int) $request->get('status'))or usematch(true)with explicit comparisons if the type is genuinely uncertain. - ✕Mistake 2: Treating the nullsafe operator as a null check replacement everywhere — Symptom: a
nullresult propagates silently through multiple layers, causing misleading output or a confusing error far from the source — Fix: use?->only when null is a valid, expected outcome (optional relationships, optional config). If null at that point would be a bug, use normal->so PHP throws immediately and you find the real problem faster. - ✕Mistake 3: Expecting JIT to speed up typical Laravel/Symfony apps significantly — Symptom: you enable JIT, benchmark a typical CRUD endpoint, and see no meaningful improvement — Fix: JIT helps CPU-bound work. A web request that spends 80% of its time waiting on a database query will see negligible gain. Benchmark with
opcache_get_status()['jit']to confirm JIT is active, then profile with Blackfire or Xdebug to find your actual bottleneck before optimising.
Interview Questions on This Topic
- QWhat is the key difference between `switch` and `match` in PHP 8, and can you give a scenario where using `switch` instead of `match` would introduce a bug?
- QHow does the nullsafe operator `?->` differ from a null check using `isset()` or a ternary? When would you deliberately NOT use it?
- QPHP 8's JIT compiler was heavily marketed as a performance breakthrough — but many developers report no improvement in their web apps. Why, and what types of workloads actually benefit from it?
Frequently Asked Questions
Is PHP 8 backward compatible with PHP 7 code?
Mostly, but not completely. PHP 8 removed several deprecated features from PHP 7 (like the each() function and certain error suppression behaviours) and made type handling stricter in some edge cases. The vast majority of well-written PHP 7 code runs on PHP 8 without changes, but you should run your test suite and check the official migration guide before upgrading a production application.
When should I use `match` versus `if/elseif` chains?
match is ideal when you're mapping a single expression to one of several possible values — essentially a lookup table. Use if/elseif when each branch needs complex conditional logic that involves multiple variables or operations. A good rule: if every condition starts with $sameVariable ===, it belongs in a match.
Does enabling JIT require any code changes?
No code changes are needed. JIT is purely a runtime configuration feature enabled via php.ini settings (opcache.jit and opcache.jit_buffer_size). Your existing PHP code runs unchanged — the engine simply compiles hot execution paths to native machine code transparently. You can verify it's active by calling opcache_get_status()['jit']['enabled'] at runtime.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.