IO

composer require php-standard-library/io

The IO component provides handle-based I/O abstractions. Instead of reaching for global functions like fread() and fwrite(), you work with typed handle interfaces that make I/O composable, testable, and safe.

Handle Interfaces

Handles are defined as narrow interfaces, each describing a single capability:

A concrete handle implements whichever combination applies. For example, a file handle implements read, write, seek, and close, while a network socket implements read, write, and close but not seek.

Buffered Interfaces

Two higher-level interfaces extend the base read and write interfaces for handles that buffer data internally:

Quick Output

Convenience functions write directly to stdout or stderr:

use Psl\IO;

$msg = 'file not found';

IO\write('Hello, %s!', 'world'); // stdout, no newline
IO\write_line('Count: %d', 42); // stdout + newline
IO\write_error('something went wrong'); // stderr
IO\write_error_line('Error: %s', $msg); // stderr + newline

All output functions support sprintf-style formatting.

Standard Handles

Three functions return the process-level I/O handles:

use Psl\IO;

$stdin = IO\input_handle(); // ReadHandleInterface  (STDIN in CLI)
$stdout = IO\output_handle(); // WriteHandleInterface (STDOUT in CLI)
$stderr = IO\error_handle(); // WriteHandleInterface (STDERR in CLI, null otherwise)

In non-CLI SAPIs, input_handle() reads from php://input and output_handle() writes to php://output. error_handle() returns null outside CLI.

MemoryHandle

MemoryHandle is an in-memory buffer implementing read, write, seek, and close. It is useful for testing code that accepts handle interfaces without touching the filesystem or network.

use Psl\IO;

$handle = new IO\MemoryHandle();
$handle->writeAll('Hello, World!');
$handle->seek(0);
$handle->readAll(); // 'Hello, World!'
$_ = $handle->getBuffer(); // 'Hello, World!' (always returns full buffer)

// Use MemoryHandle as a test double for any WriteHandleInterface
function render_report(IO\WriteHandleInterface $output): void
{
    $output->writeAll("Report\n");
}

$buffer = new IO\MemoryHandle();
render_report($buffer);
Psl\invariant($buffer->getBuffer() === "Report\n", 'Expected buffer to contain "Report\n"');

Reader

Reader wraps any ReadHandleInterface and implements BufferedReadHandleInterface, providing buffered, higher-level reading methods:

use Psl\IO;

$handle = new IO\MemoryHandle("line one\nline two\n\x00rest");
$reader = new IO\Reader($handle);

$line = $reader->readLine(); // read until newline: "line one"
$byte = $reader->readByte(); // read exactly one byte: "l"
$chunk = $reader->readFixedSize(7); // read exactly 7 bytes: "ine two"
$until = $reader->readUntil("\0"); // read until a delimiter (delimiter consumed but not returned): "\n"

IO\write_line('Line:  %s', $line ?? '<line not found>');
IO\write_line('Byte:  %s', $byte);
IO\write_line('Chunk: %s', $chunk);
IO\write_line('Until: %s', $until ?? '<suffix not found>');

Bounded Reads

Reader::readUntilBounded() works like readUntil() but enforces a maximum byte limit. If the suffix is not found within $max_bytes, an IO\Exception\OverflowException is thrown. This prevents unbounded memory consumption when reading from untrusted sources -- for example, capping HTTP header lines to a safe size so a malicious client cannot exhaust memory by sending an endless line.

use Psl\IO;

$handle = new IO\MemoryHandle("GET / HTTP/1.1\r\nHost: example.com\r\n\r\nbody");
$reader = new IO\Reader($handle);

// Read the request line, capped at 8192 bytes
$requestLine = $reader->readUntilBounded("\r\n", 8192);
IO\write_line('Request line: %s', $requestLine ?? '<not found>');

// Read a header line, capped at 4096 bytes
$header = $reader->readUntilBounded("\r\n", 4096);
IO\write_line('Header: %s', $header ?? '<not found>');

// If the line exceeds the limit, OverflowException is thrown:
$tinyHandle = new IO\MemoryHandle("this line is way too long\r\n");
$tinyReader = new IO\Reader($tinyHandle);

try {
    $tinyReader->readUntilBounded("\r\n", 5);
} catch (IO\Exception\OverflowException $e) {
    IO\write_line('Caught: %s', $e->getMessage());
}

Spool

IO\spool() creates a handle that writes to memory until a threshold is reached (default 2MB), then transparently spools to a temporary file on disk. This is useful when buffering data of unknown size without risking excessive memory usage.

use Psl\IO;
use Psl\Str;

// Writes to memory until 2MB, then spools to a temporary file on disk
$handle = IO\spool();

$handle->writeAll('Hello, World!');
$handle->seek(0);
$handle->readAll(); // 'Hello, World!'

$handle->close();

// Custom threshold: spool to disk after 64 bytes
$small = IO\spool(maxMemory: 64);
$small->writeAll(Str\repeat('x', 256)); // transparently written to disk
$small->seek(0);
$small->readAll(); // 256 bytes of 'x'

$small->close();

Pipes

IO\pipe() creates a connected pair of handles: anything written to the write end can be read from the read end.

use Psl\IO;

[$reader, $writer] = IO\pipe();
$writer->writeAll('hello');
$writer->close();

$result = $reader->readAll(); // 'hello'
$reader->close();

IO\write_line('Read from pipe: %s', $result);

Copying and Streaming

IO\copy() reads from one handle and writes to another until EOF. If the writer implements BufferedWriteHandleInterface, it is flushed after all data has been written:

use Psl\IO;

$source = new IO\MemoryHandle('data to copy');
$dest = new IO\MemoryHandle();
IO\copy($source, $dest);

IO\write_line('Copied: %s', $dest->getBuffer()); // 'data to copy'

For control over the read chunk size, use IO\copy_chunked():

use Psl\IO;

$source = new IO\MemoryHandle(str_repeat('x', 100_000));
$destination = new IO\MemoryHandle();

// Copy using 4 KB chunks instead of the default 8 KB
$bytesCopied = IO\copy_chunked($source, $destination, 4096);

$bytesCopied; // 100000

Both functions have bidirectional variants -- IO\copy_bidirectional() and IO\copy_bidirectional_chunked() -- that copy data in both directions concurrently between two read-write handles. These are useful for building proxies and tunnels.

IO\streaming() multiplexes reads from several stream handles concurrently, yielding chunks as they arrive. This is useful for reading interleaved process output:

use Psl\IO;

// Create two pipes to simulate concurrent streams
[$outReader, $outWriter] = IO\pipe();
[$errReader, $errWriter] = IO\pipe();

// Write data to both pipes
$outWriter->writeAll('stdout output');
$outWriter->close();
$errWriter->writeAll('stderr output');
$errWriter->close();

foreach (IO\streaming(['out' => $outReader, 'err' => $errReader]) as $name => $chunk) {
    IO\write_line('[%s] %s', $name, $chunk);
}

$outReader->close();
$errReader->close();

Handle Decorators

PSL provides several decorator handles that wrap an existing handle and add behavior.

IterableReadHandle

IterableReadHandle wraps an iterable<string> (array, generator, or any iterable) as a streaming ReadHandleInterface. The iterator is advanced lazily on each read() call, so the entire content is never buffered in memory.

use Psl\IO;

// Wrap a generator as a streaming read handle
$handle = new IO\IterableReadHandle(
    (function () {
        yield 'first chunk';
        yield 'second chunk';
        yield 'third chunk';
    })(),
);

$handle->read(); // 'first chunk'
$handle->read(); // 'second chunk'
$handle->read(); // 'third chunk'
$handle->read(); // '' (EOF)
$handle->reachedEndOfDataSource(); // true

// Also works with arrays
$handle = new IO\IterableReadHandle(['hello', ' ', 'world']);
$handle->readAll(); // 'hello world'

ConcatReadHandle

ConcatReadHandle reads from two handles in sequence. It delegates to the first handle until EOF, then transparently switches to the second.

use Psl\IO;

// Concatenate two handles into a single stream
$header = new IO\MemoryHandle("Header\n");
$body = new IO\MemoryHandle('Body content');

$combined = new IO\ConcatReadHandle($header, $body);
$combined->readAll(); // "Header\nBody content"

TruncatedReadHandle

TruncatedReadHandle reads up to N bytes from the underlying handle. Once the limit is reached, it silently reports EOF regardless of whether more data is available. Data beyond the limit is neither consumed nor validated.

use Psl\IO;

// Read at most 5 bytes, silently discard the rest
$inner = new IO\MemoryHandle('hello world');
$handle = new IO\TruncatedReadHandle($inner, 5);

$handle->readAll(); // 'hello'
$handle->reachedEndOfDataSource(); // true

// The underlying handle still has data, but the truncated handle doesn't care
$inner->tryRead(); // ' world'

