Mid-level 4 min · March 06, 2026

PHP 8 New Features: Nullsafe Operator Causing Payment Bug

Checkout completed without errors but no payment charged — nullsafe hid a gateway timeout.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Match expressions use strict === and return a value — no fall-through, no silent null
  • Nullsafe operator ?-> short-circuits to null on any null in the chain
  • Named arguments skip positional confusion — pass by parameter name, not order
  • Union types enforce int|string at runtime, not just in docblocks
  • JIT compiles hot paths to native code — helps CPU-bound tasks, not I/O
  • Biggest mistake: using match with uncast user input — it's strict about types
Plain-English First

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 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.

OrderStatusLabel.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<?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;
}
Output
Switch result: Shipped
Match result: Shipped
Strict check result: Type mismatch caught safely
Caught: Unhandled match case
Watch Out: match is strict, switch is not
If you migrate a 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.
Production Insight
A common production bug: using match with uncast query parameters.
The '3' string never matches integer 3, so default runs every time.
Rule: always cast user input to the expected type before matching.
Key Takeaway
match replaces switch's loose == with strict === and forces exhaustive handling.
UnhandledMatchError is your friend — it turns silent wrong answers into loud exceptions.
Cast input types before matching, or you'll get caught by strictness.

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.

UserProfileService.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<?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;
Output
Old way ISO code: GB
Nullsafe ISO code: GB
Dialling code: +44
Guest ISO code: N/A
Pro Tip: Combine ?-> with ?? for clean defaults
The nullsafe operator returns 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.
Production Insight
Nullsafe chains can hide real bugs when a null is unexpected.
In the payment incident, nullsafe on gateway response masked a timeout.
Rule: use ?-> only for optional data — never for critical path data that must exist.
Key Takeaway
Nullsafe operator short-circuits on null — no more isset pyramids.
Don't use it to suppress errors from mandatory data; use normal -> to throw.
Combine with ?? 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.

DateFormatter.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?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;
Output
Positional: 2026-04-22T12:00:00+00:00 true false en
Named: 2026-04-22T12:00:00+00:00
Only date: 2026-04-22
Padded: 00000042
Interview Gold: Named args make refactoring harder
Changing a parameter name is now a breaking change if any caller uses named syntax. Always add a deprecated alias parameter or use a @param docblock with both old and new names during migrations. In large codebases, consider using an options object pattern instead of many optional parameters.
Production Insight
A team renamed a parameter from $userId to $customerId in a public library.
External users who passed userId: 42 got silent errors — no warning.
Rule: treat parameter names as part of your public API when named arguments are used.
Key Takeaway
Named arguments make function calls self-documenting but tie you to parameter names.
Use them for built-in functions and public APIs with many optionals.
Parameter renames become breaking changes — plan for it.

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).

FindUser.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?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;
}
Output
By ID: Alice
By email: Bob
Type caught: findUser(): Argument #1 ($identifier) must be of type int|string, float given
NULL
Mental Model: Type as contract enforcement
  • 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.
Production Insight
A legacy API accepted 'int|string' in docblock but internally used is_int checks.
When a new client sent a float, it bypassed the guard because PHP didn't enforce the type.
Union types would have thrown immediately, saving hours of debugging.
Key Takeaway
Union types enforce type constraints at the boundary — not just in comments.
Use int|string for IDs, array|null for optional collections.
TypeError at call time is better than a silent bug five layers deep.

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.

MoneyValueObject.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
<?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;
Output
Item: £19.99
Shipping: £4.99
Total: £24.98
Caught: Cannot add GBP to USD
JIT enabled: Yes
JIT mode: tracing
Pro Tip: readonly + constructor promotion = immutable value objects
Combine constructor promotion with 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.
Production Insight
JIT is often enabled with default settings, but many servers miss the opcache.jit_buffer_size.
Without enough buffer, JIT compiles only tiny regions — negligible benefit.
Rule: set buffer to at least 100M and use tracing mode for web apps.
Key Takeaway
Constructor promotion eliminates boilerplate and prevents assignment bugs.
JIT helps CPU-bound tasks, not I/O — profile before expecting improvements.
Set opcache.jit_buffer_size=100M and opcache.jit=tracing for real gains.
● Production incidentPOST-MORTEMseverity: high

How a Missing Nullsafe Caused a Silent Payment Failure

Symptom
Checkout completed without errors, but no payment was charged. The order status remained 'pending' and no payment gateway transaction existed.
Assumption
Someone assumed the payment gateway response always contained a transactionId property.
Root cause
A legacy 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'.
Fix
Removed the nullsafe from the critical path and added explicit null checks. The nullsafe is for expected optional data — never for data that must exist for correctness.
Key lesson
  • 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.
