I/O #

I/O (Input/Output) is the foundation of almost every real application — reading configuration from files, writing logs, receiving input from CLI users, downloading data from URLs, or streaming large content without exhausting memory. PHP has a rich and flexible I/O system, built on the concept of streams — an abstraction that unifies files, networks, stdin/stdout, compression, and even custom data sources behind one consistent interface. This article covers file and directory operations in depth, streams and contexts that let you access URLs like files, stdin/stdout handling for CLI applications, stream filters for on-the-fly data transformation, and SplFileObject as a more modern OOP approach.

The Stream Concept in PHP #

All I/O in PHP works through streams — data channels that can be read, written, or both. Functions like fopen(), file_get_contents(), and fread() all work on streams underneath.

flowchart LR
    App[PHP Code] --> S[Stream\nUnified Abstraction]
    S --> F[file://\nFile system]
    S --> H[http://\nhttps://\nURL/Network]
    S --> P[php://\nstdin/stdout\nmemory/temp]
    S --> Z[compress.gz://\nzip://\nCompression]
    S --> C[Custom\nStream Wrapper]

    style S fill:#fef9c3,stroke:#ca8a04
<?php
// All of these use streams, even though the syntax differs
$content1 = file_get_contents('data.txt');           // file stream
$content2 = file_get_contents('https://api.example.com/data'); // http stream
$content3 = file_get_contents('php://stdin');        // stdin stream

// Stream wrappers built into PHP
$wrappers = stream_get_wrappers();
// ['https', 'ftps', 'compress.zlib', 'compress.bzip2', 'php', 'file', 'glob', 'data', 'http', 'ftp', 'phar', 'zip']

Basic File Operations #

Reading Files #

PHP provides several ways to read files, each suited to a different situation:

<?php
// 1. file_get_contents() — read the entire file into a string
// Suitable for small-to-medium files (< a few MB)
$content = file_get_contents('config.json');
$config  = json_decode($content, true);

// 2. file() — read the entire file as an array of lines
$lines = file('data.csv', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $i => $line) {
    echo "$i: $line\n";
}

// 3. fopen/fread/fclose — read with full control
// Suitable for large files (streaming)
$handle = fopen('large_data.log', 'r');
if ($handle === false) {
    throw new \RuntimeException("Failed to open file");
}

try {
    while (!feof($handle)) {
        $line = fgets($handle, 4096); // read one line, max 4KB
        if ($line === false) break;
        // process the line...
    }
} finally {
    fclose($handle); // always close
}

// 4. readfile() — read and output directly to the browser
// Useful for streaming downloads
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="report.pdf"');
readfile('/path/to/report.pdf');

// 5. Read a number of bytes from a specific position
$handle = fopen('data.bin', 'rb'); // 'b' for binary mode
fseek($handle, 1024);              // jump to byte 1024
$chunk  = fread($handle, 512);     // read 512 bytes
$position = ftell($handle);        // current position: 1536
fclose($handle);

Writing Files #

<?php
// 1. file_put_contents() — write a string to a file
// Overwrites old content by default
$bytes = file_put_contents('output.txt', "First line\n");
echo "Wrote $bytes bytes\n";

// Append — add at the end of the file
file_put_contents('log.txt', date('Y-m-d H:i:s') . " — event occurred\n", FILE_APPEND);

// With locking — safe for concurrent writes
file_put_contents('data.json', json_encode($data), LOCK_EX);

// 2. fopen/fwrite/fclose — full control
$handle = fopen('report.csv', 'w'); // 'w' = write, create if missing, truncate if exists
if ($handle === false) {
    throw new \RuntimeException("Failed to open file for writing");
}

try {
    // Write the CSV header
    fputcsv($handle, ['ID', 'Name', 'Email', 'Total']);

    // Write the data
    foreach ($data as $row) {
        fputcsv($handle, [$row['id'], $row['name'], $row['email'], $row['total']]);
    }
} finally {
    fclose($handle);
}

// Important fopen file modes:
// 'r'  — read only, pointer at the start
// 'r+' — read and write, pointer at the start
// 'w'  — write only, create/truncate, pointer at the start
// 'w+' — read and write, create/truncate
// 'a'  — append only, pointer at the end
// 'a+' — read and append
// 'x'  — write only, fails if it already exists (safe for create-new)
// 'x+' — read and write, fails if it already exists

File Locks — Safe Access from Multiple Processes #

<?php
// Atomic read-modify-write with an exclusive lock
function incrementCounter(string $file): int
{
    $handle = fopen($file, 'c+'); // open or create, don't truncate

    // Get an exclusive lock — wait if another process is accessing
    flock($handle, LOCK_EX);

    try {
        $value  = (int) fread($handle, 20);
        $value++;

        rewind($handle);           // back to the start
        ftruncate($handle, 0);     // clear old content
        fwrite($handle, (string) $value);

        return $value;
    } finally {
        flock($handle, LOCK_UN);   // release the lock
        fclose($handle);
    }
}

// Safe to call from several concurrent processes
echo incrementCounter('/tmp/counter.txt'); // always increments correctly

Directory Operations #

<?php
// Create directories
mkdir('/tmp/results', 0755);
mkdir('/tmp/a/b/c', 0755, recursive: true); // create all levels at once

// Check and navigate
echo getcwd();                          // current directory
chdir('/tmp');                          // change directory
echo realpath('../config');             // resolved absolute path
echo dirname('/var/www/app/index.php'); // '/var/www/app'
echo basename('/var/www/app/index.php'); // 'index.php'

// Scan a directory
$entries = scandir('/var/www/app');
foreach ($entries as $entry) {
    if ($entry === '.' || $entry === '..') continue;
    $path = '/var/www/app/' . $entry;
    echo ($entry . (is_dir($path) ? '/' : '') . "\n");
}

// Glob — pattern matching for files
$phpFiles  = glob('/var/www/app/src/**/*.php');
$csvFiles  = glob('/data/export/*.csv');
$allFiles  = glob('/tmp/{*.log,*.txt}', GLOB_BRACE); // multiple patterns

// File and directory info
$file = '/var/www/app/index.php';
echo file_exists($file) ? "exists" : "doesn't exist";
echo is_file($file) ? "file" : "not a file";
echo is_dir(dirname($file)) ? "directory exists" : "doesn't";
echo is_readable($file) ? "readable" : "not readable";
echo is_writable($file) ? "writable" : "not writable";
echo filesize($file) . " bytes";
echo date('Y-m-d H:i:s', filemtime($file)); // last modification time

// Copy, move, delete
copy('/tmp/source.txt', '/tmp/dest.txt');
rename('/tmp/old.txt', '/tmp/new.txt');  // move or rename
unlink('/tmp/delete.txt');                // delete a file
rmdir('/tmp/empty-dir');                // delete a directory (must be empty)

Recursive Directory Deletion #

<?php
function deleteDirRecursive(string $path): void
{
    if (!is_dir($path)) {
        throw new \InvalidArgumentException("$path is not a directory");
    }

    $entries = new \RecursiveIteratorIterator(
        new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS),
        \RecursiveIteratorIterator::CHILD_FIRST // delete contents first, then the container
    );

    foreach ($entries as $entry) {
        if ($entry->isDir()) {
            rmdir($entry->getPathname());
        } else {
            unlink($entry->getPathname());
        }
    }

    rmdir($path); // delete the main directory
}

// Copy a directory with its contents
function copyDirRecursive(string $source, string $destination): void
{
    if (!is_dir($destination)) {
        mkdir($destination, 0755, true);
    }

    $iterator = new \RecursiveIteratorIterator(
        new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
        \RecursiveIteratorIterator::SELF_FIRST
    );

    foreach ($iterator as $item) {
        $target = $destination . DIRECTORY_SEPARATOR . $iterator->getSubPathname();
        if ($item->isDir()) {
            mkdir($target, 0755, true);
        } else {
            copy($item->getPathname(), $target);
        }
    }
}

Stream Contexts — Configuring Network Access #

Stream contexts allow configuring HTTP requests, SSL, FTP, and other options when accessing URLs as files:

<?php
// HTTP GET with custom headers
$context = stream_context_create([
    'http' => [
        'method'  => 'GET',
        'header'  => implode("\r\n", [
            'Authorization: Bearer ' . $token,
            'Accept: application/json',
            'User-Agent: MyApp/1.0',
        ]),
        'timeout' => 10, // 10 second timeout
    ],
    'ssl' => [
        'verify_peer'       => true,
        'verify_peer_name'  => true,
        'cafile'            => '/etc/ssl/certs/ca-certificates.crt',
    ],
]);

$response = file_get_contents('https://api.example.com/data', false, $context);
$data     = json_decode($response, true);

// HTTP POST — send JSON
$payload = json_encode(['name' => 'Budi', 'email' => '[email protected]']);

$context = stream_context_create([
    'http' => [
        'method'  => 'POST',
        'header'  => implode("\r\n", [
            'Content-Type: application/json',
            'Content-Length: ' . strlen($payload),
            'Authorization: Bearer ' . $token,
        ]),
        'content' => $payload,
        'timeout' => 30,
        'ignore_errors' => true, // get the body even on HTTP 4xx/5xx errors
    ],
]);

$response = file_get_contents('https://api.example.com/users', false, $context);
$httpCode = $http_response_header[0]; // "HTTP/1.1 201 Created"

// Extract the HTTP status code
preg_match('/HTTP\/\d\.\d (\d{3})/', $httpCode, $m);
$statusCode = (int) $m[1]; // 201

Special PHP Streams #

PHP provides the php:// stream wrapper for various special purposes:

<?php
// php://stdin — input from the terminal (blocking)
$input = fgets(STDIN);
echo "You entered: $input";

// php://stdout and php://stderr — output
fwrite(STDOUT, "Normal message\n");
fwrite(STDERR, "Error message\n"); // unbuffered, goes out immediately

// php://memory — temporary file in memory (fast, no disk I/O)
$handle = fopen('php://memory', 'r+');
fwrite($handle, "temporary data\n");
rewind($handle);
$content = stream_get_contents($handle);
fclose($handle);
// $content = "temporary data\n"

// php://temp — in memory up to 2MB, then moves to a temp file
$handle = fopen('php://temp', 'r+');
fwrite($handle, str_repeat('x', 3_000_000)); // > 2MB, moves to disk

// php://input — raw request body (read once, can't be rewound)
// Useful for receiving JSON or binary from a POST request
$body  = file_get_contents('php://input');
$data  = json_decode($body, true);

// php://filter — apply filters while reading
// Base64-encode a file as it's read
$base64 = file_get_contents('php://filter/convert.base64-encode/resource=image.png');

Stream Filters — On-the-Fly Data Transformation #

Stream filters allow data to be transformed as it flows through a stream — without having to read the entire file into memory:

<?php
// Useful built-in filters
// string.toupper, string.tolower, string.rot13
// convert.base64-encode, convert.base64-decode
// zlib.deflate, zlib.inflate
// bzip2.compress, bzip2.decompress

// Read a file and convert to uppercase at the same time
$handle = fopen('data.txt', 'r');
stream_filter_append($handle, 'string.toupper');

while (!feof($handle)) {
    echo fread($handle, 1024); // already uppercase when it exits the filter
}
fclose($handle);

// Compress while writing
$handle = fopen('data.gz', 'wb');
stream_filter_append($handle, 'zlib.deflate', STREAM_FILTER_WRITE, ['level' => 6]);
fwrite($handle, "data to be compressed\n");
fwrite($handle, str_repeat("lorem ipsum ", 1000));
fclose($handle);

// Custom filter implementation
class SanitizeHtmlFilter extends \php_user_filter
{
    public function filter($in, $out, &$consumed, bool $closing): int
    {
        while ($bucket = stream_bucket_make_writeable($in)) {
            $bucket->data = htmlspecialchars($bucket->data, ENT_QUOTES, 'UTF-8');
            $consumed    += $bucket->datalen;
            stream_bucket_append($out, $bucket);
        }
        return PSFS_PASS_ON;
    }
}

stream_filter_register('sanitize.html', SanitizeHtmlFilter::class);

// Use the custom filter
$handle = fopen('php://memory', 'r+');
stream_filter_append($handle, 'sanitize.html');
fwrite($handle, '<script>alert("xss")</script>');
rewind($handle);
echo fread($handle, 100);
// &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;

SplFileObject — Object-Oriented I/O #

SplFileObject is the OOP way to work with files, implementing Iterator so it can be used directly with foreach:

<?php
// Reading a CSV with SplFileObject
$file = new \SplFileObject('data.csv', 'r');
$file->setFlags(
    \SplFileObject::READ_CSV |        // parse as CSV automatically
    \SplFileObject::SKIP_EMPTY |      // skip empty lines
    \SplFileObject::DROP_NEW_LINE     // strip the newline at the end of lines
);

// Read the header
$file->rewind();
$header = $file->current();
$file->next();

// Read the data
while (!$file->eof()) {
    $row = $file->current();
    if ($row === false || $row === [null]) {
        $file->next();
        continue;
    }

    $data = array_combine($header, $row);
    // process $data...
    $file->next();
}

// Writing a CSV with SplFileObject
$output = new \SplFileObject('report.csv', 'w');
$output->fputcsv(['ID', 'Name', 'Email']); // header

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

// SplFileObject can also be used with foreach directly
$file = new \SplFileObject('access.log', 'r');
$file->setFlags(\SplFileObject::DROP_NEW_LINE);

$errorCount = 0;
foreach ($file as $line) {
    if (str_contains($line, ' 500 ')) {
        $errorCount++;
    }
}
echo "500 errors found: $errorCount\n";

Temporary Files #

<?php
// tmpfile() — create a temp file that's automatically deleted when closed
$tmp = tmpfile();
fwrite($tmp, "temporary data");
rewind($tmp);
$content = fread($tmp, 100);
fclose($tmp); // the file is automatically deleted

// tempnam() — create a unique temp file name
$fileName = tempnam(sys_get_temp_dir(), 'prefix_');
// Example: /tmp/prefix_abc123

file_put_contents($fileName, $data);
// process...
unlink($fileName); // must delete manually

// sys_get_temp_dir() — the OS-appropriate temp directory
echo sys_get_temp_dir(); // /tmp on Linux, C:\Windows\Temp on Windows

I/O for CLI Applications #

<?php
// Read from stdin — interactive input
echo "Enter your name: ";
$name = trim(fgets(STDIN));
echo "Hello, $name!\n";

// Detect whether input comes from a terminal or a pipe
if (posix_isatty(STDIN)) {
    echo "Input from terminal\n";
} else {
    echo "Input from pipe or file\n";
}

// Read a password without echo (Linux/macOS)
function readPassword(string $prompt = "Password: "): string
{
    if (PHP_OS_FAMILY === 'Windows') {
        // Windows doesn't support this approach
        echo $prompt;
        return trim(fgets(STDIN));
    }

    echo $prompt;
    system('stty -echo'); // turn off terminal echo
    $password = trim(fgets(STDIN));
    system('stty echo');  // turn it back on
    echo "\n";
    return $password;
}

// Structured output to stderr (for errors) and stdout (for data)
function success(string $message): void
{
    fwrite(STDOUT, "\033[32m✓ $message\033[0m\n"); // green
}

function error(string $message): void
{
    fwrite(STDERR, "\033[31m✗ $message\033[0m\n"); // red
}

function info(string $message): void
{
    fwrite(STDOUT, "\033[36mℹ $message\033[0m\n"); // cyan
}

// A simple progress bar
function progressBar(int $done, int $total, int $width = 40): void
{
    $percent = $total > 0 ? round($done / $total * 100) : 0;
    $filled  = (int) ($width * $done / max($total, 1));
    $empty   = $width - $filled;
    $bar     = str_repeat('█', $filled) . str_repeat('░', $empty);

    // \r — back to the start of the line (overwrite, not a new line)
    printf("\r[%s] %d%% (%d/%d)", $bar, $percent, $done, $total);

    if ($done >= $total) {
        echo "\n"; // newline at the end
    }
}

$total = 100;
for ($i = 1; $i <= $total; $i++) {
    usleep(50000); // simulate work
    progressBar($i, $total);
}

Common I/O Anti-Patterns #

<?php
// ✗ Anti-pattern 1: reading a large file entirely into memory
$content = file_get_contents('data_1gb.csv'); // PHP runs out of memory!
$lines = explode("\n", $content);             // makes the problem worse

// ✓ Stream line by line
$handle = fopen('data_1gb.csv', 'r');
while (($line = fgets($handle)) !== false) {
    process($line); // only one line in memory
}
fclose($handle);

// ✗ Anti-pattern 2: not closing file handles
function readData(): string
{
    $handle = fopen('data.txt', 'r');
    $content = fread($handle, 1024);
    return $content; // handle NOT closed — resource leak!
}

// ✓ Always close — use try/finally
function readDataCorrect(): string
{
    $handle = fopen('data.txt', 'r');
    try {
        return fread($handle, 1024);
    } finally {
        fclose($handle); // always closed even on exception
    }
}

// ✗ Anti-pattern 3: not validating paths from user input
$file = $_GET['file'];
echo file_get_contents($file); // Path traversal attack! ../../etc/passwd

// ✓ Validate and constrain the path
function readFileSafe(string $fileName, string $baseDir): string
{
    // Normalize the path
    $fullPath   = realpath($baseDir . '/' . $fileName);

    // Make sure the file is within the allowed directory
    if ($fullPath === false || !str_starts_with($fullPath, realpath($baseDir))) {
        throw new \InvalidArgumentException("File access not allowed");
    }

    if (!is_readable($fullPath)) {
        throw new \RuntimeException("File is not readable");
    }

    return file_get_contents($fullPath);
}

// ✗ Anti-pattern 4: writing to the same file from many processes without a lock
file_put_contents('log.txt', $message, FILE_APPEND); // can corrupt when concurrent

// ✓ Use FILE_APPEND with LOCK_EX
file_put_contents('log.txt', $message . "\n", FILE_APPEND | LOCK_EX);

Summary #

  • All PHP I/O works via streams — a unified abstraction that brings files, URLs, stdin/stdout, and custom data sources together behind one consistent interface.
  • file_get_contents and file_put_contents for small-to-medium files; fopen/fread/fwrite/fclose for large files that need chunk-by-chunk streaming to avoid running out of memory.
  • Always close file handles with fclose() — use try/finally to guarantee the handle is always closed even when an exception occurs.
  • LOCK_EX for exclusive locks when writing from several concurrent processes — prevents data corruption in logs and counter files.
  • Stream contexts allow configuring HTTP requests (method, headers, timeout, SSL) when accessing URLs with file_get_contents() — no need for Guzzle in simple cases.
  • php://memory and php://temp for in-memory temporary files — very fast for intermediate operations whose results don’t need to be saved to disk.
  • SplFileObject with the READ_CSV flag makes CSV parsing cleaner and directly iterable with foreach.
  • Always validate paths from user input — use realpath() and check that the path is within the allowed directory to prevent path traversal attacks.

← Previous: Multi Threading   Next: Sockets →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact