Files and Streams: I/O, Locking, and php:// Wrappers

Work with PHP's file and stream I/O functions correctly: open and close file handles with fopen/fclose, read and write with fread/fwrite/fgets/fputcsv, use the higher-level file_get_contents/file_put_contents, lock files safely with flock, manage permissions and temporary files, and use the php:// stream wrappers (input, output, memory, temp) along with stream contexts and filters.

Almost every non-trivial PHP script eventually reads or writes something outside its own memory — a config file, a CSV export, a log line, the raw request body — and PHP's I/O layer is built around a single unifying abstraction called a stream. A stream is anything you can read from or write to sequentially through a common set of functions: a real file on disk, standard input/output, a network socket, or a purely in-memory buffer are all streams, addressed by a path-like string (a real path, or a php:// wrapper URI) and manipulated with the same handful of functions regardless of what's actually on the other end.

Opening, closing, reading, and writing with a handle

fopen($path, $mode) opens a file (or any stream) and returns a resource handle used by every subsequent operation on it; the mode string controls both direction and behavior — 'r' (read only, pointer at start, fails if the file doesn't exist), 'w' (write only, truncates existing content or creates the file), 'a' (write only, appends, pointer always at end regardless of seeks), 'r+' (read and write, pointer at start), and 'x' (write only, but fails if the file already exists — useful for atomically ensuring you're creating a new file, not overwriting one). Every fopen() should be paired with fclose($handle) once done, which flushes any buffered writes to disk and releases the underlying file descriptor.

fread($handle, $length) reads up to $length bytes from the current position and advances the pointer; fwrite($handle, $string) writes $string at the current position (returning bytes actually written, worth checking against strlen($string) for a full-disk scenario); fgets($handle) reads a single line at a time (up to and including the next newline, or EOF), which is the natural building block for line-oriented formats like log files or CSVs read manually. fputcsv($handle, $fields) is a dedicated helper that writes one array of values as a single, properly-escaped CSV line (handling quoting of fields containing commas, quotes, or newlines per RFC 4180) directly to an open file handle — far less error-prone than hand-building CSV strings with implode(',').

The convenience functions: file_get_contents and file_put_contents

For the common case of reading or writing an entire file in one shot, file_get_contents($path) and file_put_contents($path, $data) skip the fopen/fclose ceremony entirely — one call reads the whole file into a string, one call writes a whole string to a file (creating it if needed, truncating by default). Both also transparently work with URLs when the allow_url_fopen ini setting permits it, and both accept an optional stream context (covered below) for finer control such as HTTP headers on a remote fetch.

file_put_contents() accepts a FILE_APPEND flag to append rather than truncate, and a LOCK_EX flag to acquire an exclusive lock for the duration of the write — for anything beyond a quick single-shot write where you don't need fine control over locking behavior across multiple operations, these convenience functions are usually the right choice over manual fopen/fwrite/fclose, which is better reserved for line-by-line processing, CSV writing, or cases needing an explicit lock held across several separate read/write calls.

• file_get_contents($path) -> whole file as a string, one call • file_put_contents($path, $data) -> whole string written, one call; add FILE_APPEND or LOCK_EX as flags • Prefer these over manual fopen/fwrite/fclose for simple whole-file operations • Prefer manual handles + fgets/fwrite/fputcsv for line-by-line or CSV work, or when a lock must span multiple operations

Locking, permissions, and temporary files

flock($handle, $operation) requests a lock on an open file handle: LOCK_SH (shared, for reading — multiple readers can hold it simultaneously) or LOCK_EX (exclusive, for writing — blocks until every other lock, shared or exclusive, is released), with LOCK_UN to release it explicitly (fclose() also releases it implicitly). Without flock, two concurrent PHP processes writing to the same file can interleave their writes and corrupt the content — a classic race condition in anything logging to a shared file or maintaining a shared counter file. Adding LOCK_NB (non-blocking) to either lock type makes flock() return immediately with false rather than waiting, useful when the caller would rather skip the operation than stall.

