PHP 8 New Features: Nullsafe Operator Causing Payment Bug
Checkout completed without errors but no payment charged — nullsafe hid a gateway timeout.
20+ years shipping production PHP systems at scale. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Match expressions use strict
===and return a value — no fall-through, no silent null - Nullsafe operator
?->short-circuits to null on anynullin the chain - Named arguments skip positional confusion — pass by parameter name, not order
- Union types enforce
int|stringat runtime, not just in docblocks - JIT compiles hot paths to native code — helps CPU-bound tasks, not I/O
- Biggest mistake: using
matchwith uncast user input — it's strict about types
PHP 8, released in November 2020, is a major version that fundamentally changes how you write PHP by introducing syntax-level safety and performance improvements. The nullsafe operator (?->) lets you chain method calls on potentially null values without nested checks — instead of writing isset()$user ? $user->getProfile() ? $user->getProfile()->getAddress() : null : null, you write $user?->getProfile()?->getAddress().
This eliminates entire categories of null-check boilerplate but, as the article's payment bug demonstrates, can silently swallow nulls that should throw exceptions, masking logic errors in critical paths like financial transactions. Match expressions replace the error-prone switch with strict type comparison and no fall-through — each arm is an expression that returns a value, and unmatched values throw a UnhandledMatchError instead of silently continuing.
Named arguments let you pass parameters by name (sendEmail(to: 'user@example.com', subject: 'Hello')) rather than position, making API calls self-documenting and allowing you to skip optional parameters without dummy values. Union types (int|string) enforce type flexibility at runtime — a parameter can accept multiple types without resorting to mixed or docblock annotations.
Constructor promotion collapses property declaration and assignment into a single line (public function __construct(private int $id) {}), while JIT compilation (Just-In-Time) compiles PHP bytecode to machine code at runtime, yielding 2-3x performance gains for CPU-bound workloads. These features collectively reduce boilerplate by 30-50% in typical codebases but require careful migration — the nullsafe operator alone has caused production outages when developers assumed it behaved like optional chaining in JavaScript or TypeScript, where null propagation is explicit and expected.
Imagine your kitchen just got a full renovation. The stove still works the same way, but now you have an induction cooktop (faster, smarter), a smart fridge that doesn't crash when the milk is missing (nullsafe operator), and magnetic labels you can stick on ingredients in any order (named arguments). PHP 8 is that renovation — same language, but the daily frustrations are gone and everything runs noticeably faster.
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 calls. Comparing a value against a list of options required a verbose isset()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.
What PHP 8's Nullsafe Operator Actually Changes
PHP 8 introduced the nullsafe operator (?->) as a syntactic shortcut for chained method or property calls where any intermediate value might be null. Instead of writing nested null checks like if ($user !== null && $user->getProfile() !== null) { ... }, you write $user?->getProfile()?->getName(). The operator short-circuits: if the left-hand side is null, the entire expression evaluates to null without evaluating the right side. This is not a try-catch for null — it's a conditional access chain that returns null at the first null encountered. The operator works with methods, properties, and static calls, but it does not suppress errors or warnings; it only handles null. In practice, this means you get a null result instead of a "Call to a member function on null" error, which can silently propagate null through your system. Use it for optional chaining where null is a valid, expected state — not as a blanket null-safety net. The real danger: when a null unexpectedly appears in a chain that should never be null, the operator hides the bug instead of failing loudly, turning a crash into a subtle data corruption or payment processing error.
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; }
switch to match and your data is coming from user input or a URL (always a string), your previously-matching integers will suddenly miss. Cast your input to the correct type before passing it to match, or you'll replace silent wrong answers with unexpected UnhandledMatchError exceptions.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 calls that made the actual intent — 'give me the country code' — completely invisible.isset()
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;
null on a short-circuit, so pair it with the null coalescing operator ?? to supply a default in one readable line: $label = $user?->profile?->displayName ?? 'Anonymous';. This pattern is idiomatic modern PHP.?-> only for optional data — never for critical path data that must exist.-> to throw.?? for defaults, but validate external API responses separately.Named Arguments — API Calls That Read Like English
Named arguments let you pass values to a function by parameter name instead of position. This is a game-changer for functions with many optional parameters — the kind you always need to look up. No more passing null, null, true just to skip to the last parameter.
The real win is readability: htmlspecialchars($string, double_encode: false) tells you exactly what's being skipped and what's being set. Positional calls with three true values are a guessing game.
Named arguments also let you reorder parameters however you like — only the names matter. This makes internal refactoring risky if you rename a parameter, because callers using named syntax will break. It's a double-edged sword: cleaner APIs but tighter coupling to parameter names.
Use named arguments for public API calls (especially built-in functions) and for constructors with many optional parameters. Be more cautious inside your own codebase — parameter renames become breaking changes.
<?php // A function with many optional parameters — typical in formatting utilities function formatDate( DateTimeInterface $date, string $format = 'Y-m-d', bool $includeTime = false, bool $utc = false, string $locale = 'en' ): string { if ($utc) { $date = (clone $date)->setTimezone(new DateTimeZone('UTC')); } $formatted = $date->format($format); if ($includeTime) { $formatted .= ' ' . $date->format('H:i:s'); } return $formatted; } $now = new DateTimeImmutable(); // Old positional call — what do these booleans mean? You have to count. echo "Positional: " . formatDate($now, 'c', true, false, 'en') . PHP_EOL; // Named argument call — reads like plain English, order doesn't matter echo "Named: " . formatDate( date: $now, format: 'c', includeTime: true ) . PHP_EOL; // Skip optional params without dummy values echo "Only date: " . formatDate(date: $now, utc: true) . PHP_EOL; // Built-in functions also support named arguments $padded = str_pad(string: '42', length: 8, pad_string: '0', pad_type: STR_PAD_LEFT); echo "Padded: $padded" . PHP_EOL;
@param docblock with both old and new names during migrations. In large codebases, consider using an options object pattern instead of many optional parameters.$userId to $customerId in a public library.userId: 42 got silent errors — no warning.Union Types — Runtime Enforced Type Flexibility
Union types were a long-requested feature. Before PHP 8, you could document that a parameter accepts int|string in a docblock, but PHP wouldn't enforce it. If you passed a float, it silently corrupted your logic. Union types make the engine enforce the contract at runtime.
Declare a parameter as int|string and PHP will throw a TypeError if you pass anything else — a float, an array, an object. The error is thrown at the function boundary, not deep inside where it's hard to trace.
Union types also work for return values, so you can declare that a function returns User|null and never have to guess what happens when a user isn't found.
Use union types anytime a function legitimately accepts or returns more than one type. The most common patterns are: int|string for IDs, array|null for optional collections, and YourType|false for legacy return-on-failure patterns (though ?YourType is preferred for new code).
<?php // Union type on both parameter and return function findUser(int|string $identifier): array|null { // Simulate a database lookup $users = [ ['id' => 1, 'email' => 'alice@example.com', 'name' => 'Alice'], ['id' => 2, 'email' => 'bob@example.com', 'name' => 'Bob'], ]; foreach ($users as $user) { if (is_int($identifier) && $user['id'] === $identifier) { return $user; } if (is_string($identifier) && $user['email'] === $identifier) { return $user; } } return null; } // Works with int $user1 = findUser(1); echo "By ID: " . ($user1['name'] ?? 'not found') . PHP_EOL; // Works with string $user2 = findUser('bob@example.com'); echo "By email: " . ($user2['name'] ?? 'not found') . PHP_EOL; // TypeError on invalid type — this is the key benefit try { $user3 = findUser(3.14); // float is not int|string } catch (\TypeError $e) { echo "Type caught: " . $e->getMessage() . PHP_EOL; } // Return type enforced: function promises array|null try { $result = findUser(99); // null returned, fine var_dump($result); } catch (\TypeError $e) { echo "Return type error: " . $e->getMessage() . PHP_EOL; }
- Before: docblock says int|string but PHP accepts any type.
- Now: PHP throws TypeError at call time if you pass a float.
- The error is caught at the function boundary, not buried in logic.
- Return types also enforced — no more guessing if the function returns User or null.
Constructor Promotion and JIT — Less Boilerplate, More Speed
Constructor promotion collapses the three-step property declaration ritual into a single line in the constructor signature. It's not just syntactic sugar — it removes an entire class of bugs where you declare a property but forget to assign it.
The JIT compiler is the other half of the story. It compiles hot code paths to native machine instructions at runtime. For CPU-bound work like image processing, serialization, or complex algorithms, this can yield 20-50% speedups. But for typical web apps where the bottleneck is I/O (database queries, API calls), you'll see maybe 5-10% improvement.
The trick is to enable JIT with the right mode: tracing for web apps, function for CLI scripts. Don't expect magic from JIT — profile first, then optimise the actual bottleneck. If your app is CPU-bound, JIT is a free performance upgrade. If your app is I/O-bound, spend your time on query optimization and caching instead.
<?php // ============================================================ // CONSTRUCTOR PROMOTION — before and after // ============================================================ // BEFORE PHP 8 — the three-step ritual class MoneyLegacy { private int $amountInPence; private string $currencyCode; private string $formattedSymbol; public function __construct( int $amountInPence, string $currencyCode, string $formattedSymbol ) { $this->amountInPence = $amountInPence; $this->currencyCode = $currencyCode; $this->formattedSymbol = $formattedSymbol; } public function format(): string { return $this->formattedSymbol . number_format($this->amountInPence / 100, 2); } } // PHP 8 constructor promotion — 60% less code, same behaviour class Money { public function __construct( private readonly int $amountInPence, 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 { 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->format() . PHP_EOL; echo "Shipping: " . $shipping->format() . PHP_EOL; echo "Total: " . $total->format() . PHP_EOL; // Currency guard in action try { $usd = new Money(amountInPence: 2000, currencyCode: 'USD', formattedSymbol: '$'); $price->add($usd); } catch (\InvalidArgumentException $e) { echo "Caught: " . $e->getMessage() . PHP_EOL; } // ============================================================ // JIT configuration (in php.ini, not in code) // ============================================================ // opcache.enable=1 // opcache.jit_buffer_size=100M // opcache.jit=tracing // Verify JIT status at runtime $jit = opcache_get_status()['jit'] ?? null; echo "JIT enabled: " . ($jit['enabled'] ? 'Yes' : 'No') . PHP_EOL; echo "JIT mode: " . ($jit['jit'] ?? 'N/A') . PHP_EOL;
readonly (PHP 8.1) to create immutable value objects in a single declaration line. Immutable objects are thread-safe, cacheable, and trivial to unit test — they're one of the highest-ROI patterns in object-oriented PHP.opcache.jit_buffer_size.tracing mode for web apps.opcache.jit_buffer_size=100M and opcache.jit=tracing for real gains.Attributes — The End of Docblock Decorators
You've seen @route or @param in docblocks. Those are comments. They rotted silently when you refactored. PHP 8's attributes are structured metadata the engine understands at compile time. No parsing strings. No guessing. Define an attribute class with #[Attribute], then slap #[Route('/api/users')] directly above your method. Reflection reads them natively. Frameworks like Symfony and Laravel already migrated. You get autocomplete, type safety, and your IDE won't lie to you. Why this matters: static analysis tools can validate attribute arguments before runtime. A mistyped route pattern becomes a compile error, not a 404 at 3 AM. Stop treating metadata like documentation. Treat it like code.
// io.thecodeforge #[Attribute(Attribute::TARGET_METHOD)] class Route { public function __construct( public string $path, public string $method = 'GET' ) {} } class UserController { #[Route('/users', 'GET')] public function list(): array { return ['users' => []]; } #[Route('/users/{id}', 'GET')] public function show(int $id): array { return ['user' => ['id' => $id]]; } } // Runtime reflection $ref = new ReflectionMethod(UserController::class, 'list'); $route = $ref->getAttributes(Route::class)[0]->newInstance(); echo $route->path; // /users
WeakMaps — Stop Leaking Memory with Caches
You built a cache layer. Objects pile up. Memory climbs. You write a custom destructor or pray the GC catches up. That's the old way. PHP 8 adds WeakMap — a map where keys are objects, but references don't prevent garbage collection. When the key object is destroyed, the entry vanishes. No manual cleanup. No loops. Why this matters: caches for computed properties, ORM hydration, or event listeners all suffer from retention leaks. A unset()WeakMap turns that into a non-issue. Think of a project that caches normalized user data per request. Traditional array keeps everything alive. Use WeakMap with request-scoped objects as keys—when the request ends, the cache evaporates. Your memory graph stays sane.
// io.thecodeforge class User { public function __construct( public readonly int $id, public readonly string $name ) {} } class ComputedStats { private WeakMap $cache; public function __construct() { $this->cache = new WeakMap(); } public function getLastLogin(User $user): ?\DateTimeImmutable { if (!isset($this->cache[$user])) { // Expensive DB query $this->cache[$user] = \DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', '2024-03-15 14:30:00' ); } return $this->cache[$user]; } } $stats = new ComputedStats(); $user = new User(1, 'Alice'); echo $stats->getLastLogin($user)->format('Y-m-d'); // 2024-03-15 unset($user); // Cache entry disappears automatically
String Functions — Because strpos() Was Always Wrong
You've written if (strpos($haystack, $needle) !== false) a thousand times. Each time a junior writes if (strpos(...)) and it breaks on position zero. PHP 8 kills that pattern outright. , str_contains(), str_starts_with() return booleans. No integer. No type juggling. Why this matters: these aren't just syntax sugar. They reduce cognitive load during code review. One glance tells intent. Performance is on par with hand-rolled str_ends_with()strpos checks — the engine optimizes internally. Start migrating your codebase today. Replace every strpos($h, $n) !== false with str_contains($h, $n). Your future self will thank you when a bug report doesn't involve unexpected zero indexes.
// io.thecodeforge $log = "ERROR: Connection timeout on host 10.0.0.1"; // Old way - bug waiting to happen if (strpos($log, 'ERROR') !== false) { echo "Alert triggered (old)\n"; } // PHP 8 way - explicit intent if (str_contains($log, 'ERROR')) { echo "Alert triggered (new)\n"; } // Domain checks $email = "admin@thecodeforge.io"; if (str_ends_with($email, '@thecodeforge.io')) { echo "Internal user\n"; } // Prefix matching $path = "/api/v2/users"; if (str_starts_with($path, '/api/')) { echo "API request\n"; }
stripos() or mb_strtolower() first. Don't assume they handle Unicode gracefully — test with multibyte strings.strpos() !== false with str_contains(). It's safer, clearer, and costs nothing in performance.How a Missing Nullsafe Caused a Silent Payment Failure
transactionId property.switch block on order status had a missing break and fell through to the default. But the real trigger was a nullsafe operator used on a $gatewayResponse?->transactionId that returned null because the gateway returned a timeout error with no body. The null was never checked, and downstream code treated null as 'payment successful'.- Don't use
?->on data that must exist — it masks bugs. - Always validate response structure from external APIs before using nullsafe or null coalescing.
- Add monitoring for unexpected null returns from mission-critical external calls.
default even though the value looks correct$_GET is always a string. Cast it: match((int) $input). Verify with var_dump($value) before the match.?? 'missing' to trace which part short-circuited.opcache_get_status()['jit']['enabled']. Profile the request with Blackfire or Xdebug. If the bottleneck is I/O (DB query, HTTP call), JIT won't help. Focus on CPU-bound code like image processing or complex calculations.php -r "var_dump(\$_GET['status'] ?? 'no value');"php -r "echo gettype(\$_GET['status'] ?? null);"php -r "print_r(\$response->data ?? 'null at data');"php -r "print_r(\$response?->data?->user ?? 'full chain null');"?-> with -> on mandatory paths, add explicit null check with exceptionphp -r "print_r(opcache_get_status()['jit'] ?? 'OPcache disabled');"php -r "ini_get('opcache.jit');"opcache.jit=tracing and opcache.jit_buffer_size=100M in php.ini, restart PHP-FPMgrep -r 'oldParamName:' src/php -l the_file.php # syntax check if params changed@deprecated docblock| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| OrderStatusLabel.php | $orderStatus = 3; | Match Expressions |
| UserProfileService.php | class Country { | Nullsafe Operator |
| DateFormatter.php | function formatDate( | Named Arguments |
| FindUser.php | function findUser(int|string $identifier): array|null { | Union Types |
| MoneyValueObject.php | class MoneyLegacy { | Constructor Promotion and JIT |
| RouteAttribute.php | class Route { | Attributes |
| WeakMapCache.php | class User { | WeakMaps |
| StringUtils.php | $log = "ERROR: Connection timeout on host 10.0.0.1"; | String Functions |
Key takeaways
match uses strict === comparison and throws UnhandledMatchError on no match?-> is for expected nulls (optional relationships); don't use it to suppress errors that indicate a genuine bug in your logic.readonly is the fastest path to bulletproof immutable value objectsCommon mistakes to avoid
4 patternsUsing `match` with uncast user input
default even when the value looks correct$_GET, $_POST, and URLs is always a string. Cast it before matching: match((int) $request->get('status')) or use match(true) with explicit comparisons if the type is uncertain.Treating the nullsafe operator as a null check replacement everywhere
null result propagates silently through multiple layers, causing misleading output or a confusing error far from the source?-> 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.Expecting JIT to speed up typical Laravel/Symfony apps significantly
opcache_get_status()['jit'] to confirm JIT is active, then profile with Blackfire or Xdebug to find your actual bottleneck before optimising.Renaming a parameter without updating named argument callers
Interview Questions on This Topic
What 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?
match uses strict === comparison, returns a value directly, and throws UnhandledMatchError if no arm matches. switch uses loose == comparison and requires explicit break to prevent fall-through. A bug scenario: using switch to compare a string '0' against integer 0 — they match because of ==, leading to incorrect branch execution. match would treat them as different.How does the nullsafe operator `?->` differ from a null check using `isset()` or a ternary? When would you deliberately NOT use it?
isset() checks multiple variables but doesn't propagate null. Ternary checks one level at a time. Do NOT use ?-> when null is unexpected — it masks bugs. Use normal -> to throw an error and catch the problem early.PHP 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?
Explain how union types differ from docblock type hints in practice. What runtime advantage do they provide?
int|string and someone passes a float, PHP throws a TypeError immediately at the function boundary. Docblocks only catch issues during development or when using static analysers.Frequently Asked Questions
Mostly, but not completely. PHP 8 removed several deprecated features from PHP 7 (like the 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.each()
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.
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 at runtime.opcache_get_status()['jit']['enabled']
Yes. Union types work for both parameters and return types. For example, function findUser(int $id): User|null ensures the function returns either a User object or null. The PHP engine enforces this at runtime — if you accidentally return a string, it throws a TypeError.
Named arguments work with variadic functions, but you cannot pass the variadic arguments using named syntax because the variadic parameter doesn't have a fixed name — it captures all unmatched positional arguments. Pass variadic arguments positionally. Named arguments for the fixed parameters still work.
20+ years shipping production PHP systems at scale. Everything here is grounded in real deployments.
That's Advanced PHP. Mark it forged?
5 min read · try the examples if you haven't