Skip to content
System nominalUTC --:--:--Articles 041
MODULE 04 Lesson 12 / 15 30 MIN

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:

ModeOpens when it existsOpens when it does notUse 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.

Lab 25 minutes

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.