File permissions matter independently of PHP code correctness: a file or directory PHP writes to must be writable by the user the web server process runs as (often www-data), and chmod()/fileperms() let a script inspect or adjust permissions, though production practice generally favors setting correct ownership and directory permissions at the infrastructure level rather than loosening permissions from application code (chmod(0777, ...) is a red flag, not a fix).

tmpfile() creates an anonymous temporary file, returns a normal handle usable with the same fread/fwrite functions as any other file, and — critically — is automatically deleted the moment the handle is closed or the script ends, making it ideal for scratch data that must never persist. sys_get_temp_dir() returns the system's configured temp directory path (respecting the TMPDIR environment variable, php.ini's sys_temp_dir, or the OS default) for cases needing a named temp file rather than an anonymous handle — commonly combined with tempnam(sys_get_temp_dir(), 'prefix') to get a uniquely-named file in that directory that the script is then responsible for deleting itself.

The php:// stream wrappers

php://input is a read-only stream giving access to the raw HTTP request body exactly as it arrived — the standard way to read a raw JSON POST body or any body that $_POST doesn't auto-parse (form-urlencoded and multipart bodies populate $_POST automatically; anything else, like an application/json body, does not, and php://input is how you get at it). It can only be read once per request in most SAPIs and is unavailable when enctype is multipart/form-data (that body is already consumed into $_FILES/$_POST by the time your script runs).

php://output is a write-only stream that sends data directly to the same output buffer as echo/print — useful when a function expects to write to a stream resource (like fputcsv(), which needs a handle) but you want the result to go straight to the HTTP response rather than into a real file, exactly the pattern for streaming a CSV export without ever touching disk. php://memory and php://temp are both read-write, in-process scratch streams behaving like a real file handle (seekable, usable with fread/fwrite/fgets) without touching the filesystem at all for small data — the difference is that php://memory always stays in RAM, while php://temp starts in RAM and automatically spills over to a real temporary file on disk once the data exceeds a size threshold (2MB by default, configurable as php://temp/maxmemory:NEWSIZE), which avoids exhausting memory on an unexpectedly large in-memory buffer.

• php://input — read-only, the raw HTTP request body (e.g. for JSON APIs) • php://output — write-only, sends bytes straight into the HTTP response body • php://memory — read/write, always RAM-backed, never touches disk • php://temp — read/write, RAM-backed up to a threshold, then spills to a real temp file automatically

Stream contexts and filters

A stream context, created with stream_context_create(), bundles wrapper-specific options into a single value passed as an extra argument to fopen(), file_get_contents(), or similar functions — most commonly used to set custom HTTP headers, method, or timeout for an http:// wrapper request, or SSL options for an https:// one, without needing a full HTTP client library for a simple one-off request. It's the mechanism that lets file_get_contents('https://api.example.com/x', false, $context) send a POST with a JSON body and an Authorization header despite looking, at a glance, like a plain file-read call.

Stream filters are transformation layers PHP can attach to a stream so data is transformed automatically as it passes through — stream_filter_append($handle, 'string.toupper') on a stream makes every subsequent read or write through that handle pass through the filter, and built-in filters cover things like zlib.deflate/zlib.inflate for on-the-fly compression, convert.base64-encode/decode, and string.rot13, chained transparently without the calling code needing to manually post-process each chunk it reads or pre-process each chunk it writes.

Core file and stream functions

Always check fopen()'s return value against false before using the handle — a missing file or a permissions problem makes it fail silently unless you check. flock() only coordinates between processes that also call flock() on the same file — it does not prevent a process that skips locking entirely from writing concurrently.

<?php
// Manual handle-based I/O
$fh = fopen('/var/app/data/log.txt', 'a');
if ($fh !== false) {
    flock($fh, LOCK_EX);                 // exclusive lock before writing
    fwrite($fh, date('c') . " event\n");
    flock($fh, LOCK_UN);
    fclose($fh);
}

// Whole-file convenience functions
$contents = file_get_contents('/var/app/data/config.json');
file_put_contents('/var/app/data/out.txt', "hello\n", FILE_APPEND | LOCK_EX);

