Null Object Pattern in Laravel: Replace Defensive Null Chains

8 min read

The nullsafe operator is excellent for one optional relationship. It is not an architectural decision about what absence means. If the same ?-> chain appears in checkout, invoices, API resources, and tests, every caller is recreating a business rule: what happens when a customer has no discount? That rule deserves a name, one implementation point, and tests.

The Null Object pattern represents a valid absence with an object that satisfies the same contract as a real implementation. A caller gets Discount, never ?Discount, and invokes it without branching on storage details. This article builds a small B2B discount boundary and, more importantly, marks the cases in which a neutral object would dangerously hide an error.

Repeated uncertainty is the warning sign

This first version is reasonable for one call site:

php
<?php

declare(strict_types=1);

namespace App\Billing;

use App\Models\Customer;

final class CheckoutTotal
{
    public function total(int $subtotalCents, Customer $customer): int
    {
        $discountCents = $customer->activePromotion?->discountFor($subtotalCents) ?? 0;

        return max(0, $subtotalCents - $discountCents);
    }
}

The cost appears when “no promotion” spreads. One caller uses zero, another displays a label, and another cannot distinguish no eligibility from a relation that was not loaded. A future minimum threshold, audit trail, or invoice note then requires finding every conditional. The question is not whether null is possible. It is whether no eligible discount is a normal, useful business state. It is. A database outage, a missing mandatory contract, or an invalid subtotal is not; those must remain failures.

Describe one business operation

Keep money in integer cents and return both an amount and an explanation. The label can later be persisted with an order, so historical invoices do not depend on whatever a promotion is called next month.

php
<?php

declare(strict_types=1);

namespace App\Billing;

final readonly class DiscountResult
{
    public function __construct(
        public int $amountCents,
        public string $label,
    ) {
        if ($amountCents < 0) {
            throw new \InvalidArgumentException('A discount cannot be negative.');
        }
    }
}
php
<?php

declare(strict_types=1);

namespace App\Billing;

interface Discount
{
    public function calculate(int $subtotalCents): DiscountResult;
}

The contract says nothing about HTTP, Eloquent, or the current request. An order service depends on policy; the controller, job, or resolver remains responsible for selecting it. This small boundary lets tests use the same public operation as production code.

Make neutral behaviour explicit

NoDiscount is not a fake promotion. It is the valid policy when a customer does not qualify. It must still enforce the contract: neutral must never mean that invalid input is silently accepted.

php
<?php

declare(strict_types=1);

namespace App\Billing;

final readonly class NoDiscount implements Discount
{
    public function calculate(int $subtotalCents): DiscountResult
    {
        if ($subtotalCents < 0) {
            throw new \InvalidArgumentException('Subtotal cannot be negative.');
        }

        return new DiscountResult(
            amountCents: 0,
            label: 'No eligible discount',
        );
    }
}

The observable label matters. 0 alone can mean no offer, a rounding bug, or a failed lookup. The order can record this valid outcome without pretending it was a discounted one.

A real policy uses the same input and output. Basis points avoid floating-point rounding: 1_500 means fifteen percent exactly.

php
<?php

declare(strict_types=1);

namespace App\Billing;

final readonly class PercentageDiscount implements Discount
{
    public function __construct(private int $basisPoints)
    {
        if ($basisPoints < 0 || $basisPoints > 10_000) {
            throw new \InvalidArgumentException('Discount must be between 0 and 10,000 basis points.');
        }
    }

    public function calculate(int $subtotalCents): DiscountResult
    {
        if ($subtotalCents < 0) {
            throw new \InvalidArgumentException('Subtotal cannot be negative.');
        }

        return new DiscountResult(
            amountCents: intdiv($subtotalCents * $this->basisPoints, 10_000),
            label: sprintf('%s%% partner discount', $this->basisPoints / 100),
        );
    }
}

The rate may eventually be read from a promotion model and the label from a translation key. Those are application decisions, not reasons to make the contract nullable.

Resolve the nullable relation at one boundary

The resolver is intentionally the only place that knows a promotion can be missing. It converts nullable persistence to a non-null dependency before the checkout service sees it. A service-provider binding is not enough here because the correct policy changes per customer at runtime.

php
<?php

declare(strict_types=1);

namespace App\Billing;

use App\Models\Customer;

final readonly class DiscountResolver
{
    public function resolve(Customer $customer): Discount
    {
        $promotion = $customer->activePromotion;

        if ($promotion === null) {
            return new NoDiscount();
        }

        return new PercentageDiscount($promotion->discount_basis_points);
    }
}

