01 · THE IDEAREAD → TRY → LEARN
The problem: optional objects
Real object graphs often have gaps. A user may have no address, a team may have no lead, and a lead may have no email. Reading $user->address->city when address is null produces a warning and gives you null anyway. Calling a method on null throws an Error. Before the nullsafe operator you had to wrap each step in an if or an isset() check.
The nullsafe operator ?-> is written like normal property or method access, with a question mark in front of the arrow. If the value on its left is null, the access yields null instead of failing. If the value is an object, it behaves like ->.
A first example
The next example has a user with an optional address, and an address with an optional city.
class Address
{
public function __construct(public ?string $city) {}
}
class User
{
public function __construct(public ?Address $address) {}
}
$withCity = new User(new Address('Lisbon'));
$withoutAddress = new User(null);
echo json_encode($withCity->address?->city), "\n";
echo json_encode($withoutAddress->address?->city), "\n";"Lisbon" null
The first chain reaches the city and returns "Lisbon". In the second chain address is null, so ?->city produces null and no warning appears.
Short-circuiting
The nullsafe operator does more than return null for one step. When the left side is null, PHP skips the rest of the chain. That includes the evaluation of method arguments, so any side effects in those arguments do not happen.
class Profile
{
public function label(string $prefix): string
{
return $prefix . ':ready';
}
}
function prefix(): string
{
echo "prefix evaluated\n";
return 'profile';
}
$profile = null;
$result = $profile?->label(prefix());
echo json_encode($result), "\n";
$profile = new Profile();
$result = $profile?->label(prefix());
echo json_encode($result), "\n";null prefix evaluated "profile:ready"
With null, the helper never runs. With a real object, it runs and the method result is returned.
Adding a scalar fallback
A nullsafe chain ends in either a real value or null. Callers usually want a plain default instead, which is what the null coalescing operator ?? provides. Put it at the end of the chain.
class Person
{
public function __construct(private ?string $email) {}
public function email(): ?string
{
return $this->email;
}
}
class Team
{
public function __construct(private ?Person $lead) {}
public function lead(): ?Person
{
return $this->lead;
}
}
function leadEmail(?Team $team): string
{
return $team?->lead()?->email() ?? 'unknown';
}
echo json_encode([
leadEmail(new Team(new Person('ana@example.com'))),
leadEmail(new Team(null)),
leadEmail(new Team(new Person(null))),
leadEmail(null),
]), "\n";["ana@example.com","unknown","unknown","unknown"]
Each ?-> guards the step that precedes it. A missing team, a missing lead and a missing email all end up as 'unknown'.
Things to remember
?->only checks fornull. If the left side is some other non-object value such as an integer, you still get the usual warning or error.- Use
??rather than?:for the fallback when falsy real values such as''or0must be kept.?:replaces them, while??replaces onlynull. - Nullsafe access is for reading. You cannot use it as the target of an assignment such as
$a?->b = 1. - Do not put
?->everywhere. If a value should never benull, a plain->will reveal the bug sooner.
Exercise contract
Write shippingCity(array $order): string. The starter already converts the nested array to local objects with json_decode(json_encode($order), false), so each nested level can be read with ->. Return the city at customer, then address, then city. If the customer, the address or the city is null, return 'unknown'. An empty string city is a real value and must be returned unchanged.
The tests check the returned string for a full record, a null customer, a null address, a null city and an empty city.
Recap
Use ?-> to stop a chain safely at the first null, rely on short-circuiting to skip later calls and their arguments, and finish with ?? when you need a scalar default.
Functions · Arrays · Types
- Use ?-> on local object chains with a scalar fallback
- Implement and test a deterministic PHP 8.4 function