// Reading a raw JSON request body (not auto-parsed into $_POST)
$raw  = file_get_contents('php://input');
$data = json_decode($raw, true);

// Streaming CSV directly into the HTTP response, no temp file needed
$out = fopen('php://output', 'w');
fputcsv($out, ['id', 'name', 'email']);
fclose($out);

// Temporary scratch file, auto-deleted on close
$tmp = tmpfile();
fwrite($tmp, 'scratch data');
rewind($tmp);
echo fread($tmp, 1024);
fclose($tmp); // file is deleted here automatically

Code Examples

fgets() reads one line per call and returns false once the end of the file is reached, which is exactly the loop-termination condition used here — this line-by-line pattern is the standard way to process a large text file without loading the entire thing into memory at once, unlike file_get_contents(). Expected result: First note Second note Third note exportUsersAsCsv() writes directly to php://output via fputcsv(), streaming a correctly-escaped CSV file straight into the HTTP response with no temporary file ever created on disk. incrementSharedCounter() shows why locking must wrap the entire read-modify-write sequence, not just the final write: without holding LOCK_EX from before the read until after the write, two concurrent requests could both read the same starting value and one increment would be lost.

flock($fh, LOCK_EX); // exclusive lock across the whole read-modify-write readJsonBody() streams php://input into a php://memory buffer in fixed-size chunks, enforcing a hard cap before it ever fully materializes an oversized body, which is safer than a single file_get_contents('php://input') call when the request size isn't already bounded elsewhere. The stream context example shows file_get_contents() performing a real POST with custom headers against an https:// URL, entirely through context options rather than a dedicated HTTP client. In production, request size limits are normally also enforced at the web server or php.ini level (post_max_size) — the application-level check here is a second, explicit layer for cases where that alone isn't precise enough.

<?php
$handle = fopen('notes.txt', 'r');
if ($handle === false) {
    die('Could not open file.');
}

while (($line = fgets($handle)) !== false) {
    echo trim($line) . PHP_EOL;
}

fclose($handle);
<?php
function exportUsersAsCsv(array $users): void
{
    header('Content-Type: text/csv');
    header('Content-Disposition: attachment; filename="users.csv"');

    $out = fopen('php://output', 'w');
    fputcsv($out, ['id', 'name', 'email']);

    foreach ($users as $user) {
        fputcsv($out, [$user['id'], $user['name'], $user['email']]);
    }

    fclose($out);
}

function incrementSharedCounter(string $path): int
{
    $fh = fopen($path, 'c+'); // create if missing, don't truncate
    flock($fh, LOCK_EX);      // exclusive lock across the whole read-modify-write

    $current = (int) fread($fh, 1024);
    $next    = $current + 1;

    ftruncate($fh, 0);
    rewind($fh);
    fwrite($fh, (string) $next);

    flock($fh, LOCK_UN);
    fclose($fh);

    return $next;
}
<?php
// Read a raw JSON API request body with a size guard using php://memory
function readJsonBody(int $maxBytes = 1_000_000): array
{
    $input = fopen('php://input', 'rb');
    $buffer = fopen('php://memory', 'w+b'); // pure in-RAM scratch stream

    $total = 0;
    while (!feof($input)) {
        $chunk = fread($input, 8192);
        $total += strlen($chunk);
        if ($total > $maxBytes) {
            fclose($input);
            fclose($buffer);
            throw new RuntimeException('Request body too large.');
        }
        fwrite($buffer, $chunk);
    }
    fclose($input);

    rewind($buffer);
    $raw = stream_get_contents($buffer);
    fclose($buffer);

    $data = json_decode($raw, true);
    if (!is_array($data)) {
        throw new RuntimeException('Invalid JSON body.');
    }
    return $data;
}

// Stream context: POST JSON to an internal API with custom headers
$context = stream_context_create([
    'http' => [
        'method'  => 'POST',
        'header'  => "Content-Type: application/json\r\nAuthorization: Bearer secret123\r\n",
        'content' => json_encode(['event' => 'user.created']),
        'timeout' => 5,
    ],
]);
$response = file_get_contents
…

php:// stream wrappers at a glance

