Errors and resources
Streams, Files and Closing Resources
fopen returns false rather than throwing, streams sit under every file call, and __destruct is not guaranteed. Read a large file without loading it whole.
An import script works in development and dies on the client's machine with nothing
in the log except an empty response. The file it reads is 22 MB, and the box has a
64 MB memory limit, so the read itself exhausted memory before any parsing happened.
Nothing threw. There was no exception to catch, because file_get_contents()
returned a 22 MB string and PHP ran out of room building it.
The second half of that story is quieter. The function that opened the file never
called fclose(), on the assumption that the handle would be cleaned up when
the object holding it went away. That is true until a fatal error or a reference cycle
gets in the way, and then it is not true at all. File handles are process resources, not
garbage that PHP will always collect for you.
Both problems come from treating files as a special case. They are not: PHP routes files, network sockets, compression streams and in-memory buffers through one abstraction, and once you see the abstraction the right patterns are obvious. Examples run on PHP 8.5.6 against a 23,047,666-byte CSV of 400,001 lines.
fopen returns false, and the warning is not an exception
Every function in the file family reports failure the same way: it returns
false and emits a warning. Nothing throws. This is the oldest part of PHP
and it predates exceptions by years, which is why the two mechanisms coexist.
<?php
declare(strict_types=1);
echo "1. nonexistent file: fopen returns false and warns\n";
$handle = fopen('/path/to/nope.csv', 'r');
var_dump($handle);
echo "\n2. mode x on an existing path: also false, not an exception\n";
var_dump(@fopen('/path/to/orders.csv', 'x'));
echo "\n3. mode r on the same path: a stream resource\n";
$read = fopen('/path/to/orders.csv', 'r');
var_dump($read);
var_dump(stream_get_meta_data($read)['mode']);
var_dump(stream_get_meta_data($read)['seekable']);
fclose($read);
1. nonexistent file: fopen returns false and warns
PHP Warning: fopen(/path/to/nope.csv): Failed to open stream: No such file or directory in /path/to/openmode2.php on line 5
Warning: fopen(/path/to/nope.csv): Failed to open stream: No such file or directory in /path/to/openmode2.php on line 5
bool(false)
2. mode x on an existing path: also false, not an exception
bool(false)
3. mode r on the same path: a stream resource
resource(5) of type (stream)
string(1) "r"
bool(true)
The warning in the first case fires twice: once to the error log with the
PHP Warning: prefix, once to standard output because
display_errors is on. The second mode, 'x', means create and
fail if it exists, which is how you avoid two processes writing the same file; it
returned false with no exception. The third case shows the success return
value, a stream resource, and asks the stream about itself.
stream_get_meta_data() is the general introspection call and it works on
every stream, not just files. The mode key confirms what was negotiated
rather than what you asked for, and seekable tells you whether
fseek() and rewind() will work. A socket is not seekable; a
file is. Code that rewinds a stream without checking is code that works in tests
against a file and fails in production against a response body.
The modes themselves are a small set with sharp edges. Three of them matter most:
| Mode | Opens when it exists | Opens when it does not | Use it for |
|---|---|---|---|
r |
yes, read from the start | no, returns false | Reading an input you expect to be there. |
w |
yes, truncated to zero bytes | yes, created | Rewriting a file completely. It does not ask before destroying the contents. |
a |
yes, position at end | yes, created | Appending log lines. Every write goes to the end regardless of other writers. |
x |
no, returns false | yes, created | A lock file, a temp output, anywhere a second process must not clobber the first. |
c |
yes, truncated to zero bytes | yes, created | Rewriting in place while holding an exclusive lock with flock(). |
Why c exists at all comes down to the difference between it and w:
exists. With w the file is emptied the moment it opens, before you have
taken a lock, so a crash between open and write leaves a zero-byte file. With
c nothing is written at open time, so you can take a lock and only then
truncate. The difference between a and r+ is subtler and only
matters when two processes append; a is the one you want for logs.
A warning that reached the caller
Any file function can also reach a set_error_handler you installed
earlier in the request, which may turn the warning into an exception. So
fopen both returns false and throws, depending on what else
is registered in that process. Check the return value as well as catching.
Reading line by line costs one line of memory
The measurement that settles this argument is cheap to run. Three ways to read the same file, each in its own process so the peak is not contaminated by the previous one.
<?php
declare(strict_types=1);
$path = '/path/to/big.csv';
$base = memory_get_usage();
$m = $argv[1];
if ($m === 'fgets') {
$handle = fopen($path, 'r');
$n = 0;
while (($line = fgets($handle)) !== false) {
$n++;
}
fclose($handle);
} elseif ($m === 'file_get_contents') {
$all = file_get_contents($path);
$n = substr_count($all, "\n");
} else {
$lines = file($path);
$n = count($lines);
}
printf("%-18s rows=%d current=%+.2f MB peak=%.2f MB\n", $m, $n,
(memory_get_usage() - $base) / 1048576, memory_get_peak_usage() / 1048576);
$ php one.php fgets
fgets rows=400001 current=+0.00 MB peak=0.48 MB
$ php one.php file_get_contents
file_get_contents rows=400001 current=+21.98 MB peak=22.45 MB
$ php one.php file
file rows=400001 current=+44.61 MB peak=67.05 MB
The file is 22 MB on disk. The loop holds one line at a time, so its peak is 0.48 MB
and its current usage at the end is unchanged. file_get_contents() holds the
whole document as one string, which costs 21.98 MB. file() is the worst of
the three: it allocates a string for every one of the 400,001 lines, and the per-element
overhead is why 22 MB of text becomes 67 MB of peak memory.
Those numbers are specific to this machine and this file, but the shape generalises:
file() costs several times the file size, file_get_contents()
costs about the file size, and a line loop costs about one line. On a request with a
64 MB memory limit, the difference between the second and first is the difference between
a working import and a fatal error.
The loop is not free. It makes one function call per line and it cannot be parallelised inside one process, which matters when the work per line is expensive. The right split is to stream the lines and batch the expensive work:
<?php
declare(strict_types=1);
$handle = fopen('/path/to/big.csv', 'r');
fgetcsv($handle, null, ',', '"', ''); // discard the header row
$batch = [];
while (($row = fgetcsv($handle, null, ',', '"', '')) !== false) {
$batch[] = ['sku' => $row[1], 'qty' => (int) $row[2]];
if (count($batch) >= 500) {
importBatch($batch);
$batch = [];
}
}
if ($batch !== []) {
importBatch($batch);
}
fclose($handle);
fgetcsv() parses quoted fields, escaped delimiters and embedded newlines
for you, and it is the reason a CSV reader should not be built from
explode(). It returns false at end of file and also on error,
which is a distinction PHP does not make for you. The batching keeps peak memory bounded
by the batch size rather than the file size while still amortising whatever
importBatch() does.
Those four explicit arguments deserve a note. The escape parameter has a
default of "\\", and relying on that default is deprecated as of PHP 8.4:
the manual recommends passing the empty string, which disables the escape mechanism, and
says the default will change in a future version no earlier than PHP 9.0. The default
escape handling is not RFC 4180 CSV and can round-trip badly, so passing
'' is both the forward-compatible choice and the more correct one. Omit it
and PHP 8.5 prints a deprecation for every call:
Deprecated: fgetcsv(): the $escape parameter must be provided as its default value will change in /path/to/summarise.php on line 21
SplFileObject is the object-oriented face of the same stream
For files specifically, the standard library offers an iterator that reads like an
array without holding one. It is a FileObject, so you also get
fgets(), fwrite(), ftell() and
fseek() as methods, plus flags that change what iteration yields.
<?php
declare(strict_types=1);
$file = new SplFileObject('/path/to/orders.csv', 'r');
$file->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE);
foreach ($file as $line => $row) {
printf("line %d: %s\n", $line, var_export($row, true));
}
line 0: array (
0 => 'id',
1 => 'sku',
2 => 'qty',
)
line 1: array (
0 => '1',
1 => 'ABC',
2 => '2',
)
line 2: false
That last entry is not a formatting mistake. With READ_CSV set, the
iterator reaches the end of a file whose last character is a newline and yields
false for a final iteration. Any code indexing $row[1] inside
the loop without checking will emit Trying to access array offset on false
on the last pass, which is exactly the kind of warning that gets silenced instead of
fixed. The working loop checks for both shapes:
foreach ($file as $row) {
// SplFileObject yields false once the CSV reader hits EOF.
if ($row === false || $row === [null]) {
continue;
}
if ($header === null) {
$header = $row;
continue;
}
printf("%s: %s units (order %s)\n", $row[1], $row[2], $row[0]);
}
ABC: 2 units (order 1)
The [null] case is a genuinely empty line, which the CSV reader reports as
an array containing one null. SKIP_EMPTY handles most of those, but the
reader still produces one at the end of some files, which is why the check stays. Reach
for SplFileObject when you want the flags, the CSV parsing and the file
metadata in one object; reach for fopen() and fgets() when you
want code whose correctness is visible at a glance, which for an import script is often
the better trade.
php://memory and php://temp are streams with no file
Both wrappers give you a readable, writable, seekable stream with no path on disk. They exist so that code which takes a stream can be tested and reused without writing temporary files, and so that you can assemble output and decide later whether it becomes a response body or a download.
<?php
declare(strict_types=1);
$memory = fopen('php://memory', 'r+');
fwrite($memory, "line one\nline two\n");
rewind($memory);
while (($line = fgets($memory)) !== false) {
echo 'memory: ', $line;
}
printf("memory stream size: %d bytes\n", fstat($memory)['size']);
fclose($memory);
$temp = fopen('php://temp/maxmemory:1024', 'r+');
fwrite($temp, str_repeat('a', 10));
printf("small: uri=%s size=%d\n", stream_get_meta_data($temp)['uri'], fstat($temp)['size']);
fwrite($temp, str_repeat('b', 5000));
printf("large: uri=%s size=%d\n", stream_get_meta_data($temp)['uri'], fstat($temp)['size']);
fclose($temp);
memory: line one
memory: line two
memory stream size: 18 bytes
small: uri=php://temp/maxmemory:1024 size=10
large: uri=php://temp/maxmemory:1024 size=5010
php://memory stays in RAM for the life of the stream and has no limit you
can set, so writing a gigabyte to it exhausts memory. php://temp keeps data
in memory until it exceeds the byte count in the wrapper name and then spills to a real
temporary file, deleting it when the stream closes. In the run above the 5,010 bytes
crossed the 1,024-byte threshold and the data moved to disk, yet the
uri still reports the wrapper rather than the temporary path. That is the
one piece of information you lose, and it matters if you were planning to hand the path
to another process.
Use php://memory for small buffers you want to guarantee stay in RAM and
php://temp for anything whose size you do not control. Both accept a mode
like a file, both are seekable, and both can be passed to any function that takes a
stream, which is the point: fgetcsv(), json_decode() reading
from a stream and your own parsers do not need to know whether the bytes came from disk,
a socket or memory.
fclose releases the descriptor, and __destruct is not a promise
Closing is explicit in this codebase for one reason: the descriptor is a finite process resource, and the limit is low enough to hit in a long-running worker. The finished-check is also worth knowing, because a closed stream is not a resource any more.
<?php
declare(strict_types=1);
$h = fopen('/path/to/orders.csv', 'r');
var_dump(is_resource($h));
fclose($h);
var_dump(is_resource($h));
var_dump(gettype($h));
var_dump(fclose($h));
bool(true)
bool(false)
string(17) "resource (closed)"
PHP Fatal error: Uncaught TypeError: fclose(): Argument #1 ($stream) must be an open stream resource in /path/to/doubleclose.php:9
Stack trace:
#0 /path/to/doubleclose.php(9): fclose(Resource id #5)
#1 {main}
thrown in /path/to/doubleclose.php on line 9
After fclose() the variable's type becomes
resource (closed) and is_resource() is false, which
is the guard to use. Calling fclose() a second time is a
TypeError in PHP 8, not a warning, so a cleanup path that can run twice
needs the check. is_resource($h) before every close, or a nullable property
set to null after closing, both work.
What about relying on the destructor instead? Here is an object that opens lazily and
closes in __destruct(), run in two situations.
<?php
declare(strict_types=1);
final class LazyWriter
{
/** @var resource|null */
private $handle = null;
public function __construct(
private readonly string $path,
public ?LazyWriter $peer = null,
) {
}
public function write(string $line): void
{
$this->handle ??= fopen($this->path, 'a');
fwrite($this->handle, $line . "\n");
}
public function __destruct()
{
fwrite(STDERR, " __destruct called\n");
if (is_resource($this->handle)) {
fclose($this->handle);
}
}
}
echo "1. no cycle: the destructor runs as soon as the scope ends\n";
(function (): void {
$w = new LazyWriter('/path/to/lazy.log');
$w->write('first');
echo " leaving scope\n";
})();
echo " file contents: ", trim((string) file_get_contents('/path/to/lazy.log')), PHP_EOL;
echo "\n2. reference cycle: the destructor waits for the collector\n";
(function (): void {
$a = new LazyWriter('/path/to/lazy.log');
$b = new LazyWriter('/path/to/lazy.log');
$a->peer = $b;
$b->peer = $a;
$a->write('second');
echo " leaving scope with a cycle\n";
})();
echo " cycles collected now: ", gc_collect_cycles(), PHP_EOL;
1. no cycle: the destructor runs as soon as the scope ends
leaving scope
__destruct called
file contents: first
2. reference cycle: the destructor waits for the collector
leaving scope with a cycle
cycles collected now: __destruct called
__destruct called
2
Two objects, two outcomes. Without a cycle the destructor runs at the end of the function, immediately, and the file is complete before the next statement. With a cycle both objects survive the scope exit and are only collected when the cycle collector runs, which in a request is at shutdown. Every destructor in that graph is deferred by however long the request lasts.
Now the case that breaks the pattern. The same object, writing a batch, then a fatal error from memory exhaustion:
<?php
declare(strict_types=1);
final class BatchWriter
{
/** @var resource */
private $handle;
public function __construct(private readonly string $path)
{
$this->handle = fopen($this->path, 'w');
}
public function write(string $line): void
{
fwrite($this->handle, $line . "\n");
}
public function __destruct()
{
fwrite(STDERR, " __destruct ran\n");
if (is_resource($this->handle)) {
fclose($this->handle);
}
}
}
register_shutdown_function(static function (): void {
fwrite(STDERR, " shutdown function ran\n");
});
$writer = new BatchWriter('/path/to/oom.log');
$writer->write('row 1');
$writer->write('row 2');
$hog = [];
while (true) {
$hog[] = str_repeat('x', 100_000);
}
Fatal error: Allowed memory size of 33554432 bytes exhausted (tried to allocate 102400 bytes) in /path/to/oom.php on line 38
Stack trace:
#0 /path/to/oom.php(38): str_repeat('x', 100000)
#1 {main}
shutdown function ran
The shutdown function ran. The destructor did not, and nothing printed
__destruct ran. When the engine cannot allocate memory it stops unwinding
objects, so any cleanup that only exists in __destruct() is skipped at
exactly the moment a partial file is most likely.
Here is the guarantee without the optimism: __destruct() runs when the last
reference to an object is dropped and the engine is healthy. It does not run on a
memory-exhaustion fatal, it is deferred by reference cycles, and it does not run at all
if the process is killed. For a file handle that means the operating system still closes
the descriptor when the process dies, so the bytes you wrote are usually on disk anyway;
what you lose is the deterministic release and the chance to flush, rename a temporary
file into place or mark a job finished. Anything that must happen goes in a
finally block or an explicit close() call, and the destructor
is a backstop for the paths you missed.
Wrap the old API once instead of checking false everywhere
Given how much of the standard library reports failure by return value, the useful
move is to convert it at the boundary. One helper that opens or throws means every
caller can use try and none of them repeat the
=== false check.
<?php
declare(strict_types=1);
final class FileAccessFailed extends RuntimeException
{
public static function forPath(string $path, string $operation, ?Throwable $previous = null): self
{
return new self("{$operation} failed for {$path}", previous: $previous);
}
}
/** @return resource */
function openFile(string $path, string $mode): mixed
{
set_error_handler(static function (int $severity, string $message, string $file, int $line): never {
throw new ErrorException($message, 0, $severity, $file, $line);
});
try {
$handle = fopen($path, $mode);
} catch (ErrorException $e) {
throw FileAccessFailed::forPath($path, 'fopen', $e);
} finally {
restore_error_handler();
}
if ($handle === false) {
throw FileAccessFailed::forPath($path, 'fopen');
}
return $handle;
}
1. good path
opened, first line: id,sku,qty
2. missing path
caught FileAccessFailed: fopen failed for /path/to/nope.csv
previous: fopen(/path/to/nope.csv): Failed to open stream: No such file or directory
3. calling code still sees no warning on stdout
The handler is installed inside the function and removed in
finally, so the rest of the process is unaffected. Both
catch (ErrorException) and the === false check are present,
because both can happen: with the handler installed the warning becomes an exception, and
if a different handler were restored first the same call would return false. The
previous argument keeps the original warning text, so the log still contains
the operating system's explanation rather than only your sentence.
Two limits. Wrapping only fopen leaves fwrite,
fread and flock reporting through return values, so a complete
bridge covers every call your code makes. And a throwing wrapper hides the difference
between "the file is missing" and "the directory is not writable", which callers
sometimes need to distinguish; if they do, throw different classes rather than one.
Write a function tailLines(string $path, int $n): array that returns
the last $n lines of a file without loading the whole file into memory.
It must throw a domain exception when the file cannot be opened, and it must close
every handle it opens.
Then write a second function, summarise(string $path): array, that
streams the same file with fgetcsv() and returns the row count and the
sum of the third column, skipping malformed rows. Measure its peak memory against
file() on a file of at least 50,000 lines using
memory_get_peak_usage() in a separate process for each version.
Answer: how do you know your tailLines never reads the whole file, and
what happens to your peak memory measurement if you call file() first and
then measure the streaming version in the same process?
Show the solution and the reasoning
The last-N-lines problem has an obvious wrong answer and two right ones. The wrong
answer is array_slice(file($path), -$n), which reads everything to keep
the end. The simple right answer reads the file once and keeps a sliding window, using
memory proportional to $n rather than to the file:
<?php
declare(strict_types=1);
final class FileAccessFailed extends RuntimeException
{
}
function tailLines(string $path, int $n): array
{
if ($n < 1) {
throw new InvalidArgumentException('tailLines() needs a positive line count');
}
set_error_handler(static function (int $severity, string $message, string $file, int $line): never {
throw new ErrorException($message, 0, $severity, $file, $line);
});
try {
$handle = fopen($path, 'r');
} catch (ErrorException $e) {
throw new FileAccessFailed("Cannot open {$path}", previous: $e);
} finally {
restore_error_handler();
}
if ($handle === false) {
throw new FileAccessFailed("Cannot open {$path}");
}
try {
$window = [];
while (($line = fgets($handle)) !== false) {
$window[] = $line;
if (count($window) > $n) {
array_shift($window);
}
}
return $window;
} finally {
fclose($handle);
}
}
The proof that it never reads the whole file is the shape of the loop, not the
measurement: $window is trimmed on every iteration, so its maximum length
is $n. You can state an upper bound on memory before running anything,
which is stronger evidence than a single peak reading. The finally around
the read is what makes the handle close even when fgets() throws, and the
array_shift() is O(n) per call but n is the size of the window, which you
chose.
The streaming summary is the same pattern with a parser instead of a line reader:
function summarise(string $path): array
{
$handle = fopen($path, 'r');
if ($handle === false) {
throw new FileAccessFailed("Cannot open {$path}");
}
try {
fgetcsv($handle, null, ',', '"', ''); // header
$rows = 0;
$total = 0;
while (($row = fgetcsv($handle, null, ',', '"', '')) !== false) {
if (count($row) < 3 || !is_numeric($row[2])) {
continue;
}
$rows++;
$total += (int) $row[2];
}
return ['rows' => $rows, 'total' => $total];
} finally {
fclose($handle);
}
}
The second question is the one people get wrong. memory_get_peak_usage()
reports the high-water mark for the whole process, and it never goes down. If you call
file() first, that peak is already recorded, and the streaming
measurement afterwards reports the earlier maximum no matter how little the streaming
loop uses. The measurement is only meaningful in a fresh process, one process per
strategy, which is why the example in the lesson is written as three separate runs with
an argument rather than one script that does all three.
An alternative for tailLines is to seek backwards from the end and read
blocks until you have counted $n newlines, which is faster on a large file
and much harder to get right with a variable-width encoding or a CRLF file. The
sliding window is the version I would ship unless the file is large enough that reading
it once is the bottleneck, and then I would want tests for both line endings before
switching.
Progress
Mark this lesson done and it stays marked in this browser.