Eager load activePromotion when listing many customers. Null Object removes a discount conditional; it does not fix an N+1 query. Do not catch a repository exception here and return NoDiscount: “not eligible” and “lookup timed out” need different operational responses.

The action now reads as a business flow, not nullable mechanics:

php
<?php

declare(strict_types=1);

namespace App\Billing;

use App\Models\Customer;

final readonly class PlaceOrder
{
    public function __construct(private DiscountResolver $discountResolver) {}

    public function totalFor(Customer $customer, int $subtotalCents): int
    {
        $result = $this->discountResolver->resolve($customer)->calculate($subtotalCents);

        return $subtotalCents - $result->amountCents;
    }
}

Persist amountCents and label on the order. Recalculating a historical order from a current promotion makes old commitments depend on today’s configuration.

Test the contract and the neutral path

The important test is behaviour, not a class-exists assertion. These Pest tests prove exact cents and make the neutral result visible to a maintainer.

php
<?php

use App\Billing\NoDiscount;
use App\Billing\PercentageDiscount;

it('returns a visible zero discount for an eligible absence', function () {
    $result = (new NoDiscount())->calculate(12_500);

    expect($result->amountCents)->toBe(0)
        ->and($result->label)->toBe('No eligible discount');
});

it('calculates a percentage discount in integer cents', function () {
    $result = (new PercentageDiscount(1_500))->calculate(12_500);

    expect($result->amountCents)->toBe(1_875)
        ->and($result->label)->toBe('15% partner discount');
});

Add a feature test for the resolver when eligibility is stored in Eloquent. It should assert that a customer without an active promotion resolves to NoDiscount, rather than merely asserting that checkout did not crash. That catches an accidental shift in the meaning of a missing relation.

Pitfalls: neutral is not successful

The pattern is safe only when the neutral outcome is valid under the same contract. NoDiscount is safe because charging the undiscounted price is legitimate and visible. These examples are not safe Null Objects:

  • a NullPaymentGateway reporting success after a failed charge;
  • an AnonymousUser passing an authorization check;
  • an EmptyShippingAddress used to buy a delivery label;
  • a NullInventoryReservation allowing an oversold order to continue.

Each represents a failure, a required missing value, or a security decision. Throw a domain exception, return validation feedback, or model a pending state. A Null Object may omit an optional side effect, such as a no-op analytics sink in a focused test. It must never invent success.

When not to use it

Use ?-> for a genuinely incidental optional value with one local consumer: adding a nickname to a notification does not justify a class. Use match for two stable, simple paths without independent dependencies. If multiple algorithms evolve separately, Strategy is the better boundary; a Null Object can be one strategy for the legitimate “none” case.

Use an option/result type when callers must consciously choose how to handle absence. A missing compliance document should force an upload or download flow; an empty document would hide a meaningful choice. The practical rule is simple: choose Null Object only when absence is expected, neutral, and safe. Resolve it once at the edge, test it as seriously as the active behaviour, and leave real failures visible.

Keep selection separate from construction

Putting the null check directly in a controller works until orders also arrive from imports, subscription renewals, and queued integrations. The resolver is a single policy seam: it can later check validity dates, customer segments, minimum subtotals, and excluded products without changing every order caller.

Keep it small. The resolver selects an implementation, PercentageDiscount calculates, and a model or repository answers persistence questions. If one active rule needs negotiated prices, tax jurisdiction, or a remote campaign, give that implementation an explicit dependency. Do not add nullable optional arguments to Discount::calculate() merely because one algorithm needs more information than another.

This also clarifies Laravel container usage. It can inject DiscountResolver into PlaceOrder because its dependencies are stable. It cannot globally bind the final Discount without losing per-customer context. Contextual binding is useful where a known consumer always needs one policy; runtime eligibility belongs in the resolver.

Finally decide whether a zero-rate promotion differs from no promotion. Both produce zero cents, but analytics and customer communication may need the distinction. Model PercentageDiscount(0) or a dedicated policy when it matters; do not collapse meaningful states because their arithmetic matches.

Review this boundary whenever a new caller arrives. If that caller needs to ask instanceof NoDiscount, the contract is missing information or the consumer is trying to reclaim a persistence detail. Add a meaningful result field or move the decision to the resolver instead. Consumers should care about the calculated result and its documented label, never about which concrete class produced it.

That discipline keeps the boundary durable as policy changes.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.