A streamed CSV import failed in production last month and the only thing in the log was fopen(): Failed to open stream. Permissions? Missing directory? A 403 from the bucket? The return value was false and the message was a sentence. PHP 8.6's stream error API replaces that sentence with objects you can branch on.
Everything below targets PHP 8.6, which is in release candidate at the time of writing with GA scheduled for 19 November 2026. The API shape is locked — the RFC was marked implemented in August — but check UPGRADING on the final release before you ship.
Reproduce a stream failure the old way#
Start by proving the problem exists, because the old pattern looks fine until you need to branch on it. The only way to get a reason out of a failed stream call before 8.6 is to suppress the warning and read the last error out of PHP's global error state, then parse English.
$handle = @fopen('/var/imports/orders.csv', 'rb');
if ($handle === false) {
$last = error_get_last();
// The only structure here is a string. "Failed to open stream:
// No such file or directory" vs "Permission denied" vs
// "HTTP request failed! HTTP/1.1 403 Forbidden" — three wrappers,
// three formats, no shared shape.
report(new RuntimeException($last['message'] ?? 'unknown stream failure'));
}
Two things are wrong. error_get_last() is global, so anything between the fopen() and the read can overwrite it. And the message format is per-wrapper, so a retry helper written against the local filesystem falls apart the moment someone passes an https:// path. If you have ever introspected the handler stack to work out who swallowed a warning — I covered that in PHP 8.5's error handler introspection functions — you already know how little the global state tells you.
Read the failure with stream_last_errors()#
The first new function replaces error_get_last() for streams. stream_last_errors() returns an array of StreamError objects from the most recent stored operation, ordered with the primary error first. It only returns something if the stream was opened with a context that stores errors, which is the part every release round-up skips.
$context = stream_context_create([
'stream' => [
'error_mode' => StreamErrorMode::Silent,
'error_store' => StreamErrorStore::All,
],
]);
$handle = fopen('/var/imports/orders.csv', 'rb', false, $context);
if ($handle === false) {
// Primary error first; array_first() landed in PHP 8.5.
$error = array_first(stream_last_errors());
logger()->error('import stream failed', [
'code' => $error->code->name, // StreamErrorCode enum case
'message' => $error->message,
'wrapper' => $error->wrapperName, // 'plainfile', 'http', 's3', ...
'param' => $error->param, // usually the path or URL
'terminating' => $error->terminating,
]);
}
StreamError is a final readonly class with six properties: code, message, wrapperName, severity, terminating and param. Nothing to parse. array_first() and the array_any() / array_find() helpers used later come from PHP 8.5's array_first() and array_last() and PHP 8.4's array_find(), array_any() and array_all() — the RFC leans on them throughout.
There is also stream_clear_errors(), but you rarely need it. Each operation that produces storable errors replaces the previous result, so stream_last_errors() never leaks state from an earlier call.
Switch one stream to exception mode#
Exception mode is the version most people want, and it is configured on the context rather than globally. Set error_mode to StreamErrorMode::Exception and any terminating error — one that prevents the operation completing — throws a StreamException instead of emitting a warning.
$context = stream_context_create([
'stream' => ['error_mode' => StreamErrorMode::Exception],
]);
try {
$handle = fopen('https://example.test/orders.csv', 'rb', false, $context);
} catch (StreamException $e) {
// getErrors() returns StreamError[] — the full group, not just one.
foreach ($e->getErrors() as $error) {
echo "{$error->code->name}: {$error->message}\n";
}
}
The scoping is deliberate and it is the single most important detail here. You cannot set error_mode, error_store or error_handler on the default context — stream_context_set_default() throws a ValueError if you try. That restriction exists so a package that relies on stream warnings does not break because your application flipped a global switch. Exception mode is a per-stream decision, forever.
Non-terminating errors, such as buffered data being lost, never throw. They are still stored when error_store allows it, so a successful-but-degraded read is something you can still detect after the fact.
Map StreamErrorCode to a retry decision#
StreamErrorCode has over seventy cases, and the useful thing about them is that they split cleanly into "try again" and "fix your code". That is the split a match expression should encode, not a substring search on a message.
function shouldRetryStreamFailure(StreamException $e): bool
{
$error = array_first($e->getErrors());
return match ($error->code) {
// Transient: the network or the far end moved.
StreamErrorCode::ConnectFailed,
StreamErrorCode::NetworkSendFailed,
StreamErrorCode::NetworkRecvFailed,
StreamErrorCode::ResumptionFailed,
StreamErrorCode::LockFailed => true,
// Permanent: retrying just burns queue workers.
StreamErrorCode::NotFound,
StreamErrorCode::PermissionDenied,
StreamErrorCode::AuthFailed,
StreamErrorCode::InvalidPath,
StreamErrorCode::Readonly,
StreamErrorCode::NotWritable,
StreamErrorCode::WrapperNotFound => false,
default => false,
};
}
I default to false. An unknown code is more often a bug in my path handling than a flaky socket, and a job that fails loudly on the first attempt is easier to diagnose than one that retries eight times and then fails anyway. When a code does turn out to be retryable, add it to the list explicitly.
Because errors arrive as a group, you can also ask whether any error in the operation matched something:
if (array_any($e->getErrors(), fn ($err) => $err->code === StreamErrorCode::RedirectLimit)) {
// The URL is a redirect loop. No amount of retrying fixes it.
}
Wrap the Laravel Storage boundary#
Here is the honest state of play: Storage::readStream() does not throw StreamException. Flysystem's local adapter still calls @fopen() with no stream context, and the S3 adapter goes through the AWS SDK rather than a PHP stream wrapper at all. Until Flysystem opts in, you get UnableToReadFile, same as before.
Where the new API pays off today is your own stream calls — the ones in import jobs, export writers and Http::sink() targets. Wrap them once and convert to a domain exception with context attached, the pattern I use for custom exceptions with render(), report() and context().
namespace App\Exceptions;
final class StreamUnavailable extends \RuntimeException
{
public function __construct(
public readonly string $path,
public readonly \StreamError $streamError,
\StreamException $previous,
) {
parent::__construct(
"Could not open [{$path}]: {$streamError->code->name}",
previous: $previous,
);
}
/** Attached to every log line and Sentry event for this exception. */
public function context(): array
{
return [
'path' => $this->path,
'code' => $this->streamError->code->name,
'wrapper' => $this->streamError->wrapperName,
'retry' => shouldRetryStreamFailure($this->getPrevious()),
];
}
}
And the reader itself:
namespace App\Support;
use App\Exceptions\StreamUnavailable;
final class SafeStream
{
/** @return resource */
public static function open(string $path, string $mode = 'rb')
{
$context = stream_context_create([
'stream' => ['error_mode' => \StreamErrorMode::Exception],
]);
try {
return fopen($path, $mode, false, $context);
} catch (\StreamException $e) {
throw new StreamUnavailable($path, array_first($e->getErrors()), $e);
}
}
}
In a queued import, that turns "Undefined array key 3" four frames deep into a failed job whose first log line names the file and the reason. Pair it with request-scoped logging context in jobs and the batch ID travels with the failure.
For paths you reach through a disk, call Storage::disk('local')->path($relative) and hand the absolute path to SafeStream::open() rather than using readStream(). The Laravel file storage and uploads guide covers the disk configuration side of that.
Test both failure paths with Pest#
Two tests cover the branches that matter: a path that does not exist, and a path you are not allowed to read. Both are cheap to arrange with real files, so there is nothing to mock.
use App\Exceptions\StreamUnavailable;
use App\Support\SafeStream;
it('reports a missing file as NotFound', function () {
try {
SafeStream::open('/tmp/definitely-not-here-'.uniqid().'.csv');
$this->fail('Expected StreamUnavailable.');
} catch (StreamUnavailable $e) {
expect($e->streamError->code)->toBe(StreamErrorCode::NotFound)
->and($e->streamError->wrapperName)->toBe('plainfile');
}
});
it('reports an unreadable file as PermissionDenied', function () {
$path = tempnam(sys_get_temp_dir(), 'perm');
chmod($path, 0o000);
try {
SafeStream::open($path);
$this->fail('Expected StreamUnavailable.');
} catch (StreamUnavailable $e) {
expect($e->streamError->code)->toBe(StreamErrorCode::PermissionDenied);
} finally {
chmod($path, 0o600);
unlink($path);
}
})->skip(fn () => posix_geteuid() === 0, 'root bypasses file permissions');
That skip() is not optional. CI containers routinely run as root, and root reads a 0000 file happily, so without the guard the second test passes locally and fails in the pipeline for reasons that have nothing to do with your code.
Gotchas and Edge Cases#
@ does not suppress the exception. Suppression affects diagnostics, not control flow. In exception mode the StreamException is thrown whether or not you wrote @fopen(), and an un-caught throw in code that used to just continue is the one real behaviour change to look for when you opt a stream in.
No context means no stored errors. The default error_store is StreamErrorStore::Auto, and under the default Error mode Auto stores nothing. Call stream_last_errors() after a context-less fopen() and you get an empty array — not an error, just silence. Since you also cannot set the option on the default context, every stream you want errors from needs its own context explicitly.
Pass the context to stream_copy_to_stream() too. It gained a fifth $context parameter in 8.6, alongside stream_select() (sixth), stream_socket_pair() (fourth) and stream_is_local() (second). A copy between two exception-mode handles still reports its own failures according to the context you pass to the copy call, so omit it and a mid-copy write failure falls back to a warning.
Grouped errors are not noise. A single failed stream_select() on a userspace stream can produce two terminating errors — NotImplemented for the missing stream_cast(), then CastNotSupported. Log the whole array. The first element tells you what failed; the rest tell you why.
error_get_last() is not deprecated. The RFC is purely additive and the default StreamErrorMode::Error preserves existing warning behaviour, so nothing in your current code breaks. The only noted side effects are some previously-wrong errors being corrected and error reporting landing slightly closer to the function return, which can reorder warnings when stream and non-stream errors interleave.
Wrapping Up#
Add a SafeStream-style wrapper, point your import and export jobs at it, and delete the @ and error_get_last() pair as you go. Leave Storage::readStream() alone until Flysystem passes a context — wrapping it today gains you nothing.
If you are planning the move, start with the Laravel on PHP 8.6 upgrade checklist, and PHP 8.6's clamp() function is the gentler end of the same release.
FAQ#
How do I get the reason fopen() failed in PHP?
In PHP 8.6, open the stream with a context whose stream.error_mode is StreamErrorMode::Exception and catch StreamException, or use StreamErrorMode::Silent with error_store set and read stream_last_errors() after the call. Both give you StreamError objects carrying a StreamErrorCode enum case, the wrapper name and the path. Before 8.6 the only option was @fopen() followed by error_get_last() and parsing the message string, which varies by wrapper.
What is StreamException in PHP 8.6?
StreamException extends Exception and is thrown by stream operations when the stream's context sets error_mode to StreamErrorMode::Exception. It adds one method, getErrors(), which returns an array of StreamError objects — the complete group of errors from the operation, with the primary one first. Only terminating errors throw; non-terminating ones are stored instead.
What does stream_last_errors() return?
It returns an array of StreamError objects from the most recent stream operation that produced storable errors, ordered most significant first, or an empty array if nothing was stored. Each object exposes code, message, wrapperName, severity, terminating and param. Errors are only stored when the stream's context asks for it, so a context-less call returns an empty array even after a failure.
How do I make PHP stream functions throw exceptions instead of warnings?
Create a context with stream_context_create(['stream' => ['error_mode' => StreamErrorMode::Exception]]) and pass it as the context argument to fopen(), file_get_contents() and the stream functions that accept one. You cannot do this globally — stream_context_set_default() throws a ValueError for the error handling options, deliberately, so that libraries relying on warnings keep working.
Does Laravel's Storage facade use the new PHP stream errors?
Not yet. Flysystem's local adapter calls fopen() without a stream context and the S3 adapter uses the AWS SDK rather than a stream wrapper, so Storage::readStream() still surfaces UnableToReadFile rather than StreamException. The benefit today is in your own fopen(), stream_copy_to_stream() and sink calls, which you can wrap at the application boundary.
Is error_get_last() deprecated in PHP 8.6?
No. The stream errors RFC is additive and the default error mode keeps emitting the same warnings and notices, so error_get_last() behaves as it always has. It is simply no longer the best tool for stream failures, because the new API gives you structured errors scoped to a single stream instead of global state anything can overwrite.