Null Object pattern — eliminating null checks
Concept
Null Object pattern eliminates null checks by providing an object with do-nothing (neutral) behavior in place of null. Instead of if ($logger !== null) { $logger->log(...); }, you use a NullLogger that implements the same interface but does nothing.
The problem with null: Null references require defensive checks everywhere. A missing null check throws TypeError: Call to a member function log() on null. Null Object provides a safe, predictable object that simply does nothing.
Structure: The Null Object implements the same interface as the "real" object. All methods do nothing or return neutral values (empty array, false, 0, empty string).
When to use Null Object:
- Optional dependencies that are sometimes not configured.
- Default/fallback behavior that is "do nothing".
- Simplifying code that has many
if ($obj !== null)guards.
Null Object vs null: Null Object is a valid object — you can call methods on it safely. It's the "zero value" of the object's domain. null is the absence of an object.
PHP 8 nullsafe operator (?->): $logger?->log(...) is an alternative for single calls. Null Object is better when: you pass the dependency around, you call it in multiple places, or you want polymorphic behavior (swap real with null).
Examples:
NullLoggerimplementing PSR-3LoggerInterface.NullCacheimplementingCacheInterface.NullEventDispatcher.NullTransportfor testing mailers.
Code Example
<?php
// Real implementation
interface LoggerInterface
{
public function info(string $message, array $context = []): void;
public function warning(string $message, array $context = []): void;
public function error(string $message, array $context = []): void;
}
class FileLogger implements LoggerInterface
{
public function __construct(private readonly string $path) {}
public function info(string $message, array $context = []): void { $this->write("INFO: {$message}", $context); }
public function warning(string $message, array $context = []): void { $this->write("WARNING: {$message}", $context); }
public function error(string $message, array $context = []): void { $this->write("ERROR: {$message}", $context); }
private function write(string $msg, array $ctx): void { file_put_contents($this->path, $msg . "\n", FILE_APPEND); }
}
// Null Object — safe no-op implementation
class NullLogger implements LoggerInterface
{
public function info(string $message, array $context = []): void {}
public function warning(string $message, array $context = []): void {}
public function error(string $message, array $context = []): void {}
}
// Service — requires a logger
class UserImporter
{
public function __construct(
private readonly LoggerInterface $logger, // always set, never null
) {}
public function import(array $users): void
{
$this->logger->info('Starting import', ['count' => count($users)]);
foreach ($users as $user) {
try {
User::create($user);
$this->logger->info('Imported user', ['email' => $user['email']]);
} catch (\Exception $e) {
$this->logger->error('Failed to import', ['email' => $user['email'], 'error' => $e->getMessage()]);
}
}
$this->logger->info('Import complete');
// NO null checks — safe because logger is always a valid object
}
}
// Usage — swap real vs null logger
$importerWithLogging = new UserImporter(new FileLogger('/logs/import.log'));
$importerSilent = new UserImporter(new NullLogger()); // no logging, no null checks needed
// Container — default to NullLogger if not configured
$logger = config('logging.enabled')
? new FileLogger(storage_path('logs/app.log'))
: new NullLogger();
app()->instance(LoggerInterface::class, $logger);
// Testing — NullLogger lets you test without file I/O
class UserImporterTest extends \PHPUnit\Framework\TestCase
{
public function test_imports_users(): void
{
$importer = new UserImporter(new NullLogger());
$importer->import([['email' => 'alice@example.com', 'name' => 'Alice']]);
// No need to mock logger — NullLogger is perfectly valid
}
}