One of the more disruptive Magento 2 failures on S3 remote storage is a PHP fatal that nobody threw on purpose: TypeError: stream_get_meta_data(): Argument #1 ($stream) must be of type resource, bool given. It surfaces in your logs as PHP Fatal error: Uncaught TypeError ... in .../module-aws-s3/Driver/AwsS3.php, and it does not fail gracefully. A web request dies mid-response, or worse, a long-running queue consumer crashes and your async operations stop draining. We'll cover why PHP 8 turns this into a fatal, how to confirm it's the destructor and not an S3 connectivity issue, and a two-part fix that addresses both the symptom and the cause.
If this error is impacting live revenue, our senior Magento team can help you debug and deploy a fix safely.
Contact us →
Why Magento 2 throws this error
The crash lives in core: MagentoAwsS3DriverAwsS3, specifically fileClose() and __destruct(). fileClose() calls stream_get_meta_data($resource)['uri'] on a handle that has already been closed elsewhere. A closed stream is false (a bool), and under PHP 8 passing a bool to a parameter typed resource throws a TypeError. That throw happens during object teardown — and a throw escaping a destructor becomes an unrecoverable fatal.
- PHP 8 promoted type mismatches: passing a closed stream to a function typed
resourcenow throwsTypeErrorinstead of warning and returningfalselike PHP 7 did. TypeErrorextendsError, notException. Both implementThrowable, but acatch (Exception $e)block silently misses everyErrorsubclass.- A destructor must never let a throwable escape — PHP can't propagate it normally, so it escalates to a fatal error that kills the process.
fileClose()had nois_resource()guard, and the in-memory$this->streamsregistry could still hold handles closed elsewhere, so the same stream got double-closed at teardown.
1. Confirm PHP 8 and locate the core driver
Start with the artifact, not assumptions. Verify the runtime and that the affected class is present.
php -v && ls -1 vendor/magento/module-aws-s3/Driver/AwsS3.php
You should see PHP 8.x and the path printed. If both check out, the bug is in core Magento's S3 driver — not in any custom module of yours.
2. Reproduce the fatal from the logs
Pull the actual failure so you can prove it's the destructor path and not a network error talking to your-bucket.
grep -R "stream_get_meta_data" var/log/*.log var/report/* 2>/dev/null | head
You should see an uncaught TypeError from stream_get_meta_data() inside module-aws-s3/Driver/AwsS3.php during destruction. If you instead see S3 auth or timeout errors, you're chasing a different problem.
If this error is impacting live revenue, our senior Magento team can help you debug and deploy a fix safely.
Contact us →
3. Widen the destructor catch to Throwable
The destructor only caught Exception, so the TypeError walked straight past it. Catch the common ancestor instead.
// MagentoAwsS3DriverAwsS3::__destruct()
} catch (Throwable $e) {
// throwing from a destructor causes a fatal error
$this->logger->critical($e);
}
Now any failure during teardown — TypeError included — is logged as critical rather than escalated to a fatal. A destructor should never throw, and catching Throwable enforces that.
4. Guard fileClose() with is_resource()
Catching the throwable stops the crash, but you still want to fix the cause. Don't read metadata off a non-resource, and prune stale handles before iterating.
// MagentoAwsS3DriverAwsS3::fileClose()
if (!is_resource($resource)) {
return false;
}
$resourcePath = stream_get_meta_data($resource)['uri'];
foreach ($this->streams as $path => $stream) {
if (!is_resource($stream)) {
unset($this->streams[$path]);
continue;
}
// ... existing match-and-write logic
}
Returning false matches the method's bool return type, and unsetting dead handles prevents the double-close at teardown. stream_get_meta_data() now only ever sees a real resource.
5. Ship it as a composer patch and verify
The change is in vendor/, so deliver it through cweagans/composer-patches and confirm it applies.
composer install 2>&1 | grep -i 'aws-s3|patch'
You should see the patch report as applied with no "Could not apply patch" errors. The fix survives every composer install across your environments.
6. Restart consumers and confirm no fatal
Bring the workers back and watch a real batch run.
bin/magento queue:consumers:list && bin/magento queue:consumers:start async.operations.all --max-messages=200
The consumer should process its batch and exit cleanly, with no "Uncaught TypeError" in the logs. We've seen this most often when remote storage is paired with high-throughput async consumers — the same teardown that's harmless on a single request becomes a process-killer when one worker handles thousands of streams.
When this points to a deeper problem
A single patch closes this hole, but the pattern behind it is worth auditing:
- The
catch (Exception)idiom is everywhere, and across a PHP 8 codebase it silently lets everyErrorsubclass through — a latent bug in any cleanup, shutdown, orfinally-style code. - Resource registries like
$this->streamstrack handles without an invariant that each is still open, so teardown can act on stale resources. - Destructors that perform real I/O do failure-prone work at the worst possible moment for a throw.
- Long-running consumers magnify the blast radius: one bad teardown takes down the whole worker, not one request.
Grep for catch (Exception and bare catch (Exception inside __destruct() and shutdown handlers, then move each to Throwable. Audit every stream_get_meta_data(), fclose(), fread(), and fwrite() for a missing is_resource() guard. Patching one driver clears the symptom; enforcing "destructors catch Throwable and never throw" is the durable fix.
You now have everything to stop the crash and keep your consumers draining. Apply the catch widening and the resource guard, ship them as a patch, and the fatal that used to kill requests and workers becomes a logged line you can ignore.