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);
// <script>alert("xss")</script>
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_contentsandfile_put_contentsfor small-to-medium files;fopen/fread/fwrite/fclosefor large files that need chunk-by-chunk streaming to avoid running out of memory.- Always close file handles with
fclose()— usetry/finallyto guarantee the handle is always closed even when an exception occurs.LOCK_EXfor 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://memoryandphp://tempfor in-memory temporary files — very fast for intermediate operations whose results don’t need to be saved to disk.SplFileObjectwith theREAD_CSVflag makes CSV parsing cleaner and directly iterable withforeach.- Always validate paths from user input — use
realpath()and check that the path is within the allowed directory to prevent path traversal attacks.