Production debug guideSymptom → Action patterns for the most common PHP 8 pitfalls4 entries
Symptom · 01
Match expression always hits default even though the value looks correct
Fix
Check the type of the matched value. User input from $_GET is always a string. Cast it: match((int) $input). Verify with var_dump($value) before the match.
Symptom · 02
Nullsafe chain returns null but you expected a real value
Fix
Add a var_dump at the start of the chain to confirm the first object is not null. Check each intermediate property individually. Use ?? 'missing' to trace which part short-circuited.
Symptom · 03
Named argument function call breaks after a refactor
Fix
Search for callsites using the renamed parameter name. Named arguments are tied to the parameter name — renaming is a breaking change. Use an IDE rename refactor that also updates callers, or add a deprecated alias.
Symptom · 04
JIT appears enabled but no performance gain on web requests
Fix
Confirm JIT is active with 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 8 Quick Debug Cheat SheetFive common production issues and the one-liner commands to diagnose them.
UnhandledMatchError thrown unexpectedly
Immediate action
Check the value type with var_dump() before match
Commands
php -r "var_dump(\$_GET['status'] ?? 'no value');"
php -r "echo gettype(\$_GET['status'] ?? null);"
Fix now
Cast to expected type before matching, or add a default arm that logs the unexpected value
Null chain propagates silent null+
Immediate action
Add intermediate assignments to find the break point
Commands
php -r "print_r(\$response->data ?? 'null at data');"
php -r "print_r(\$response?->data?->user ?? 'full chain null');"
Fix now
Replace ?-> with -> on mandatory paths, add explicit null check with exception
JIT not improving performance+
Immediate action
Verify JIT is actually running
Commands
php -r "print_r(opcache_get_status()['jit'] ?? 'OPcache disabled');"
php -r "ini_get('opcache.jit');"
Fix now
Set opcache.jit=tracing and opcache.jit_buffer_size=100M in php.ini, restart PHP-FPM
Named argument breaks after parameter rename+
Immediate action
Search for the old parameter name across codebase
Commands
grep -r 'oldParamName:' src/
php -l the_file.php # syntax check if params changed
Fix now
Update all callers to use the new parameter name, or add an alias with @deprecated docblock
PHP 8 Feature Comparison
FeatureBefore PHP 8PHP 8 Approach
Value comparison in branchingswitch with loose ==, no return value, needs breakmatch with strict ===, returns a value, throws on no match
Null chain navigationNested if ($obj !== null && $obj->prop !== null)$obj?->prop?->subProp — short-circuits to null cleanly
Multi-type parametersDocblock @param int|string — honoured by IDEs, ignored by runtimeNative int|string union type — enforced by PHP engine at call time
Skip optional paramsMust pass all positional args: func(1, null, null, true)Named args: func(first: 1, fourth: true) — skips middle params
Value object boilerplateDeclare property + constructor param + $this->x = $x (3 steps)Constructor promotion: public function __construct(private int $x)
CPU-bound performanceInterpreted via opcode cache — no native compilationJIT compiler converts hot paths to native machine code at runtime

Key takeaways

1
match uses strict === comparison and throws UnhandledMatchError on no match
this turns silent wrong-answer bugs into loud, traceable exceptions.
2
The nullsafe operator ?-> is for expected nulls (optional relationships); don't use it to suppress errors that indicate a genuine bug in your logic.
3
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.
4
Union types enforce type constraints at the function boundary
no more silent float-where-int-expected bugs.
5
Constructor promotion with readonly is the fastest path to bulletproof immutable value objects
the boilerplate reduction is real and it has zero runtime cost.
6
JIT helps CPU-bound tasks only
profile your app before expecting performance gains from it.

Common mistakes to avoid

4 patterns
×

Using `match` with uncast user input

Symptom
Your code always falls through to default even 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 use match(true) with explicit comparisons if the type is uncertain.
×

Treating the nullsafe operator as a null check replacement everywhere

Symptom
A null result 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.
×

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.
×

Renaming a parameter without updating named argument callers

Symptom
Functions called with named syntax suddenly fail silently or with confusing errors
Fix
Use an IDE refactor that updates all callers. For public APIs, keep the old parameter name as a deprecated alias or use a migration strategy with warning logs.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the key difference between `switch` and `match` in PHP 8, and ca...
Q02SENIOR
How does the nullsafe operator `?->` differ from a null check using `iss...
Q03SENIOR
PHP 8's JIT compiler was heavily marketed as a performance breakthrough ...
Q04SENIOR
Explain how union types differ from docblock type hints in practice. Wha...
Q01 of 04SENIOR

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?

ANSWER
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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Is PHP 8 backward compatible with PHP 7 code?
02
When should I use `match` versus `if/elseif` chains?
03
Does enabling JIT require any code changes?
04
Can I use union types as return types?
05
What happens if I use named arguments with a function that has variadic parameters?
🔥

That's Advanced PHP. Mark it forged?

4 min read · try the examples if you haven't

Previous
PHP Security Best Practices
5 / 13 · Advanced PHP
Next
REST API with Pure PHP