A missing coupon is not an error. Checkout should continue, the discount should be zero, and callers should not care whether a coupon was supplied.
A missing payment gateway is different. Pretending that a charge succeeded would corrupt the order workflow. Even doing nothing would turn a deployment mistake into a plausible-looking transaction.
Both dependencies can be typed as nullable, but only one has an honest neutral
behavior. That is the boundary for the Null Object pattern: use it when
absence means “perform the same operation with no effect,” not whenever an
if statement looks untidy.
Repeated branches reveal a shared policy
Suppose coupon selection returns either a coupon or null:
final readonly class CartTotals
{
public function calculate(Cart $cart, ?Coupon $coupon): Totals
{
$subtotal = $cart->subtotal();
$discount = $coupon === null
? Money::zero($subtotal->currency())
: $coupon->discountFor($cart);
return new Totals($subtotal, $discount);
}
}One branch is not a design crisis. Keep it if this is the only caller and the distinction remains useful.
The pressure changes when controllers, previews, recalculation jobs, and tests all decide what a missing coupon means. The duplicated code is not merely a null check; it is a distributed policy that no coupon produces no discount.
Give that policy an implementation:
interface Coupon
{
public function discountFor(Cart $cart): Money;
}
final readonly class NoCoupon implements Coupon
{
public function discountFor(Cart $cart): Money
{
return Money::zero($cart->currency());
}
}
final readonly class PercentageCoupon implements Coupon
{
public function __construct(private Percentage $percentage) {}
public function discountFor(Cart $cart): Money
{
return $cart->subtotal()->percentage($this->percentage);
}
}CartTotals can now require the contract:
final readonly class CartTotals
{
public function calculate(Cart $cart, Coupon $coupon): Totals
{
return new Totals(
subtotal: $cart->subtotal(),
discount: $coupon->discountFor($cart),
);
}
}The important change is not the missing branch. “No coupon” now has one name, one currency-safe result, and the same contract as every real coupon.
The neutral implementation must preserve the contract
A Null Object is substitutable only if callers do not need to detect it. That requires more than returning a convenient default. The same behavioral contract that governs every subtype governs the neutral implementation too.
For a coupon, zero money in the cart’s currency is an identity value: applying
it leaves the total unchanged. For a logger, discarding a message may be an
accepted fallback. PSR-3 explicitly provides
Psr\Log\NullLogger for that case, while warning that
conditional logging may be better when building context is expensive.
Other apparent defaults are not neutral:
- an authorizer that always allows changes security policy;
- a payment gateway that returns a fake transaction ID reports success that never happened;
- storage that discards writes violates a contract promising later reads; and
- a repository returning an empty entity hides the difference between absent data and an entity with empty fields.
Those cases should return an explicit absence, a result type, or an exception,
depending on the contract. Naming a class NullPaymentGateway cannot make its
behavior safe.
A useful test applies the same behavioral expectations to real and neutral implementations:
it('never returns a discount in another currency', function (Coupon $coupon) {
$cart = Cart::in('EUR')->withSubtotal(Money::EUR(10_00));
expect($coupon->discountFor($cart)->currency())->toBe('EUR');
})->with([
'none' => new NoCoupon(),
'ten percent' => new PercentageCoupon(Percentage::fromInt(10)),
]);The test does not assert that both objects return the same amount. It asserts the invariant the contract promises.
Laravel’s default models solve a narrower problem
Eloquent supports default models through
withDefault() on belongsTo, hasOne,
hasOneThrough, and morphOne relationships:
public function profile(): HasOne
{
return $this->hasOne(Profile::class)->withDefault([
'display_name' => 'Anonymous',
]);
}This can simplify presentation code:
return $user->profile->display_name;It also creates an unsaved Profile object that looks like a model. Code must
not infer persistence merely because it received a Profile instance.
$user->profile->exists remains false for the default model, and operations
that require a stored profile should still treat absence explicitly.
That makes withDefault() a good fit for neutral display values and a poor
fit for domain decisions such as “this customer has completed onboarding.” A
default relationship removes a conditional access; it does not prove that the
relationship exists.
Resolve absence where its meaning is known
The container can bind a neutral collaborator, but global defaults deserve
suspicion. A coupon is usually selected from request or customer state, so a
resolver at that boundary can return NoCoupon:
final readonly class CouponResolver
{
public function __construct(private CouponRepository $coupons) {}
public function for(?string $code): Coupon
{
if ($code === null || $code === '') {
return new NoCoupon();
}
return $this->coupons->getByCode($code);
}
}An unknown non-empty code is not neutral in this example; getByCode() should
fail with a domain-specific error. The resolver converts only the absence it
understands. It does not turn invalid input into “no discount.”
This placement also makes observability clearer. Metrics can distinguish an omitted coupon from a rejected one before both reach the calculation. If a Null Object is selected deep inside a service, that distinction may already be lost.
Keep the branch when the difference matters
null is often the more honest type for data. A search that finds no order, a
shipment without an estimated delivery time, and a user without a verified
email all carry information that callers may need to handle differently.
The choice is not about which type looks cleanest. It is about what the caller must still know:
| Representation | What remains visible to the caller |
|---|---|
?Coupon | A coupon may be absent, and the caller must decide what absence means. |
CouponLookupResult | Omitted, rejected, and accepted coupons remain distinct workflow outcomes. |
| Domain exception | The request cannot satisfy a required contract, so normal execution stops. |
NoCoupon | The calculation continues with zero discount, and the distinction is deliberately erased. |
The resolver above chooses NoCoupon only for omitted input. It treats an
unknown non-empty code as invalid. If a checkout screen or an analytics event
must distinguish omitted, rejected, and accepted coupons, an explicit result
object should carry those outcomes instead. Converting all three to
NoCoupon would discard information before the caller could use it.
A Null Object is preferable only when every caller should perform the same operation and absence has a stable identity behavior.
Use the pattern to centralize a real neutral policy. If callers need to ask whether the object is “the null one,” the substitution has failed and the branch belongs in the model after all.