All five are opened and manipulated with the same fopen/fread/fwrite/fclose functions — the wrapper prefix, not a different API, is what changes where the bytes actually go.

HTTP request  ---> php://input   (read-only: raw request body)

  PHP script    ---> php://output  (write-only: straight into the HTTP response)

  php://memory  <--> in-process RAM only, never touches disk

  php://temp    <--> RAM up to ~2MB, then auto-spills to a real temp file on disk

  A real file   <--> fopen('/path', mode) -- an actual filesystem path

Important Rules

Always check fopen()'s return against false, and always pair a successful fopen() with fclose().

Use flock(LOCK_EX) around the entire read-modify-write sequence for any file multiple processes might touch concurrently, not just around the final write.

Prefer file_get_contents()/file_put_contents() for simple whole-file operations; use manual handles for line-by-line processing, CSV writing, or when a lock must span several calls.

Use php://input to read a raw (e.g. JSON) request body that $_POST does not auto-populate; use php://output to stream generated content straight into the HTTP response without a temp file.

Prefer php://memory/php://temp over a real temp file for scratch data that doesn't need to persist, and tmpfile() when you do want a real, auto-deleted temp file handle.

Where this shows up

CSV/report export endpoints stream through php://output with fputcsv() to avoid ever writing a temp file for a one-time download; JSON API endpoints read php://input because $_POST never populates for an application/json body; log rotation and any shared counter/lock file relies on flock() to stay correct under concurrent PHP-FPM worker processes; and stream contexts are a common lightweight alternative to pulling in a full HTTP client library for a single outbound webhook call.

$fh = fopen($counterFile, 'r+'); $n = (int) fread($fh, 100); fwrite($fh, (string) ($n + 1)); // no lock at all fclose($fh);

$fh = fopen($counterFile, 'r+'); flock($fh, LOCK_EX); $n = (int) fread($fh, 100); ftruncate($fh, 0); rewind($fh); fwrite($fh, (string) ($n + 1)); flock($fh, LOCK_UN); fclose($fh);

Without flock(LOCK_EX) held across the read and the write, two concurrent requests can both read the same value before either writes back, and one increment is silently lost — a classic race condition. The corrected version holds an exclusive lock for the whole read-modify-write sequence, plus properly truncates and rewinds before writing the new value.

[multiple-choice] Why must php://input be used (rather than $_POST) to read the body of a JSON API request? A. php://input is faster for every request type B. $_POST is only automatically populated for form-urlencoded and multipart form bodies, not for other content types like application/json C. $_POST is deprecated in PHP 8.2+ D. php://input works only with GET requests

B. $_POST is only automatically populated for form-urlencoded and multipart form bodies, not for other content types like application/json — PHP only parses the request body into $_POST for the two standard HTML form encodings. Any other body format, including a JSON API payload, arrives unparsed — php://input is the only way to access those raw bytes.

[multiple-choice] In a read-modify-write sequence on a shared file (read a counter, increment it, write it back), where should flock(LOCK_EX) be acquired and released? A. Only around the final fwrite() call B. It doesn't matter, as long as it's called somewhere in the function C. Before the read and held until after the write, so no other process can read a stale value in between D. flock() is unnecessary if file_put_contents() is used instead

C. Before the read and held until after the write, so no other process can read a stale value in between — If the lock is only held around the write, two processes can both read the same value before either writes, and one increment is lost. The lock must be acquired before the read and released only after the write completes, so the entire read-modify-write is atomic with respect to other lock-respecting processes.

Write a function streamCsvExport(array $rows, array $headers) that sends the correct CSV response headers, then writes $headers as the first row and each entry of $rows as a subsequent row, all directly to the HTTP response without creating any file on disk.

function streamCsvExport(array $rows, array $headers): void { header('Content-Type: text/csv'); header('Content-Disposition: attachment; filename="export.csv"'); $out = fopen('php://output', 'w'); fputcsv($out, $headers); foreach ($rows as $row) { fputcsv($out, $row); } fclose($out); }

Open a handle on php://output and use fputcsv() for both the header row and each data row.