Skip to content

Instantly share code, notes, and snippets.

@davidon
Last active March 30, 2026 13:40
Show Gist options
  • Select an option

  • Save davidon/e925297d3569ee51a0cae6a3fe7aedb9 to your computer and use it in GitHub Desktop.

Select an option

Save davidon/e925297d3569ee51a0cae6a3fe7aedb9 to your computer and use it in GitHub Desktop.
Null Object Pattern in PHP

Null Object Pattern in PHP

Overview

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.

When to use it

  • 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 a null they 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.

When not to use it

  • The absence of a value is truly exceptional and should always throw.
  • The object graph is so simple that a plain null check is clearer.

P.S. "Normal absence" vs "Truly exceptional absence"

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.');
}

Generic Example

The example below uses a simple RecordModel hierarchy — a parent class, a domain subclass, and a NullRecordModel that acts as the Null Object.

1. The base class

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

2. A domain subclass

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

3. The Null Object

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

4. A service that returns the Null Object instead of null

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

Usage Examples

A) Existing ID guards work transparently

$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.');
}

B) Detecting the Null Object explicitly with isNullRecord()

$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";

C) Detecting the Null Object with instanceof

$record = $service->getRecord();

if ($record instanceof NullRecordModel) {
    echo "Null Object detected via instanceof.\n";
    return;
}

D) Reading attributes — get() returns null safely

$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"

E) Writing attributes — set() is a silent no-op

$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

F) Saving — save() is a safe no-op

$record = $service->getRecord();

$saved = $record->save();

if (!$saved) {
    echo "Record was not saved (expected for NullRecordModel).\n";
}

G) Iterating child records — safe empty return

// 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";
}

H) String casting for logging / debugging

$record = $service->getRecord();

echo (string) $record;
// Real record:  (depends on __toString implementation)
// NullRecord:   "[NullRecordModel: no record loaded]"

I) Using toArray() — returns empty array

$record = $service->getRecord();

$data = $record->toArray();              // []
echo count($data);                       // 0

J) Validation that rejects the Null Object

class 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.'
            );
        }
    }
}

Class Diagram

┌──────────────────────┐
│     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
└──────────────────────────────┘

Key Takeaways

  1. Transparent to existing code — guards like getId() <= 0 catch the Null Object without changes.
  2. Explicit detection — use isNullRecord() or instanceof NullRecordModel when you need to branch.
  3. No side effectssave(), set(), and any mutating method are no-ops.
  4. Safe defaultsget()null, toArray()[], getId()0.
  5. Make it final — subclassing a Null Object couples behaviour to an inert stand-in.

P.S. Deep Dive: Why getId() <= 0 is transparent

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.

Before (nullable — crashes)

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

After (Null Object — works without any code change to OrderService)

// 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

What changed and what didn't

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.

More examples of guards that work transparently

// 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment