Development

PHP 8.5's #[NoDiscard] Attribute: Stop Silently Ignoring Those Important Return Values.

6 minutes

Right, let's talk about a bug pattern we've all created at least once, it's not a pattern that breaks anything as such is more a pattern of we've forgotten to do something.

You write a function that returns something important! - maybe a success/failure status, maybe validation results, maybe a resource that needs to be handled properly. And then somewhere in your codebase, someone (possibly you, possibly a teammate, possibly at 3am during an incident) calls that function... and completely ignores the return value.

The code runs. No errors. No warnings. Nothing.

Then sometime in the future, you're debugging why data isn't being validated properly, or why locks aren't being acquired, or why that important processing step silently fails.

PHP 8.5 is adding a solution: the #[\NoDiscard] attribute. And while it might seem like a small addition, It's actually super useful and we can already see the benefits of using this attribute in our codebases.

The Problem

Here's and example bit of code to highlight the possible problems that this new attribute could help us fix:

php
1<?php
2 
3class OrderService
4{
5 public function processBulkOrders(array $orders): array
6 {
7 $errors = [];
8 
9 foreach ($orders as $order) {
10 try {
11 $this->processOrder($order);
12 } catch (\Exception $e) {
13 $errors[] = [
14 'order_id' => $order->id,
15 'error' => $e->getMessage()
16 ];
17 }
18 }
19 
20 return $errors;
21 }
22}
23 
24// Somewhere else in the codebase...
25$orderService->processBulkOrders($orders);
26// Oops - we forgot to check if there were errors!

Look at that bottom line. The function returns an array of errors (we should really fix that perhaps a collection would be better ;)). But we never captured it. We never checked it. The orders were processed, some might have failed, and we have no idea which ones or why.

This is a completely legal PHP program. It runs without warnings. And it's a bug waiting to cause problems.

Or here's another example of a potential future problem:

php
1<?php
2 
3// In a service class
4public function acquireLock(string $key): bool
5{
6 return Cache::lock($key, 10)->get();
7}
8 
9// In a controller
10public function criticalOperation(Request $request)
11{
12 $this->acquireLock('critical-resource');
13 
14 // Start modifying critical data without checking if we got the lock
15 // ...
16}

We forgot to check if the lock was acquired. Under normal load, this works fine. Under heavy load, it could cause data corruption because multiple processes were modifying the same data simultaneously.

Enter #[\NoDiscard]

PHP 8.5 lets you mark a function or method to indicate: "Hey, this return value is important. If you ignore it, that's probably a bug."

Here's how it works:

php
1<?php
2 
3class OrderService
4{
5 #[\NoDiscard("The errors array indicates which orders failed processing")]
6 public function processBulkOrders(array $orders): array
7 {
8 $errors = [];
9 
10 foreach ($orders as $order) {
11 try {
12 $this->processOrder($order);
13 } catch (\Exception $e) {
14 $errors[] = [
15 'order_id' => $order->id,
16 'error' => $e->getMessage()
17 ];
18 }
19 }
20 
21 return $errors;
22 }
23}
24 
25$orderService = new OrderService();
26// Now this triggers a warning:
27$orderService->processBulkOrders([]);
28// Warning: The return value of method OrderService::processBulkOrders() should either be used or intentionally ignored by casting it as (void)

PHP emits a warning at runtime if you call the function without using the return value. And if you're running with error handlers that convert warnings to exceptions (like Symfony does by default), the dangerous code never even executes.

The Static Analysis Game-Changer

Here's where things get really interesting. While PHP 8.5's runtime warnings are useful, the real power of #[\NoDiscard] comes when you pair it with static analysis tools. And honestly, this for me is one of the most interesting parts of this new feature.

PHPStan Already Supports It

PHPStan has already added support for #[\NoDiscard] in the latest version. This means you can catch these bugs before your code even runs:

php
1<?php
2 
3#[\NoDiscard]
4public function validateData(array $data): ValidationResult
5{
6 // ... validation logic
7 return new ValidationResult($errors);
8}
9 
10// Running PHPStan catches this at analysis time:
11$service->validateData($input);
12// PHPStan error: Call to method validateData() on a separate line
13// has its result unused.

The beautiful thing? PHPStan catches this during CI/CD, not production. No runtime needed. No waiting for that specific code path to execute. It's caught before you even merge.

Rector Can Add It Automatically

Even better, Rector can automatically add #[\NoDiscard] attributes to your codebase based on patterns:

php
1<?php
2 
3use Rector\Config\RectorConfig;
4use Rector\Set\ValueObject\LevelSetList;
5 
6return static function (RectorConfig $rectorConfig): void {
7 $rectorConfig->rule(Jump24\Rector\AddNoDiscardToValidationMethods::class);
8};
9 
10// Our custom Rector rule
11namespace Jump24\Rector;
12 
13use PhpParser\Node;
14use PhpParser\Node\Stmt\ClassMethod;
15use PhpParser\Node\Attribute;
16use PhpParser\Node\Name;
17use Rector\Core\Rector\AbstractRector;
18use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
19 
20class AddNoDiscardToValidationMethods extends AbstractRector
21{
22 public function getRuleDefinition(): RuleDefinition
23 {
24 return new RuleDefinition(
25 'Add NoDiscard attribute to validation methods',
26 [
27 // ... examples
28 ]
29 );
30 }
31 
32 public function getNodeTypes(): array
33 {
34 return [ClassMethod::class];
35 }
36 
37 public function refactor(Node $node): ?Node
38 {
39 if (!str_starts_with($node->name->toString(), 'validate')) {
40 return null;
41 }
42 
43 // Check if already has NoDiscard
44 foreach ($node->attrGroups as $attrGroup) {
45 foreach ($attrGroup->attrs as $attr) {
46 if ($attr->name->toString() === 'NoDiscard') {
47 return null;
48 }
49 }
50 }
51 
52 // Add NoDiscard attribute
53 $node->attrGroups[] = new Node\AttributeGroup([
54 new Attribute(
55 new Name('NoDiscard'),
56 [new Node\Arg(new Node\Scalar\String_(
57 'Validation results must be checked'
58 ))]
59 )
60 ]);
61 
62 return $node;
63 }
64}

Run vendor/bin/rector process, and boom - all your validation methods now have #[\NoDiscard] Just look at the time you've saved by using rector and also the possible bugs you've caught by making sure you handle the validation results. Also if you want to find out more about rector dont forget to checkout our post.

Other Examples

So lets look at potential examples of using the new #[\NoDiscard] attribute in some code examples.

API Response Handlers

php
1<?php
2 
3namespace App\Services;
4 
5use Illuminate\Support\Facades\Http;
6 
7class ExternalApiClient
8{
9 #[\NoDiscard("API calls can fail - always check the response")]
10 public function fetchUserData(int $userId): ApiResponse
11 {
12 $response = Http::timeout(5)
13 ->get("https://api.example.com/users/{$userId}");
14 
15 return new ApiResponse(
16 success: $response->successful(),
17 data: $response->json(),
18 statusCode: $response->status()
19 );
20 }
21}
22 
23// Before #[\NoDiscard], this was legal and silent:
24$client->fetchUserData($user->id);
25// Now: Warning! You need to check if the API call succeeded!
26 
27// Correct usage:
28$response = $client->fetchUserData($user->id);
29if (!$response->success) {
30 Log::error('API call failed', ['user_id' => $user->id]);
31 throw new ExternalApiException('Failed to fetch user data');
32}

Validation Results

php
1<?php
2 
3namespace App\Services;
4 
5class DataValidator
6{
7 #[\NoDiscard("Validation results must be checked before proceeding")]
8 public function validateImportData(array $data): ValidationResult
9 {
10 $errors = [];
11 
12 foreach ($data as $row => $item) {
13 if (empty($item['email'])) {
14 $errors[$row][] = 'Email is required';
15 }
16 
17 if (!filter_var($item['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
18 $errors[$row][] = 'Invalid email format';
19 }
20 }
21 
22 return new ValidationResult(
23 valid: empty($errors),
24 errors: $errors
25 );
26 }
27}
28 
29// This now triggers a warning:
30$validator->validateImportData($csvData);
31 
32// Correct:
33$result = $validator->validateImportData($csvData);
34if (! $result->valid) {
35 return back()->withErrors($result->errors);
36}

Suppressing the Warning

Sometimes you genuinely want to discard a return value. Maybe you're calling a function for its side effects. PHP 8.5 gives you three ways to suppress the warning:

php
1<?php
2 
3// 1. Explicit void cast (recommended)
4(void)bulk_process($items);

The (void) cast is new in PHP 8.5 and is the recommended approach. It's explicit: "Yes, I know this returns a value, and I'm intentionally discarding it."

When to Use #[\NoDiscard]

Based on our experience so far, here's where #[\NoDiscard] makes sense:

Use it when:

  • Functions return error/success status that must be checked

  • Functions return validation results

  • Functions return resources that need handling (locks, connections)

  • Functions return mutated copies of immutable objects

  • Ignoring the return value could lead to silent data corruption

Don't use it when:

  • The function primarily works through side effects

  • The return value is genuinely optional (like returning $this for chaining)

  • You're just returning for convenience but don't require checking

The Reality Check

Right, let's be honest about the limitations, because we don't sugarcoat things at Jump24.

It's Runtime, Not Compile Time (Unless You Use Static Analysis)

#[\NoDiscard] triggers warnings at runtime by default. So you won't catch these issues until the code executes:

php
1<?php
2 
3#[\NoDiscard]
4function important(): bool
5{
6 return true;
7}
8 
9// This won't warn until you actually run it:
10if (false) {
11 important(); // Never executes, never warns
12}

But here's the thing - with PHPStan or Psalm in your CI pipeline, you'll catch these before they ever run. Static analysis turns this from a runtime check into a compile-time check. That's why we now consider static analysis non-negotiable for any serious PHP project.

It's a Warning, Not an Error

By default, #[\NoDiscard] emits a warning. Your application will continue running. Unless you're converting warnings to exceptions, these can still slip through:

php
1<?php
2 
3// Without error handler, this warns but continues:
4someImportantFunction();
5doSomethingElse(); // This still runs

Performance Overhead

There's a tiny performance overhead for checking whether return values are used. In our testing, it's negligible (microseconds). But if you're calling a #[\NoDiscard] function in a hot loop millions of times... benchmark it.

Not a Substitute for Good Design

#[\NoDiscard] doesn't fix badly designed APIs. If your function should throw exceptions on failure instead of returning false, #[\NoDiscard] won't help you:

php
1<?php
2 
3// This is still bad design:
4#[\NoDiscard]
5function saveUser(User $user): bool
6{
7 try {
8 $user->save();
9 return true;
10 } catch (\Exception $e) {
11 return false; // Where did the error go?
12 }
13}
14 
15// Better design: just throw the exception
16function saveUser(User $user): void
17{
18 $user->save(); // Let it throw
19}

The Bottom Line

After a few weeks of using #[\NoDiscard] in our PHP 8.5 development environment, combined with PHPStan's static analysis, we've found it works well. Real instances where a return value wasn't checked was actually important to check.

The combination of runtime warnings and static analysis is powerful. You get immediate feedback during development, automated checking in CI, and protection in production. It's belt and braces, and we like that.

Is it revolutionary? No. It's a small, focused feature that makes one specific class of bugs much easier to catch.

Will it change how you write PHP? Probably not drastically. But it will make your code more explicit about intent. When you see #[\NoDiscard], you know: "This return value matters. Check it."

And when you combine it with modern static analysis tools? You've just eliminated an entire category of bugs from your codebase. That's worth the upgrade alone.

Your Turn

Have you ever had a production bug caused by ignoring a return value? Are you using static analysis to catch these issues already? How do you currently handle "must check" return values in your codebase - comments, documentation, hoping for the best?

Drop us a line. We'd love to hear about the bugs this will prevent in your applications.

Looking to scale your development team?

Don’t let limited resources hold you back. Get in touch to discuss your needs and discover how our team augmentation services can supercharge your success.

Get in touch