PHP 8 Nullsafe Operator ?-> vs ?? and isset()
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
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:
$city = find_user(3)->getAddress();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 4Before PHP 8 the careful version looked like this, and real code repeats it everywhere:
$city = null;
$user = find_user(2);
if ($user !== null) {
$address = $user->getAddress();
if ($address !== null) {
$city = $address->getCity();
}
}
var_dump($city);NULLThe nullsafe operator ?->
?-> works like ->, except that when the thing on its left is null, the whole expression evaluates to null instead of throwing:
foreach ([1, 2, 3] as $id) {
$city = find_user($id)?->getAddress()?->getCity();
echo $id, ': ';
var_dump($city);
}1: string(12) "Kuala Lumpur"
2: NULL
3: NULLUser 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:
foreach ([1, 2, 3] as $id) {
echo $id, ': ',
find_user($id)?->getAddress()?->getCity() ?? 'unknown', "\n";
}1: Kuala Lumpur
2: unknown
3: unknownShort-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:
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()));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:
// 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";
}NULL
Error: Call to a member function getCity() on nullSo 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:
$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";
}unknown
Error: Call to a member function getCity() on nullThat 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:
$data = ['user' => null];
var_dump($data['user']?->name);
var_dump($data['missing']?->name);NULL
PHP Warning: Undefined array key "missing" in /home/awie/nullsafe/arrays.php on line 5
NULLReading a property directly on null with a plain arrow is a warning in PHP 8, not a fatal error, and ?-> removes it:
$user = null;
var_dump($user->name);
var_dump($user?->name);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:
$options = ['timeout' => 0];
$options['timeout'] ??= 30;
$options['retries'] ??= 3;
var_dump($options);
$cache = null;
$cache ??= new ArrayObject();
echo get_class($cache), "\n";array(2) {
["timeout"]=>
int(0)
["retries"]=>
int(3)
}
ArrayObjectNote 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:
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));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:
$user = null;
$user?->name = 'awie';PHP Fatal error: Can't use nullsafe operator in write context in /home/awie/nullsafe/write.php on line 3unset($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:
$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";
}Error: Call to a member function get() on boolWhich 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$_GETor 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.