The Null Object pattern — replacing null checks with polymorphism
Concept
The Null Object pattern replaces null checks with a real object that does nothing (or provides sensible defaults). Instead of checking if ($logger !== null) { $logger->log(...) } everywhere, you use a NullLogger that implements the same interface and silently discards all log calls.
The null problem: null in PHP means "no value" but it doesn't carry any information about what was expected. A null reference causes TypeError (for type-hinted parameters) or a fatal error when you try to call a method on it. Defensive null checks scatter throughout the code, increasing cyclomatic complexity and reducing readability.
Null Object vs Optional/Maybe: The Null Object pattern is a behavioral pattern — it provides an object that fulfills the interface contract but does nothing. Optional/Maybe (monad-like containers, not native to PHP) wrap a potentially-null value and provide safe chaining. PHP's ?? operator and ?-> (nullsafe) are the language-level alternatives for simpler cases.
When to use Null Object:
- Optional services/dependencies (logger, event dispatcher, metrics collector) where code should work without them
- Default implementations of optional hooks in a framework
- Test contexts where you don't care about side effects
When NOT to use it: When the absence of a value is meaningful and must be handled differently (e.g., User::findById(999) returning null means "not found" — a Null User might silently swallow operations). Use explicit error handling or Result/Option types for these cases.
Code Example
<?php
declare(strict_types=1);
interface Logger
{
public function info(string $message, array $context = []): void;
public function error(string $message, array $context = []): void;
}
// Real implementation
class FileLogger implements Logger
{
public function info(string $message, array $context = []): void
{
file_put_contents('/var/log/app.log', "[INFO] $message\n", FILE_APPEND);
}
public function error(string $message, array $context = []): void
{
file_put_contents('/var/log/app.log', "[ERROR] $message\n", FILE_APPEND);
}
}
// Null Object — does nothing, no null checks needed
class NullLogger implements Logger
{
public function info(string $message, array $context = []): void {} // no-op
public function error(string $message, array $context = []): void {} // no-op
}
// Service uses Logger without null checks
class PaymentProcessor
{
public function __construct(
private readonly Logger $logger = new NullLogger(), // optional, defaults to null object
) {}
public function charge(float $amount): bool
{
$this->logger->info("Charging $amount"); // always safe
// ... process payment
$this->logger->info("Charge successful");
return true;
}
}
// Works with a real logger
$processor1 = new PaymentProcessor(new FileLogger());
$processor1->charge(100.0);
// Works without a logger — no null checks needed
$processor2 = new PaymentProcessor();
$processor2->charge(100.0);
// Compare: without Null Object pattern
class BadPaymentProcessor
{
private ?Logger $logger;
public function charge(float $amount): bool
{
if ($this->logger !== null) { // scattered null check
$this->logger->info("Charging $amount");
}
// ...
if ($this->logger !== null) { // another scattered null check
$this->logger->info("Charge successful");
}
return true;
}
}