PHP 8.5 #[\NoDiscard]: Catch “Ignored Return Value” Bugs Before They Ship
A code-first guide to PHP 8.5’s #[\NoDiscard] warnings, the new (void) cast, and practical patterns for Results, immutability, and safer…
PHP 8.5 #[\NoDiscard]: Catch “Ignored Return Value” Bugs Before They Ship
A code-first guide to PHP 8.5’s #[\NoDiscard] warnings, the new (void) cast, and practical patterns for Results, immutability, and safer APIs.

image from loud-technology
Introduction: the bug category nobody brags about
Some bugs are loud: exceptions, fatal errors, red dashboards. Others are quiet — and more expensive.
The quiet ones are the “everything ran, but nothing happened” kind. A method is called, side effects occur, and yet the critical return value (success flag, error list, new immutable instance) gets ignored. The code looks fine in a quick scan. Tests may pass if they don’t cover the edge case. The bug hides until production.
PHP has always allowed this style of mistake:
doSomethingImportant(); // returns a value… but nobody uses it
PHP 8.5 adds a native way to flag these situations: **#[\NoDiscard]**.
When a function or method is marked with #[\NoDiscard] and the caller doesn’t use the return value, PHP emits a warning. This is intended as a “compiler-level hint” (in practice it’s enforced by the engine at runtime/compile time) that improves API safety without changing behavior via exceptions. (PHP)
This article focuses on how to use #[\NoDiscard] well:
- the types of bugs it helps prevent,
- the exact semantics (what “used” means),
- high-value patterns like
Result/Eitherand immutable builders, - adoption strategy that won’t annoy your team,
- and when not to use it (false positives are real).
No detours into other PHP 8.5 features.
1) The bug category: important calls where the return value gets ignored
There are a few repeat offenders that show up across PHP codebases.
1.1 “It returns a boolean, but we assumed it always succeeds”
This is the classic example:
$ok = rename($tmpFile, $finalFile);
Someone refactors and the assignment disappears:
rename($tmpFile, $finalFile);
// continues as if the move succeeded
In dev it works. In production a permissions edge case appears and you end up reading a file that never moved.
Now, not every boolean-returning function should be #[\NoDiscard]. But in your own API, if the return value is meaningful, ignoring it should at least raise an eyebrow.
1.2 “It returns errors, but the happy path is so common we never noticed failures”
Batch processing is a perfect storm: 99.9% success means ignoring the return value won’t break most runs.
Example shape:
- function processes many items
- returns per-item error details
- side effects happen either way
- ignoring the return value hides partial failures
The official RFC uses exactly this reasoning to motivate #[\NoDiscard]. (wiki.php.net)
1.3 “Immutable APIs return a new instance — but we called it like it mutates”
This is subtle and common when moving from mutable to immutable objects.
You write an immutable “update” method:
$user = $user->withEmail($newEmail);
Someone later writes:
$user->withEmail($newEmail);
// expects $user to be changed… but it isn't
No error. No exception. Just silently unchanged state.
The RFC explicitly calls out this gotcha in DateTimeImmutable::set*() methods as a real-world example of “it sounds like it mutates, but it returns a new instance.” (wiki.php.net)
1.4 “Result objects are returned, but we forgot to unwrap/check them”
If you use a Result type (or Either) to avoid exceptions, ignoring the return value often means ignoring errors.
That’s not always fatal immediately — but it pushes error handling into “maybe later,” which is how it disappears.
2) What #[\NoDiscard] is (and the behavior you should expect)
At its core, #[\NoDiscard] is an attribute you put on functions and methods to indicate:
“If you call this and don’t use the return value, it’s probably a bug.”
Minimal usage:
#[\NoDiscard]
function createSession(): string {
return bin2hex(random_bytes(16));
}
createSession(); // warning in PHP 8.5
PHP’s behavior is defined in the RFC and in the PHP manual migration notes:
- If a
#[\NoDiscard]function is called and its return value is not used, PHP emits a warning. (PHP) - For internal (native) functions the engine emits
E_WARNING, and for userland functions it emitsE_USER_WARNING. (wiki.php.net) - You can attach a message:
#[\NoDiscard("…")], and the message is included in the warning text (similar to#[Deprecated]). (wiki.php.net)
2.1 What counts as “using the return value”?
This is the most important nuance: “used” is syntactic, not semantic.
The RFC defines “using the return value” broadly: the returned value just needs to be part of any other expression. Assigning it to a variable — even a dummy one — counts. Casting counts. (wiki.php.net)
So these are considered “used”:
$unusedButAssigned = createSession(); // no warning
(bool) createSession(); // no warning (but see OPcache note below)
That means #[\NoDiscard] doesn’t guarantee correct behavior. It guarantees you didn’t accidentally drop the result on the floor.
2.2 (void) cast: explicitly discarding is now a first-class thing
PHP 8.5 also introduces a new (void) cast:
(void) createSession(); // no warning
It has no runtime effect, but it signals intent: “yes, I’m ignoring this on purpose.” It’s also meant to suppress #[\NoDiscard] warnings, and potentially IDE or static analysis diagnostics. (PHP)
One key detail from the RFC: (void) is treated as a statement, not an expression, so you can’t embed it inside another expression. Attempting to do so is a syntax error. (wiki.php.net)
2.3 Constraints: you can’t apply #[\NoDiscard] everywhere
The RFC defines compile-time errors if #[\NoDiscard] is used on:
- functions typed as
: voidor: never, - magic methods required to be
void/ no return (like__construct,__clone), - property hooks. (wiki.php.net)
So this will fail:
#[\NoDiscard]
function logSomething(string $msg): void {
error_log($msg);
}
// Fatal: void function does not return a value, but #[\NoDiscard] requires a return value
That’s intentional: the attribute only makes sense if there is something to discard.
2.4 A “sharp edge” worth knowing: warnings can be made fatal — and the engine checks before calling
Most teams treat warnings as noise. Some teams convert warnings into exceptions (common in strict environments).
The RFC notes that the engine verifies “return value used” right before calling the function, after evaluating parameters. If you have a throwing error handler, the warning can throw an exception and the function will not be called — described as a “fail-closed” behavior. (wiki.php.net)
That’s usually desirable for #[\NoDiscard] functions (because ignoring their return value is considered unsafe), but you should be aware of it if the function has important side effects.
3) Most useful examples: Result, Either, and immutable builders/updates
Let’s get practical. These are patterns where #[\NoDiscard] delivers real value.
3.1 A Result type: error handling without exceptions
Here’s a minimal Result implementation:
<?php
declare(strict_types=1);
final class Result
{
private function __construct(
private bool $ok,
private mixed $value,
private ?string $error,
) {}
public static function ok(mixed $value = null): self
{
return new self(true, $value, null);
}
public static function err(string $error): self
{
return new self(false, null, $error);
}
public function isOk(): bool { return $this->ok; }
public function isErr(): bool { return !$this->ok; }
public function unwrap(): mixed
{
if (!$this->ok) {
throw new RuntimeException($this->error ?? 'Unknown error');
}
return $this->value;
}
public function error(): ?string { return $this->error; }
}
Now imagine a function that validates and returns a Result:
#[\NoDiscard("Validation results must be handled (ok/err)")]
function validateUsername(string $name): Result
{
$name = trim($name);
if ($name === '') {
return Result::err("Username cannot be empty.");
}
if (strlen($name) < 3) {
return Result::err("Username is too short.");
}
return Result::ok($name);
}
Calling it like this triggers a warning:
validateUsername($_POST['username'] ?? '');
And that’s exactly what you want: if you’re adopting a Result pattern, ignoring it is almost always a mistake.
A correct call site becomes explicit:
$res = validateUsername($_POST['username'] ?? '');
if ($res->isErr()) {
http_response_code(422);
echo $res->error();
exit;
}
$username = $res->unwrap();
Could a developer still do $_ = validateUsername(...) and ignore it? Yes, and PHP would consider that “used.” (wiki.php.net)
But the main failure mode—accidentally writing a bare call—is caught.
3.2 Either style: return a value or an error object
Some teams prefer a more structured error:
final class ValidationError
{
public function __construct(public string $code, public string $message) {}
}
final class Either
{
private function __construct(
public bool $isRight,
public mixed $right,
public ?ValidationError $left,
) {}
public static function right(mixed $value): self
{
return new self(true, $value, null);
}
public static function left(ValidationError $err): self
{
return new self(false, null, $err);
}
}
Marking functions returning Either as #[\NoDiscard] is usually correct, because the whole point is to force the caller to decide which branch they’re on.
3.3 Immutable builder/update APIs: the “I thought it mutated” problem
Consider an immutable builder where each method returns a new builder:
<?php
declare(strict_types=1);
final readonly class InvoiceBuilder
{
public function __construct(
public array $lines = [],
public int $totalCents = 0,
) {}
#[\NoDiscard("InvoiceBuilder is immutable; you must capture the returned builder.")]
public function withLine(string $label, int $amountCents): self
{
if ($amountCents < 0) {
throw new InvalidArgumentException('amountCents must be >= 0');
}
$newLines = $this->lines;
$newLines[] = ['label' => $label, 'amountCents' => $amountCents];
return new self(
lines: $newLines,
totalCents: $this->totalCents + $amountCents
);
}
#[\NoDiscard("Calling build() without using the invoice is almost certainly a bug.")]
public function build(): array
{
return [
'lines' => $this->lines,
'totalCents' => $this->totalCents,
];
}
}
Now watch what happens in a typical mistake:
$builder = new InvoiceBuilder();
$builder->withLine('Subscription', 1500);
$builder->withLine('Support', 500);
$invoice = $builder->build();
Without #[\NoDiscard], this produces an invoice with no lines, because the returned builders were ignored.
With #[\NoDiscard], each ignored withLine() return triggers a warning, pushing you toward the correct usage:
$builder = (new InvoiceBuilder())
->withLine('Subscription', 1500)
->withLine('Support', 500);
$invoice = $builder->build();
This is exactly the sort of bug #[\NoDiscard] is meant to surface: it’s easy to do, often passes tests, and is annoying in production.
3.4 Bonus: places where PHP itself benefits
The RFC applies #[\NoDiscard] to a small set of native APIs where ignoring the result is known to cause subtle issues:
flock()(ignoring lock failure can lead to corruption under contention),DateTimeImmutable::set*()(common migration gotcha from mutableDateTime). (wiki.php.net)
Even if you never use those functions directly, it’s a strong signal: the feature is targeting real-world mistakes, not theoretical purity.
4) Adoption strategy: start with domain/service layers (not everything)
If you adopt #[\NoDiscard] everywhere, you’ll create noise and the team will tune it out. The RFC explicitly recommends using it where ignoring a return is a likely accidental mistake and leads to bugs that are hard to detect during testing. (wiki.php.net)
A practical rollout plan:
4.1 Start in places where ignoring the return value is clearly dangerous
High-value candidates:
- domain operations that can partially fail but continue (batch processing)
- persistence calls returning a
Result/ error list - immutable update methods (
with*,set*on immutable objects) - “try” style APIs that encode failure in the return value
Low-value candidates:
- pure functions (e.g.
str_contains()style checks): calling them and doing nothing is already unusual, and ignoring the return rarely causes hidden damage. The RFC usesstr_contains()as an example of a bad use-case. (wiki.php.net) - methods that primarily exist for side effects and return a convenience value that is often legitimately ignored
4.2 Make warnings visible in dev and CI, but don’t brick production on day one
Because the engine emits warnings (not exceptions), you can gradually tighten:
- dev environment: show warnings loudly
- CI: treat
E_USER_WARNINGas failure (optional, after a short transition) - production: keep default handling unless you’re confident your warning policy is strict
Remember: if your org converts warnings to exceptions, #[\NoDiscard] can prevent the function from running at all (fail-closed). (wiki.php.net)
That’s sometimes great, but it’s a behavior change you should introduce intentionally.
5) Code review integration: the “must-use return” rule
#[\NoDiscard] works best when it reinforces a team rule, not when it replaces thinking.
Here’s a simple rule set that plays nicely with code review:
5.1 In review, treat #[\NoDiscard] warnings as a design signal
When you see a #[\NoDiscard] warning, don’t just “silence it.” Ask:
- Are we ignoring it accidentally? (most common)
- If we truly want to ignore it, is
(void)appropriate? - Or is the API returning the wrong thing?
5.2 Use (void) as an explicit “I know what I’m doing” marker
Example: you call a method that returns a cache key, but you only want the side effect:
(void) $cache->warmUp($userId);
This is now a clean, readable convention: you are intentionally discarding the value. (PHP)
That is much better than:
$unused = $cache->warmUp($userId);
Because $unused can easily survive refactors and confuse future readers.
5.3 Combine with static analysis, don’t fight it
Static analyzers and IDEs already warn about unused return values for pure functions. The RFC notes that tools like PHPStorm, PHPStan, and Psalm already catch “pure return ignored” issues (like the DateTimeImmutable gotcha), but they typically don’t have an equivalent for impure functions with important returns—which is what #[\NoDiscard] is filling in. (wiki.php.net)
So the combo tends to be:
- static analyzer: “pure return unused”
#[\NoDiscard]: “important return unused” (even if impure)
6) Refactor example: make an easy-to-miss API harder to misuse
Let’s do a practical refactor that mirrors real life: a method that “saves” and returns a status.
6.1 Before: returns a boolean, but callers often ignore it
final class UserRepository
{
public function save(User $user): bool
{
// ... write to DB ...
// return false on conflict / failure
return true;
}
}
Call sites tend to drift toward:
$repo->save($user);
// assumes saved
If the return is meaningful, that’s a bug waiting to happen.
6.2 After: mark it as #[\NoDiscard] and add a message
final class UserRepository
{
#[\NoDiscard("Save may fail; handle the return value or explicitly discard it with (void).")]
public function save(User $user): bool
{
// ... write to DB ...
return true;
}
}
Now, any call site that ignores it produces a warning.
6.3 Better: return a richer type and keep the warning
A boolean isn’t very descriptive. If you can afford it, return a Result:
final class UserRepository
{
#[\NoDiscard("Save may fail; callers must handle the Result.")]
public function save(User $user): Result
{
// Example logic
$ok = true;
if (!$ok) {
return Result::err("Write failed due to conflict.");
}
return Result::ok($user);
}
}
Now it’s harder to accidentally skip error handling, and the API becomes more self-documenting.
6.4 When you truly want to ignore it
There are legitimate cases:
- best-effort cache writes
- telemetry sends
- opportunistic cleanups
That’s when (void) is appropriate:
(void) $repo->save($user); // "I am intentionally not checking this."
That reads cleanly in code review and helps prevent accidental “silent ignore” regressions.
7) Limits and false positives: when not to use #[\NoDiscard]
#[\NoDiscard] is powerful, but it’s not a universal “quality badge.” Overuse creates noise, and noise kills signal.
7.1 Don’t use it for functions where ignoring the result is harmless
Pure queries like:
str_contains()strlen()- string transformation helpers
If you call them and do nothing, the bug is usually obvious: you computed something and didn’t use it, and there were no side effects. The RFC explicitly recommends against using #[\NoDiscard] on functions like str_contains() because ignoring the result is unlikely and has no harmful effect besides wasted computation. (wiki.php.net)
7.2 Don’t use it to “force” coding style
You’ll be tempted to mark lots of methods #[\NoDiscard] because “it’s cleaner if callers always capture the return.” That’s a style preference, not necessarily a safety issue.
Use it when ignoring the return is likely to be accidental and harmful.
7.3 Watch out for APIs with legitimate “fire-and-forget” usage
Some methods return a value for convenience but are primarily used for side effects. Marking them #[\NoDiscard] forces callers to litter code with (void) casts, which is just a different kind of clutter.
If you see a lot of (void) for one function, that’s a hint the attribute might be on the wrong API.
7.4 “Used” doesn’t mean “handled”
Because the definition of “used” is broad, you can satisfy #[\NoDiscard] without truly handling anything:
$tmp = $repo->save($user); // no warning, still ignored semantically
This is not a failure of the feature — it’s a reminder that #[\NoDiscard] is a guardrail, not a full correctness proof. (wiki.php.net)
8) Team conventions: naming methods that “must be captured”
Good teams don’t rely on attributes alone. They use conventions that guide correct usage before warnings even show up.
Here are naming conventions that pair well with #[\NoDiscard]:
8.1 with* for immutable updates
If your class is immutable:
withEmail()withStatus()withTimeout()
And those methods should almost always be #[\NoDiscard], because ignoring the return value usually means “no change happened.”
8.2 try* for operations where failure is encoded in the return
Examples:
tryLock() : booltryParse() : ResulttryConnect() : Result
If a method name starts with try, callers generally expect to check the outcome. Marking it #[\NoDiscard] reinforces that.
8.3 build() / finalize() patterns
If build() produces the thing you need, calling it and ignoring it is nearly always a mistake.
That’s a good place for #[\NoDiscard].
8.4 Keep messages short and action-oriented
A good message is something you’d want a teammate to see in CI logs:
- “This Result must be handled.”
- “Immutable update: capture the returned instance.”
- “Operation can partially fail; consume the error list.”
The RFC explicitly supports an optional message and includes it in the warning text. (wiki.php.net)
Conclusion: #[\NoDiscard] is a guardrail for the bugs that don’t crash
The strongest argument for #[\NoDiscard] isn’t theory. It’s maintenance.
Ignored return values are a repeatable failure mode in real PHP code — especially when:
- the function mostly succeeds,
- the API is immutable (returns a new instance),
- or failures are reported via return values rather than exceptions.
PHP 8.5 gives you a native way to catch those mistakes early, using warnings and an explicit (void) cast to keep intentional discards readable. (PHP)
Use it surgically:
- start with domain/service APIs where ignoring the return is harmful,
- avoid pure functions and “mostly side effect” methods,
- and pair it with naming conventions (
with*,try*) so the code reads correctly even before the engine complains.
If you do that, #[\NoDiscard] becomes one of those small features that quietly reduces production surprises—without forcing your whole team into a new programming model.
References
- PHP 8.5 Release Announcement (
#[\NoDiscard]overview, warning behavior). (PHP) - PHP Manual: PHP 8.5 new features (
#[\NoDiscard]and(void)cast). (PHP) - PHP RFC: “Marking return values as important (#[\NoDiscard])” (warning levels, meaning of “used,”
(void)cast details, constraints, recommended usage). (wiki.php.net)
메타데이터
- post_id
- dea9614dc094
- slug
- php-8-5-nodiscard-catch-ignored-return-value-bugs-before-they-ship-dea9614dc094
- url
- https://medium.com/@annxsa/php-8-5-nodiscard-catch-ignored-return-value-bugs-before-they-ship-dea9614dc094
- canonical_url
- https://medium.com/@annxsa/php-8-5-nodiscard-catch-ignored-return-value-bugs-before-they-ship-dea9614dc094
- author_url
- https://medium.com/@annxsa
- status
- ok
- fetched_at
- 2026-06-27 23:56:40