Nullable versus union
null is the easiest type to reach for and the hardest to read. Does ?Card mean "no card yet", "paid some other way", "card removed", or "something went wrong upstream"? The type can't say, so every caller guesses.
Two nullables, four states
A payment is made either by card or with a gift card. Modelled with two nullable properties:
public ?Card $card, public ?GiftCard $giftCard,
That type allows four combinations, and only two of them make sense. Every caller now has to handle "both" and "neither", or hope they never happen.
A union says exactly what's allowed:
public Card|GiftCard $paidWith,
Exactly one of the two, always present, enforced by PHP when the object is built. Callers narrow it with instanceof, and PHPStan follows that narrowing: inside if ($paidWith instanceof GiftCard), it knows you have a GiftCard, and in the else, a Card.
When null is the honest answer
null is right when something can genuinely be absent and absence means one thing. A gift message is a good example: there is one, or there isn't. ?string $giftMessage says that clearly, as long as there's only one way to say "none". If "", " " and null all mean "no message", callers have to check all three, and someone eventually prints an empty gift card. Normalise at the boundary so null is the only one.
Power-up
<?php final class Payment { public function __construct( public readonly int $pence, public readonly ?Card $card = null, public readonly ?GiftCard $giftCard = null, public readonly ?string $giftMessage = null, // or '', or ' ' ) {} }
<?php final class Payment { public readonly ?string $giftMessage; // null is the only "none" public function __construct( public readonly int $pence, public readonly Card|GiftCard $paidWith, ?string $giftMessage = null, ) { $giftMessage = $giftMessage === null ? null : trim($giftMessage); $this->giftMessage = $giftMessage === '' ? null : $giftMessage; } }