0

The Null Object pattern — replacing null checks with polymorphism

Intermediate5 min read·php-08-017
interviewsolid

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
<?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;
    }
}