BoundedReadHandle

BoundedReadHandle reads up to N bytes from the underlying handle. Unlike TruncatedReadHandle, it peeks past the limit to check for overflow. If the underlying handle has more data, an IO\Exception\RuntimeException is thrown. Use this when exceeding the limit is a violation (e.g., HTTP response body size limits).

use Psl\IO;

// Enforce a maximum of 5 bytes -- throw if the source has more
$inner = new IO\MemoryHandle('hello');
$handle = new IO\BoundedReadHandle($inner, 5);
$handle->readAll(); // 'hello' -- fits exactly, no error

// When the source exceeds the limit, an exception is thrown
$inner = new IO\MemoryHandle('hello world');
$handle = new IO\BoundedReadHandle($inner, 5);
$handle->read(5); // 'hello'

try {
    $handle->read(); // throws -- underlying handle has more data
} catch (IO\Exception\RuntimeException $e) {
    $e->getMessage(); // 'Response body exceeded the configured limit of 5 bytes.'
}

FixedLengthReadHandle

FixedLengthReadHandle reads exactly N bytes from the underlying handle. If the handle reaches EOF before N bytes are consumed, an IO\Exception\RuntimeException is thrown. Use this when the data source promises a specific length (e.g., HTTP Content-Length bodies, fixed-size protocol frames).

use Psl\IO;

// Read exactly 11 bytes
$inner = new IO\MemoryHandle('hello world');
$handle = new IO\FixedLengthReadHandle($inner, 11);
$handle->readAll(); // 'hello world'
$handle->reachedEndOfDataSource(); // true

// Premature EOF throws
$inner = new IO\MemoryHandle('hi');
$handle = new IO\FixedLengthReadHandle($inner, 10);

try {
    $handle->readAll(); // throws -- only 2 bytes available, expected 10
} catch (IO\Exception\RuntimeException $e) {
    $e->getMessage(); // 'Expected 10 bytes, but only 2 were available (premature EOF)'
}

JoinedReadWriteHandle

JoinedReadWriteHandle combines a ReadHandleInterface and a WriteHandleInterface into a single bidirectional handle. All read operations delegate to the reader, all write operations delegate to the writer. It implements BufferedWriteHandleInterface, so calling flush() delegates to the underlying writer if it also implements BufferedWriteHandleInterface.

use Psl\IO;

// Join separate read and write handles into one bidirectional handle
$reader = new IO\MemoryHandle('incoming data');
$writer = new IO\MemoryHandle();

$handle = new IO\JoinedReadWriteHandle($reader, $writer);

$handle->readAll(); // 'incoming data' (from reader)
$handle->writeAll('outgoing data'); // written to writer
$writer->seek(0);
$writer->readAll(); // 'outgoing data'

TeeWriteHandle

TeeWriteHandle writes to two handles simultaneously. If the second handle is slower, data is buffered internally and drained on subsequent writes. It implements BufferedWriteHandleInterface -- call flush() to explicitly drain any pending data to the second handle. This is useful for mirroring output to a log, computing a hash while streaming, or duplicating data to two destinations.

use Psl\IO;

// Write to two handles simultaneously -- useful for logging, hashing, or mirroring
$primary = new IO\MemoryHandle();
$mirror = new IO\MemoryHandle();

$tee = new IO\TeeWriteHandle($primary, $mirror);
$tee->writeAll('hello world');

$primary->seek(0);
$primary->readAll(); // 'hello world'

$mirror->seek(0);
$mirror->readAll(); // 'hello world'

Sink Handles

SinkWriteHandle discards all written data, like /dev/null. SinkReadHandle is a read handle that always reports EOF immediately -- unlike new MemoryHandle('') which only reports EOF after the first read attempt. SinkReadWriteHandle combines both: writes are discarded and reads always report EOF. All write sinks implement BufferedWriteHandleInterface with a no-op flush() for compatibility with code that expects flushable writers. These are useful as no-op handles in tests or when output must be consumed but can be discarded.

use Psl\IO;

// SinkWriteHandle discards everything -- like /dev/null
$sink = new IO\SinkWriteHandle();
$sink->writeAll('this goes nowhere');
$sink->writeAll('neither does this');

// SinkReadWriteHandle also supports reads, but always reports EOF
$sink = new IO\SinkReadWriteHandle();
$sink->writeAll('discarded');
$sink->read(); // '' (always empty)
$sink->reachedEndOfDataSource(); // true (always EOF)

See src/Psl/IO/ for the full API.