Programming

PHP 8 Nullsafe Operator ?-> vs ?? and isset()

By Nawawi JamiliPublished 6 min read

PHP code that walks through objects has always been full of null checks. You fetch the user and check it is not null. Then you fetch the address, check again, and only then read the city. PHP 8.0 added the nullsafe operator ?-> to reduce that to one line. It sits next to two older operators, ?? and ??=, and the three are easy to mix up because they all deal with null. They solve different problems.

Everything below was run on PHP 8.0.30 from the command line. File paths in error messages are shortened.

The test classes

The examples share these two small classes and a lookup function that returns a full user, a user with no address, or no user at all:

php
<?php
class Address
{
    public function __construct(public ?string $city = null) {}
    public function getCity(): ?string
    {
        return $this->city;
    }
}
class User
{
    public function __construct(public ?Address $address = null) {}
    public function getAddress(): ?Address
    {
        return $this->address;
    }
}
function find_user(int $id): ?User
{
    return match ($id) {
        1 => new User(new Address('Kuala Lumpur')),
        2 => new User(),
        default => null,
    };
}

The problem: calling a method on null

If any link in a chain returns null, the next method call is a fatal error, not a warning:

php
$city = find_user(3)->getAddress();
output
PHP Fatal error:  Uncaught Error: Call to a member function getAddress() on null in /home/awie/nullsafe/error.php:4
Stack trace:
#0 {main}
  thrown in /home/awie/nullsafe/error.php on line 4

Before PHP 8 the careful version looked like this, and real code repeats it everywhere:

php
$city = null;
$user = find_user(2);
if ($user !== null) {
    $address = $user->getAddress();
    if ($address !== null) {
        $city = $address->getCity();
    }
}
var_dump($city);
output
NULL

The nullsafe operator ?->

?-> works like ->, except that when the thing on its left is null, the whole expression evaluates to null instead of throwing:

php
foreach ([1, 2, 3] as $id) {
    $city = find_user($id)?->getAddress()?->getCity();
    echo $id, ': ';
    var_dump($city);
}
output
1: string(12) "Kuala Lumpur"
2: NULL
3: NULL

User 2 exists but has no address, so the second ?-> catches it. User 3 does not exist, so the first one does. One line does what the nested if blocks did, and the intent is easier to read.

Pair it with ?? when you want a default instead of null:

php
foreach ([1, 2, 3] as $id) {
    echo $id, ': ',
        find_user($id)?->getAddress()?->getCity() ?? 'unknown', "\n";
}
output
1: Kuala Lumpur
2: unknown
3: unknown

Short-circuiting: the rest of the chain is skipped

When ?-> meets null, PHP does not just return null for that one step. It skips everything else in the chain, including method arguments. You can see that with an argument that has a side effect:

php
function expensive(): string
{
    echo "expensive() called\n";
    return 'x';
}
class Box
{
    public function put(string $v): string
    {
        return "stored $v";
    }
}
$box = null;
var_dump($box?->put(expensive()));
$box = new Box();
var_dump($box?->put(expensive()));
output
NULL
expensive() called
string(8) "stored x"

With $box null, expensive() is never called. That matters if the argument writes to a log, queries a database or increments a counter.

The skipping also covers plain -> calls further along the same chain, which leads to a less obvious point. The ? only protects against the value directly to its left being null:

php
// find_user(3) is null: ->getAddress() and ->getCity() are both skipped
var_dump(find_user(3)?->getAddress()->getCity());
// find_user(2) is a User with no address: the plain -> still fails
try {
    var_dump(find_user(2)?->getAddress()->getCity());
} catch (Error $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}
output
NULL
Error: Call to a member function getCity() on null

So put ?-> at every step that can legitimately be null, and use -> where null would be a bug you want to hear about. Adding ?-> everywhere "just in case" turns real errors into silent nulls.

?? null coalescing: for undefined things

The null coalescing operator ?? (PHP 7.0) returns its left side if that is set and not null, otherwise its right side. It also suppresses the notice or warning for an undefined variable, array key or property, which is something ?-> does not do. For properties alone, ?? is already enough:

php
$user = find_user(2);
// properties: ?? alone is enough, it suppresses the warnings
echo $user->address->city ?? 'unknown', "\n";
// methods: ?? does not protect a call on null
try {
    echo $user->getAddress()->getCity() ?? 'unknown', "\n";
} catch (Error $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}
output
unknown
Error: Call to a member function getCity() on null

That is the real difference. ?? works like isset(): it can look through missing properties and keys, but it cannot make a method call on null succeed. ?-> makes method calls safe, but it does not hide an undefined array key:

php
$data = ['user' => null];
var_dump($data['user']?->name);
var_dump($data['missing']?->name);
output
NULL
PHP Warning:  Undefined array key "missing" in /home/awie/nullsafe/arrays.php on line 5
NULL

Reading a property directly on null with a plain arrow is a warning in PHP 8, not a fatal error, and ?-> removes it:

php
$user = null;
var_dump($user->name);
var_dump($user?->name);
output
PHP Warning:  Attempt to read property "name" on null in /home/awie/nullsafe/prop.php on line 3
NULL
NULL

??= assigns only when null

??= (PHP 7.4) is shorthand for $x = $x ?? $default. It fills in a value only when the current one is missing or null, which makes it ideal for default options and lazy initialisation:

php
$options = ['timeout' => 0];
$options['timeout'] ??= 30;
$options['retries'] ??= 3;
var_dump($options);
$cache = null;
$cache ??= new ArrayObject();
echo get_class($cache), "\n";
output
array(2) {
  ["timeout"]=>
  int(0)
  ["retries"]=>
  int(3)
}
ArrayObject

Note that a timeout of 0 is kept. ??= only cares about null, unlike $x = $x ?: 30, which would have overwritten the zero because it is falsy.

isset() and empty() with ?->

?-> is allowed inside isset() and empty(). But for properties, isset() already handles null in the middle of the chain without help:

php
class Profile { public ?string $bio = null; }
class Account { public ?Profile $profile = null; }
$a = new Account();
var_dump(isset($a?->profile?->bio));
$a->profile = new Profile();
$a->profile->bio = '';
var_dump(isset($a?->profile?->bio));
var_dump(empty($a?->profile?->bio));
output
bool(false)
bool(true)
bool(true)

What ?-> does not do

The operator is read-only. You cannot assign through it, unset through it or take a reference to it, and PHP rejects all three at compile time:

php
$user = null;
$user?->name = 'awie';
output
PHP Fatal error:  Can't use nullsafe operator in write context in /home/awie/nullsafe/write.php on line 3

unset($user?->name) gives the same "write context" error, and $ref = &$user?->name fails with "Cannot take reference of a nullsafe chain". The reason is good: if $user is null there is nothing to write to, and silently dropping a write would hide a bug. Check for null explicitly before writing.

Two more limits. There is no nullsafe static call: $class?::method() is a parse error. And ?-> only reacts to null. A function that returns false on failure, which many older PHP functions do, still causes a fatal error:

php
$config = false;   // e.g. a failed lookup that returns false
try {
    var_dump($config?->get('db'));
} catch (Error $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}
output
Error: Call to a member function get() on bool

Which one to use

  • ?->: calling methods (or reading properties) along a chain where a step can legitimately return null.
  • ??: supplying a default for something that may be undefined or null, such as array keys from $_GET or a config array. Combine it with ?-> for a default at the end of a chain.
  • ??=: setting a default value only if none exists yet.
  • isset(): when you need a boolean answer rather than a value.

The nullsafe section of the PHP manual is short and worth reading once you have tried the examples above.