The Null Object pattern replaces null return values with a special "do-nothing" object
that shares the same interface as the real object. This eliminates the need for null checks
at every call site and prevents "Call to a member function on null" fatal errors.
- A method can legitimately return "nothing" (e.g. a lookup by ID that finds no row).
- Callers already guard against invalid state (e.g.
$record->getId() <= 0) and would benefit from a safe default rather than anullthey must remember to check. - You want to obey the Liskov Substitution Principle — any code expecting the parent type should work with the Null Object without knowing it is one.
- The absence of a value is truly exceptional and should always throw.
- The object graph is so simple that a plain
nullcheck is clearer.
The key question is: does the caller expect "not found" to happen during normal operation?
| Scenario | Missing record is… | Right approach |
|---|---|---|
findUserByEmail('someone@example.com') — searching by user input |
Normal — the email may not exist | Null Object or null |
getRecord() on a service that was constructed without loading data yet |
Normal — the object is in an intermediate state | Null Object |
getActiveInstanceById($id) where $id came from a dropdown / form |
Normal — the row could have been deleted between page load and submit | Null Object or null |
getPaymentGateway() when the system requires a configured gateway to function |
Exceptional — this means the system is misconfigured | Throw |
getCurrentUser() inside an authenticated endpoint |
Exceptional — no user means the auth layer is broken | Throw |
loadMigration('2024_01_01_create_users') — loading a migration file that must exist |
Exceptional — a missing file means the deploy is corrupted | Throw |
P.S. Why is "lookup by ID that finds no row" normal, not exceptional?
Because the caller cannot guarantee the row still exists at query time. Between the moment the ID was obtained (e.g. from a URL, a related record, or a previous query) and the moment the lookup runs, the row could have been:
- Soft-deleted or hard-deleted by another user or process.
- Moved to a different module or merged into another record.
- Created in a transaction that was rolled back.
The caller must always be prepared for "not found". That makes it a normal control-flow outcome, not a programming error — and Null Object handles it gracefully.
Compare this to getCurrentUser() inside an already-authenticated request: if there is no
user, something is fundamentally broken (middleware failed, session corrupted). No amount of
graceful degradation makes sense — throw immediately so the bug surfaces.
// ✅ Null Object — absence is expected, caller handles it
$record = $service->getRecord(); // NullRecordModel if not loaded
if ($record->getId() <= 0) {
return 'Record not found.'; // graceful response
}
// ❌ Would be WRONG to use Null Object here — absence is a system failure
$gateway = Config::getPaymentGateway();
if ($gateway === null) {
// Silently returning a no-op gateway would let orders "succeed"
// without actually charging anyone — a silent, catastrophic bug.
throw new RuntimeException('Payment gateway is not configured.');
}The example below uses a simple RecordModel hierarchy — a parent class, a domain
subclass, and a NullRecordModel that acts as the Null Object.
<?php
declare(strict_types=1);
/**
* Base record model — represents a single database row.
*/
class RecordModel
{
protected array $attributes;
public function __construct(array $attributes = [])
{
$this->attributes = $attributes;
}
/** Get the primary key. */
public function getId(): int
{
return (int) ($this->attributes['id'] ?? 0);
}
/** Read any attribute. */
public function get(string $key)
{
return $this->attributes[$key] ?? null;
}
/** Write any attribute (fluent). */
public function set(string $key, $value): self
{
$this->attributes[$key] = $value;
return $this;
}
/** Return all attributes. */
public function toArray(): array
{
return $this->attributes;
}
/** Persist the current state to the database. */
public function save(): bool
{
// … real persistence logic …
return true;
}
/**
* Whether this instance is a Null Object stand-in.
*
* Base implementation returns false. Overridden to return true
* in NullRecordModel, so the method is safe to call on ANY instance.
*/
public function isNullRecord(): bool
{
return false;
}
}<?php
declare(strict_types=1);
/**
* Represents an "Order" entity with child line-items.
*/
class OrderRecordModel extends RecordModel
{
/** Get the customer name. */
public function getCustomerName(): string
{
return (string) $this->get('customer_name');
}
/** Get related child line-item records. */
public function getLineItems(): array
{
// … query the database for child rows …
return [];
}
}<?php
declare(strict_types=1);
/**
* Null Object implementation for RecordModel.
*
* Design principles:
* - Extends RecordModel so it satisfies all type-hints.
* - Every method returns a safe, neutral default.
* - Persistence methods are no-ops — a null record must never hit the DB.
* - Marked `final` — subclassing a Null Object defeats its purpose.
*/
final class NullRecordModel extends RecordModel
{
public function __construct()
{
// Empty attributes — inherited read methods return safe defaults.
parent::__construct([]);
}
// ── Identity ────────────────────────────────────────────────
/** Always 0 — signals "no record". */
public function getId(): int
{
return 0;
}
/** Always true — distinguishes this from a real record. */
public function isNullRecord(): bool
{
return true;
}
// ── Attribute access ────────────────────────────────────────
/** Always null — no data is loaded. */
public function get(string $key)
{
return null;
}
/** No-op — silently discards writes. */
public function set(string $key, $value): self
{
return $this;
}
/** Empty array — consistent with no attributes. */
public function toArray(): array
{
return [];
}
// ── Persistence ─────────────────────────────────────────────
/** No-op — returns false (nothing was saved). */
public function save(): bool
{
return false;
}
// ── String representation ───────────────────────────────────
public function __toString(): string
{
return '[NullRecordModel: no record loaded]';
}
}<?php
declare(strict_types=1);
/**
* Wraps a RecordModel and guarantees getRecord() never returns null.
*/
class RecordService
{
private ?RecordModel $record;
public function __construct(?RecordModel $record = null)
{
$this->record = $record;
}
/**
* Returns the loaded record, or a NullRecordModel if none exists.
*
* Callers no longer need to null-check — they can call methods
* directly and rely on their existing ID-based guards.
*/
public function getRecord(): RecordModel
{
return $this->record ?? new NullRecordModel();
}
}$service = new RecordService(); // no record loaded
$record = $service->getRecord(); // returns NullRecordModel
// Guard catches the null-object case because getId() returns 0.
if ($record->getId() <= 0) {
throw new RuntimeException('No valid record loaded.');
}$record = $service->getRecord();
if ($record->isNullRecord()) {
echo "No record found — returning default response.\n";
return [];
}
// Safe to use the record from here on.
echo "Processing record #" . $record->getId() . "\n";$record = $service->getRecord();
if ($record instanceof NullRecordModel) {
echo "Null Object detected via instanceof.\n";
return;
}$record = $service->getRecord();
// No fatal error even when record is NullRecordModel.
$name = $record->get('customer_name'); // null
$email = $record->get('email'); // null
echo $name ?? 'Guest'; // "Guest"$record = $service->getRecord();
// Fluent calls succeed without error; state is simply discarded.
$record->set('status', 'active')
->set('priority', 'high');
var_dump($record->toArray()); // [] — nothing was stored$record = $service->getRecord();
$saved = $record->save();
if (!$saved) {
echo "Record was not saved (expected for NullRecordModel).\n";
}// Assume OrderRecordModel extends RecordModel and has getLineItems().
// A NullRecordModel would need to override getLineItems() to return [].
$record = $service->getRecord();
// If NullRecordModel, getLineItems() returns [] → loop body never runs.
foreach ($record->getLineItems() as $item) {
echo "Line item: " . $item->get('product_name') . "\n";
}$record = $service->getRecord();
echo (string) $record;
// Real record: (depends on __toString implementation)
// NullRecord: "[NullRecordModel: no record loaded]"$record = $service->getRecord();
$data = $record->toArray(); // []
echo count($data); // 0class StrictRecordService
{
private RecordService $recordService;
/**
* Validates the underlying record is real, not a Null Object.
*
* @throws UnexpectedValueException
*/
public function validate(): void
{
$record = $this->recordService->getRecord();
if ($record->isNullRecord()) {
throw new UnexpectedValueException(
'A real record is required but a NullRecordModel was found.'
);
}
if ($record->getId() <= 0) {
throw new UnexpectedValueException(
'The record primary key must be a positive integer.'
);
}
}
}┌──────────────────────┐
│ RecordModel │ ◄── base class
├──────────────────────┤
│ + getId(): int │
│ + get(key): mixed │
│ + set(key, val): self│
│ + toArray(): array │
│ + save(): bool │
│ + isNullRecord(): bool│ ← returns false
└──────┬───────────────┘
│ extends
▼
┌──────────────────────┐ ┌──────────────────────────┐
│ OrderRecordModel │ │ NullRecordModel (final) │
├──────────────────────┤ ├──────────────────────────┤
│ + getCustomerName() │ │ + getId(): 0 │
│ + getLineItems(): [] │ │ + get(): null │
└──────────────────────┘ │ + set(): no-op │
│ + toArray(): [] │
│ + save(): false │
│ + isNullRecord(): true │
│ + __toString(): string │
└──────────────────────────┘
┌──────────────────────────────┐
│ RecordService │
├──────────────────────────────┤
│ - record: ?RecordModel │
│ + getRecord(): RecordModel │ ← returns NullRecordModel
│ │ when record is null
└──────────────────────────────┘
- Transparent to existing code — guards like
getId() <= 0catch the Null Object without changes. - Explicit detection — use
isNullRecord()orinstanceof NullRecordModelwhen you need to branch. - No side effects —
save(),set(), and any mutating method are no-ops. - Safe defaults —
get()→null,toArray()→[],getId()→0. - Make it
final— subclassing a Null Object couples behaviour to an inert stand-in.
The whole point of the Null Object pattern is that existing code doesn't need to know it exists. Here is a side-by-side showing what happens with the old nullable approach vs the Null Object approach in the exact same method.
class OrderService
{
private RecordService $recordService;
public function getRelatedInvoices(): array
{
// ❌ getRecord() returns null when no record is loaded
$record = $this->recordService->getRecord();
// 💥 FATAL: "Call to a member function getId() on null"
if ($record->getId() <= 0) {
throw new RuntimeException('No valid order loaded.');
}
return $this->fetchInvoicesForOrder($record->getId());
}
}
// Trigger:
$service = new OrderService(new RecordService()); // no record loaded
$service->getRelatedInvoices(); // 💥 Fatal error// RecordService::getRecord() now returns NullRecordModel instead of null.
// OrderService is COMPLETELY UNCHANGED — not a single line edited:
class OrderService
{
private RecordService $recordService;
public function getRelatedInvoices(): array
{
// ✅ getRecord() returns NullRecordModel (never null)
$record = $this->recordService->getRecord();
// ✅ NullRecordModel::getId() returns 0
// 0 <= 0 is true → exception is thrown cleanly
if ($record->getId() <= 0) {
throw new RuntimeException('No valid order loaded.');
}
return $this->fetchInvoicesForOrder($record->getId());
}
}
// Trigger:
$service = new OrderService(new RecordService()); // no record loaded
$service->getRelatedInvoices(); // ✅ throws RuntimeException| Before (null) | After (NullRecordModel) | |
|---|---|---|
getRecord() returns |
null |
NullRecordModel instance |
$record->getId() |
💥 Fatal error | 0 |
getId() <= 0 check |
Never reached | Evaluates to true → exception thrown |
OrderService code edited? |
— | No |
The guard getId() <= 0 was already there to catch invalid records. It now also catches
the Null Object for free, because NullRecordModel::getId() returns 0, and 0 <= 0 is
true. The developer who wrote OrderService didn't need to know about Null Object at all.
// All of these ALREADY existed in code before the Null Object was introduced.
// None of them needed any modification.
// Guard 1: empty() check
$record = $service->getRecord();
if (empty($record->getId())) { // empty(0) → true ✅
throw new RuntimeException('...');
}
// Guard 2: Strict equality
$record = $service->getRecord();
if ($record->getId() === 0) { // 0 === 0 → true ✅
return 'No record found.';
}
// Guard 3: Falsy check
$record = $service->getRecord();
if (!$record->getId()) { // !0 → true ✅
return [];
}
// Guard 4: String attribute check
$record = $service->getRecord();
$category = $record->get('category'); // null
if (empty($category)) { // empty(null) → true ✅
throw new RuntimeException('Category is required.');
}
// Guard 5: foreach on child records
$record = $service->getRecord();
// NullRecordModel::getLineItems() returns []
foreach ($record->getLineItems() as $item) {
// Loop body never executes — safe no-op ✅
$item->process();
}Every one of these patterns was written with "invalid real records" in mind, but they catch the Null Object too — that's what "transparent" means.