- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You have written this bug. Everyone who touched DateTimeImmutable has.
$due = new DateTimeImmutable('2026-01-01');
$due->modify('+30 days');
// $due is still 2026-01-01. The new date was thrown away.
modify() returns a new instance. It does not touch the original. Discard the return value and nothing happens, no error, no crash, just a date that is wrong by thirty days somewhere downstream. The class did its job. You dropped the result on the floor.
PHP 8.5 ships a language feature aimed straight at this class of mistake: the #[\NoDiscard] attribute. Mark a function or method with it, and PHP emits a warning when a caller ignores the return value. This is the RFC that landed for 8.5, and it changes how you design any API where the return carries the point of the call.
What #[\NoDiscard] actually does
Attach the attribute to a function or method. When someone calls it and does nothing with the result, PHP raises a warning.
#[\NoDiscard("the filtered list is the result of the call")]
function keep_active(array $users): array
{
return array_filter($users, fn ($u) => $u->isActive());
}
keep_active($users); // warning
$active = keep_active($users); // fine
The discarded call produces:
Warning: The return value of function keep_active() is
expected to be consumed, the filtered list is the
result of the call
The message you pass to the attribute becomes part of the warning text. It works the same way #[\Deprecated] folds its message into the deprecation notice. Skip the message and you still get the warning, just without the trailing explanation.
For native functions the level is E_WARNING. For your own code it comes through as E_USER_WARNING, so it flows into whatever handler and logger you already run. In a test suite that treats warnings as failures, the discard becomes a red build. That is the point: turn a silent mistake into a loud one, at the boundary where you can still fix it.
The (void) cast, and why it had to exist
Sometimes ignoring the return is deliberate. You call a function for a side effect and genuinely do not care about what it hands back. PHP 8.5 added a (void) cast for exactly this, and it shipped in the same RFC because the attribute needed an escape hatch.
// I know keep_active returns something. I don't want it.
(void) keep_active($users); // no warning
The cast says "the discard is intentional" in a way a reader and the engine both understand. It also protects the call from OPcache, which is otherwise free to optimize away a result-less call to a side-effect-free function.
One constraint to keep in your head: (void) is a statement-level cast, not an expression you can nest.
if ((void) keep_active($users)) { // parse error
// ...
}
If you are inside an expression, you are already using the value, so the warning would never fire there anyway.
Marking a Result type as must-use
Here is where it earns its place in a domain layer. Teams that model failure with a Result type (instead of throwing) have one recurring hole: nothing forces the caller to inspect the result. You return a Result::failure(...), the caller ignores it, and the error evaporates.
final class Result
{
private function __construct(
public readonly bool $ok,
public readonly mixed $value = null,
public readonly ?string $error = null,
) {}
#[\NoDiscard("a Result carries success or failure; check it")]
public static function success(mixed $value): self
{
return new self(true, $value);
}
#[\NoDiscard("a Result carries success or failure; check it")]
public static function failure(string $error): self
{
return new self(false, error: $error);
}
}
Mark each factory method that returns a Result; the attribute targets methods, not classes. Now a use case that forgets to branch on the outcome gets flagged:
$this->processPayment($order); // warning: result discarded
$result = $this->processPayment($order);
if (! $result->ok) {
$this->logger->error($result->error);
}
The compiler will not let a failed payment slide past unread. That is a guarantee your try/catch never gave you, because forgetting a catch is invisible and forgetting to read a Result now is not.
Marking immutable "with" methods
Back to the DateTimeImmutable footgun. Any immutable object with with* methods has the same trap: the method returns a fresh instance and the caller has to reassign. Mark those methods and the trap closes.
final class Money
{
public function __construct(
public readonly int $amount,
public readonly string $currency,
) {}
#[\NoDiscard("returns a new Money; the original is unchanged")]
public function add(Money $other): self
{
return new self(
$this->amount + $other->amount,
$this->currency,
);
}
}
$total->add($lineItem); // warning: you dropped the sum
$total = $total->add($lineItem); // correct
Every value object you write with copy-on-write semantics is a candidate. The whole contract of an immutable type is "I give you a new one back." The attribute makes that contract enforced instead of assumed.
Handles and resources you must not drop
The other natural fit is any call that hands back a handle, a lock, or a token the caller is responsible for. PHP 8.5 is conservative about which internal functions get the attribute, and the discussion around whether flock() should carry it (see the php-src issue) shows the bar: the return has to be something a correct program almost always consumes.
Your own infrastructure code clears that bar often. A lock acquisition whose return tells you if you got the lock is meaningless when discarded.
#[\NoDiscard("false means the lock was not acquired")]
function acquire_lock(string $key, int $ttl): bool
{
// ... returns whether the lock is now held
}
acquire_lock('invoice:42', 30); // warning
if (! acquire_lock('invoice:42', 30)) {
throw new LockException('invoice:42');
}
The classic bug is treating "I tried to lock" as "I hold the lock." The attribute pushes the caller to read the answer.
The constraints worth knowing
A few rules keep you from misusing it:
- It is a compile-time error to put
#[\NoDiscard]on a function typed: voidor: never. There is no value to consume, so the attribute is meaningless there. - Magic methods that must return void or nothing (
__construct,__clone, and friends) reject it for the same reason. - The warning fires on the call site, so a library shipping the attribute helps every consumer without them opting in. That is a real behavior change on upgrade. Audit your own APIs before adding it so you do not flood a downstream team's logs on the day they move to 8.5.
Put it on the small set of functions where discarding the result is a bug close to 100% of the time. Sprinkle it everywhere and it becomes noise people learn to mute, which is worse than not having it.
When not to reach for it
A getter whose result callers legitimately ignore in some paths does not want this. A logger method that returns $this for chaining but is usually called for its side effect does not want it either. The test is simple: if discarding the return is ever a reasonable thing to do, leave the attribute off. Reserve it for the cases where a dropped return is always a mistake, the Result you forgot to check, the new immutable you forgot to reassign, the lock you assumed you held.
PHP spent years letting these bugs ship in silence because the type system had no way to say "this one matters." Now it does, in one attribute and one (void) cast.
If this was useful
The #[\NoDiscard] cases that pay off most are the ones in your domain layer: a Result type that must be inspected, a value object that must be reassigned. Those live in the code the framework never sees, the part that has to stay correct no matter which HTTP layer wraps it this year. Keeping that boundary honest, where the domain owns its own contracts and the framework stays at the edge, is exactly what Decoupled PHP is about.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)