Null, nullable types (?Type), and null coalescing (??)
Concept
PHP's null is a first-class type representing the deliberate absence of a value. It is the only value of type null, and a variable is null in exactly three situations: it was explicitly assigned null, it was declared but not assigned, or it was unset(). Unlike in languages such as Java or C# where null is a reference to nothing, PHP's null is its own scalar value that passes through the type system.
Nullable types, introduced in PHP 7.1, allow a typed parameter or return to accept either its declared type or null. The syntax ?Type is shorthand for Type|null. For example, ?string means "a string or null". This is critical for database interactions where a column may be NULL, for optional configuration values, and for methods that may legitimately find nothing to return. Without nullable types, the only alternative was to leave parameters untyped (losing static analysis) or throw an exception, which is often semantically wrong.
The null coalescing operator ?? (PHP 7.0+) provides a concise way to supply a default when a value is null or the key does not exist in an array. $value = $config['key'] ?? 'default' is equivalent to isset($config['key']) ? $config['key'] : 'default'. Importantly, ?? does not trigger a notice on undefined variables or missing array keys — it was specifically designed to replace the verbose isset() ternary pattern. It can be chained: $a ?? $b ?? $c returns the first non-null value.
The null coalescing assignment operator ??= (PHP 7.4+) combines the coalescing check with assignment: $this->cache ??= new Cache() only assigns if $this->cache is currently null. This is idiomatic for lazy initialization and the "initialize once" pattern. The nullsafe operator ?-> (PHP 8.0+) extends the concept to method and property chains: $order?->getUser()?->getAddress()?->getCity() short-circuits to null if any step returns null, eliminating nested null checks.
A critical gotcha: 0, "", "0", false, and [] are all falsy but are not null. The ?? operator uses isset() semantics (returns false only for null and undefined), not empty() semantics. So 0 ?? 'default' returns 0, not 'default' — a common mistake when developers confuse falsy with null. Always use === null for explicit null checks in critical code paths.
Code Example
<?php
declare(strict_types=1);
// Nullable parameter and return type
function findUser(int $id): ?object
{
// Returns null if not found — not an exception, null IS the expected outcome
return $id > 0 ? (object)['id' => $id, 'name' => 'Alice'] : null;
}
$user = findUser(0);
var_dump($user); // NULL
// Nullable typed property (PHP 7.4+)
class Order
{
public ?string $couponCode = null; // explicitly unset by default
public function __construct(
public readonly int $id,
public ?int $userId = null, // nullable constructor promotion
) {}
}
$order = new Order(id: 42);
// ?? — null coalescing (uses isset semantics, not empty semantics)
$code = $order->couponCode ?? 'NO_COUPON'; // 'NO_COUPON'
$missing = null ?? false ?? 0 ?? 'fallback'; // false — first non-null
$zero = 0 ?? 'default'; // 0, not 'default'!
// ??= — null coalescing assignment (lazy init pattern)
class Repository
{
private ?array $cache = null;
public function all(): array
{
$this->cache ??= $this->loadFromDatabase();
return $this->cache;
}
private function loadFromDatabase(): array
{
return ['record1', 'record2'];
}
}
// ?-> — nullsafe operator (PHP 8.0)
$city = findUser(0)?->address?->city; // null, no error
$city = findUser(5)?->address?->city ?? 'Unknown';
// The critical distinction: null vs falsy
$values = [0, '', false, [], null, '0'];
foreach ($values as $v) {
$result = $v ?? 'was-null';
// Only null maps to 'was-null'; 0, '', false, [], '0' are returned as-is
}
// Explicitly checking null vs checking falsy
function process(?string $input): string
{
if ($input === null) {
return 'no input provided';
}
if ($input === '') {
return 'empty string provided';
}
return strtoupper($input);
}
// PHP 8.4: implicit nullable deprecated
// OLD (still works but deprecated in 8.4):
// function foo(string $s = null) {} // E_DEPRECATED
// NEW: be explicit:
function foo(?string $s = null): void {}Interview Q&A
Q: What is the difference between $value ?? 'default', $value ?: 'default', and isset($value) ? $value : 'default', and when would each give a different result?
?? uses isset() semantics: it returns the left operand unless it is null or undefined, in which case it returns the right operand. ?: (Elvis operator) uses truthiness: it returns the left operand if it is truthy, otherwise the right. isset() ternary is identical to ?? in behaviour but requires knowing the variable exists. The difference manifests with falsy-but-not-null values: given $v = 0, then $v ?? 'x' returns 0, $v ?: 'x' returns 'x', and isset($v) ? $v : 'x' returns 0. In form input handling where an empty string or zero is valid data, ?? is correct and ?: is a hidden bug. In boolean/flag contexts, ?: is cleaner.
Q: How does PHP 8.4's deprecation of implicit nullable parameters affect existing codebases, and what is the migration path?
Before PHP 8.4, writing function foo(string $s = null) was valid — the default null implicitly made the parameter nullable even though the type hint said string. PHP 8.4 emits E_DEPRECATED for this pattern, and it will become a hard error in PHP 9.0. The migration is purely mechanical: replace string $s = null with ?string $s = null everywhere the parameter both accepts null and has a null default. Automated tools like Rector (rector/rector) can apply this transformation across a large codebase with the AddArg0ToMigrationRule or the built-in NullableParameterToDefaultNullRector. Running php -d error_reporting=E_ALL your-script.php will surface all occurrences as deprecation warnings before upgrading.
Q: In a Laravel Eloquent model, when should a relationship return null versus throw a ModelNotFoundException, and how does the nullsafe operator help consuming code stay clean?
find($id) returns null when the record doesn't exist and is appropriate when absence is a valid domain state — for example, checking whether an optional profile exists. findOrFail($id) throws ModelNotFoundException (which Laravel converts to a 404 response) and is correct when the record must exist, such as resolving a route model binding. In service layer code that calls find(), the nullsafe operator lets you write $user?->profile?->bio ?? 'No bio' rather than stacking if ($user !== null && $user->profile !== null) guards. However, over-relying on ?-> in deep chains can obscure legitimate bugs — if a user should always have a profile, use findOrFail and let the exception propagate rather than silently returning null through a chain.