Copied from wiki.php.net, last synchronized Oct 13, 2026 11:49 UTC
CLI applications need to read keys, hide password input, switch terminal modes, and get the terminal size. PHP has stream line-reading functions and an optional readline extension, but libraries still need platform-specific code for these terminal operations. On POSIX this may involve tools such as stty; Windows uses the Console API.
This RFC proposes Io\Terminal in ext/standard, with support for POSIX terminals and Windows consoles. It provides terminal size queries, raw mode, key input, line input, and hidden input.
Libraries would build prompts, menus, history, completion, and screen rendering on top of these operations.
<?php
use Io\Terminal\Key;
use Io\Terminal\Terminal;
use Time\Duration;
$terminal = Terminal::fromStdio();
$size = $terminal->getSize();
if ($size !== null) {
printf("%dx%d\n", $size->cols, $size->rows);
}
$key = $terminal->readKey(Duration::fromSeconds(5));
if ($key === Key::Escape) {
echo "Escape pressed\n";
}
The proposed API is:
<?php
namespace Io\Terminal {
/** @strict-properties */
class TerminalException extends \Io\IoException {}
enum Key
{
case Up;
case Down;
case Right;
case Left;
case Enter;
case Backspace;
case Escape;
case Tab;
case Home;
case End;
case Delete;
case Insert;
case PageUp;
case PageDown;
case Resize;
case ShiftTab;
case F1;
case F2;
case F3;
case F4;
case F5;
case F6;
case F7;
case F8;
case F9;
case F10;
case F11;
case F12;
}
/** @strict-properties */
final readonly class TerminalSize
{
public readonly int $cols;
public readonly int $rows;
public function __construct(int $cols, int $rows) {}
}
/**
* @strict-properties
* @not-serializable
*/
final class ModeToken
{
private function __construct() {}
}
/**
* @strict-properties
* @not-serializable
*/
final class Terminal
{
private function __construct() {}
public static function fromStdio(): Terminal {}
/**
* @param resource $input
* @param resource|null $output
*/
public static function fromStreams(
$input,
$output = null,
): Terminal {}
public function getSize(): ?TerminalSize {}
public function enableRawMode(): ModeToken {}
public function restoreMode(
ModeToken $mode,
): void {}
public function readKey(
?\Time\Duration $timeout = null,
?\Time\Duration $sequenceTimeout = null,
): Key|string|null {}
public function readLine(): ?string {}
public function readSecret(
?\Time\Duration $timeout = null,
): ?string {}
}
}
Terminal is the final native implementation. There is one terminal class, with factories for standard I/O and supplied streams. ModeToken is a final opaque object representing one raw-mode lease; userland cannot construct it directly.
Terminal and ModeToken cannot be cloned or serialized. TerminalSize is a readonly value object with a public constructor.
Ordinary PHP streams provide scripted input for application tests without a separate core interface. They exercise input handling, but do not simulate terminal dimensions, resize events, or native mode failures. Applications needing replaceable implementations for those operations can define their own abstraction.
Terminal::fromStdio() uses the process standard input and output. Native standard descriptors or handles are resolved when an operation runs; this factory does not open the controlling terminal independently of standard I/O. The available PHP standard-stream buffering is also used where applicable.
Terminal::fromStreams() accepts an input stream and an optional output stream. If the output is omitted or null, the input stream is used for both directions.
$terminal = Terminal::fromStreams($input, $output);
$input must be a valid PHP stream resource. $output must be null or a valid PHP stream resource. The constructor is private; instances are created through these factories.
fromStreams() retains references to the supplied resources and uses those streams, including their PHP buffering. It does not retarget itself to subsequently changed standard handles. The two factories therefore have different binding behavior, but produce the same native terminal type.
The streams do not have to be attached to a terminal. On ordinary streams, readKey() decodes byte input, readLine() reads through the stream layer, and readSecret() processes input without writing it to the output stream. enableRawMode() returns a no-op lease rather than failing solely because the input is not a terminal. It does not change the ordinary stream's blocking mode or turn it into a terminal. getSize() returns null if a native size cannot be obtained.
Native terminal or console input uses the corresponding platform APIs. Operational failures still throw TerminalException; accepting ordinary streams does not make failed I/O successful.
getSize() queries the output side and returns a TerminalSize, or null if no usable native size is available. A size with zero rows or columns is treated as unavailable. The method does not use COLUMNS or LINES as fallbacks.
TerminalSize can also be constructed in userland:
$size = new Io\Terminal\TerminalSize(80, 24);
Both dimensions must be greater than zero; otherwise the constructor throws ValueError.
In the usual terminal mode, input is buffered until Enter, typed characters are echoed, and the terminal driver handles control characters. Raw mode allows the application to handle input as it arrives.
On a native terminal or console, raw mode provides these observable properties:
The platform flags used to achieve these properties are implementation details.
readKey() and readSecret() manage their own temporary terminal mode. enableRawMode() is for applications that need to keep raw mode across several operations, such as a menu that handles arrow keys and redraws without waiting for Enter.
$mode = $terminal->enableRawMode();
try {
// interactive loop
} finally {
$terminal->restoreMode($mode);
}
Each call to enableRawMode() acquires one lease and returns a ModeToken. Failure to acquire the lease or enable the native mode throws TerminalException.
Leases are shared per logical terminal within a PHP request. The first lease saves the original mode. Additional acquisitions share that restoration snapshot and add a lease. The implementation may reapply raw input settings if the terminal's mode was changed externally; this does not replace the original snapshot. Only release of the final lease restores that snapshot. Leases can be released in any order. Unrelated terminals, including unrelated pseudo-terminals, have separate state.
Restoration uses an independent native descriptor or handle, so closing the original PHP stream does not by itself prevent restoration. A lease can also be released through another Terminal for the same logical terminal:
$first = Terminal::fromStreams($stream);
$second = Terminal::fromStreams($stream);
$mode = $first->enableRawMode();
$second->restoreMode($mode);
restoreMode($mode) returns normally after releasing the specified lease; its return type is void. Releasing an intermediate lease does not restore the original OS mode while other leases remain. An invalid, consumed, or unrelated token throws ValueError. A native restoration failure throws TerminalException. If explicit restoration of the final lease fails, the token remains active so the caller can retry.
The token argument is required. Terminal does not retain a latest token, and there is no no-argument restoration operation. The caller must retain the token for as long as its lease is needed. Discarding the result of enableRawMode() therefore does not keep a lease active.
A token can outlive the wrapper that created it. Destroying the last reference to an active token attempts to release its lease. Request shutdown also attempts to restore active modes. These automatic attempts are best-effort; use restoreMode($mode) to receive an exception if restoration fails. Abrupt process termination may leave the terminal mode unrestored.
A token belongs to the process that acquired it. After pcntl_fork(), destruction of an inherited token or shutdown in the child does not release the parent's lease. Explicit restoration of an inherited parent token in the child throws ValueError.
On ordinary streams, a no-op token follows the same ownership, lifetime, and consumed-token rules. It is associated with the supplied input stream, and can be released through another Terminal wrapping that same stream. It does not prevent readLine() on that ordinary stream.
Terminal modes are shared by descriptors and processes using the same native terminal. These leases coordinate this API's mode ownership; they do not provide exclusive access to the terminal or prevent another process from changing its settings. Final restoration restores the saved snapshot, rather than merging changes made externally while leases were active.
readKey() returns a Key case for a recognized special key, or a string for other input. A resize is returned as Key::Resize.
$timeout sets the overall timeout:
null: wait without a finite deadlineValueErrorThe method returns null if no input result is available before the timeout. Escape-sequence handling can return Key::Escape or a consumed prefix instead, as described below. EOF and terminal, input, or polling failures throw TerminalException.
On POSIX, and on ordinary byte streams on either platform, arrow and function keys can be represented by escape sequences. $sequenceTimeout controls how long to wait to distinguish a standalone Escape from the start of a sequence. The default for a blocking read is 25 milliseconds. Zero disables this extra wait, and a negative duration throws ValueError. An overall timeout of zero also disables the extra wait. The sequence wait cannot extend the overall timeout.
If no continuation arrives during this window, a standalone Escape is returned as Key::Escape. If part of a sequence has been consumed, that prefix may be returned as a string. For example, ESC [ without its final byte can return:
"\x1b["
A byte arriving afterwards is read independently by the next call.
UTF-8 continuation bytes use the overall timeout, not $sequenceTimeout. If the overall timeout expires after consuming part of a multibyte value, the bytes are retained for a later readKey() call to complete the value.
Native Windows consoles use Console input records rather than escape sequences. $sequenceTimeout is validated but is not used for Windows console key decoding. Ordinary Windows streams use the byte-stream escape decoder and its sequence timeout.
readLine() returns a line with the trailing LF or CRLF removed. Only that ending is removed; spaces, tabs, and other data are preserved. With blocking byte-stream input:
"hello\n" -> "hello"
"hello\r\n" -> "hello"
"\n" -> ""
"\r\n" -> ""
Immediate EOF returns null. If EOF follows data without a newline, that final unterminated line is returned. A bare CR at EOF in a byte stream remains part of that unterminated line.
PHP already has fgets() and other stream line-reading functions. readLine() makes line input available through the same object as key and secret input, while handling native console input and checking for conflicting raw-mode leases:
For native POSIX terminal reads, EAGAIN or EWOULDBLOCK causes the method to wait for readability and continue until a newline or EOF. It does not change descriptor flags or treat temporary unavailability as EOF.
readLine() throws TerminalException while a managed raw-mode lease is active for the same native terminal, including a lease acquired through another wrapper. It does not suspend or release that lease. Line reading can proceed after the last lease is released. No-op leases on ordinary streams do not conflict with line reading.
On byte-stream input, bytes retained by the same Terminal after an incomplete UTF-8 key read are prepended to the line rather than discarded. Line-ending removal applies to the complete assembled line.
There is no timeout parameter. A portable whole-line timeout that preserves native line editing would require more control over the input and editing loop, particularly on Windows. This can be considered separately. History, completion, prompt rendering, configurable key bindings, syntax highlighting, and a portable line editor are outside this RFC. Editing available from the native line discipline or console remains platform-dependent. The existing readline extension remains separate.
readSecret() reads input without echoing it. It manages the temporary terminal mode itself; an explicit raw-mode lease is not required. On ordinary streams there is no native echo mode to change, and the method does not write the input to the output stream.
Enter submits the value. On byte streams, CR or LF submits it; CRLF is treated as one submission delimiter, including when its bytes arrive separately. A following read through the same Terminal does not receive the LF belonging to that delimiter as a separate key or empty line. Input after the delimiter remains available.
Backspace removes the previous UTF-8 code point for well-formed UTF-8 input. On byte streams, both BS (0x08) and DEL (0x7F) have this behavior. Backspace on an empty value has no effect. Editing does not combine multiple code points into grapheme clusters or normalize text. Windows console text is converted to UTF-8; arbitrary byte-stream input is not guaranteed to be valid UTF-8.
Standalone Escape, Ctrl+C, and Ctrl+D cancel the read with TerminalException. Ctrl+D is cancellation here, not submission of a partial value. Navigation and function-key input is consumed without being added to the secret. There is no cursor navigation, history, completion, prompt rendering, or masking output.
$timeout follows the same rules as readKey(): null waits without a finite deadline, zero makes a non-blocking attempt, a positive duration sets an overall deadline, and a negative duration throws ValueError. Zero permits processing already-available input without waiting for additional bytes. A positive deadline covers input processing as well as waits; additional input or escape-sequence bytes do not restart it. Normal scheduling and cleanup time can add overhead to the requested duration.
The submitted value is returned as a string, including “” for empty input. A timeout before submission returns null. EOF before submission, cancellation, and operational failures throw TerminalException. An unsubmitted value accumulated by this call is discarded on timeout, cancellation, or failure; the method does not return a partial secret. Already-consumed input is not rewound.
There is no sequenceTimeout argument, since escape sequences are handled internally and are not returned.
Before returning or throwing, the method attempts to restore the native mode that was in effect before the read. An existing raw-mode lease remains active. Failure to restore the mode throws TerminalException, even if a value had been submitted or the read would otherwise have returned null.
Pending decoder state belongs to each Terminal instance. This includes incomplete UTF-8 input, split CRLF tracking, and pending Windows console key records. Sharing raw-mode leases does not share this input state, even when two instances wrap the same input.
Use the same Terminal instance when switching between key, line, and secret reads on one input. Retained UTF-8 bytes from readKey() participate in the next byte-stream read by that instance rather than being silently cleared when changing methods. Different instances or direct reads from the underlying stream are not coordinated and may consume input another reader was expecting. The API does not add a reader lock or an exclusive-reader lease.
The return values for absence, EOF, and timeout are:
getSize(): null when no usable size is availablereadKey(): null when no input result is available before the timeoutreadLine(): null on EOF before any line datareadSecret(): null on timeout before submissionrestoreMode($mode): normal completion with a void return after successful lease releaseOperational failures throw TerminalException. EOF during readKey(), and EOF or cancellation during readSecret(), use that same exception type. This RFC does not add separate exception subclasses or reason codes for those cases. Exception messages are diagnostic text, not stable reason identifiers.
Invalid argument values, such as negative durations, non-positive dimensions, or consumed mode tokens, throw ValueError. PHP's usual type and argument-count checks apply, including to a missing restoreMode() token argument.
For native terminal line reads, Linux PTY closure reported as EIO is handled as EOF. This does not change error handling for arbitrary non-terminal streams. EAGAIN and EWOULDBLOCK are handled by waiting for input, as described above.
Existing PHP APIs are unchanged. The following names are added:
Io\Terminal\TerminalIo\Terminal\TerminalSizeIo\Terminal\ModeTokenIo\Terminal\TerminalExceptionIo\Terminal\KeyUserland code or extensions declaring the same names will conflict with the internal symbols. Backport extensions must avoid registering duplicate symbols when the native API is available.
PHP 8.7.
Libraries can use the native implementation when available and keep their existing fallbacks for older PHP versions. Applications can inject a Terminal created from scripted streams to test line, key, and secret input without a native console. This does not replace mocking of every terminal operation: dimensions, resize events, and OS failures still require native tests or an application-defined abstraction.
$input = fopen('php://temp', 'w+');
fwrite($input, "Widget\nsecret\n");
rewind($input);
$terminal = Terminal::fromStreams($input);
assert($terminal->readLine() === 'Widget');
assert($terminal->readSecret() === 'secret');
Prompts, completion, forms, styling, and other application-level UI remain in userland. The ext-terminal extension can continue as a backport for older PHP versions and for work outside this RFC, provided it avoids conflicts with the native symbols.
The implementation is in ext/standard. No existing extension API is removed or changed. The optional readline extension is unaffected.
The main use case is CLI. The classes and enum are available in other SAPIs, with each operation following the behavior described above when no terminal is attached. readLine() also supports redirected PHP streams.
Version 0.4 replaces the two public interfaces and their native implementations with final native Terminal and ModeToken classes. Supplied streams provide scripted input testing; applications requiring a replaceable contract for every operation can define that contract themselves.
This revision also:
restoreMode() and changes its return type to voidKey::Insert and Key::ShiftTabThe shared logical-terminal lease model is retained: one original snapshot, nested and out-of-order leases, cross-wrapper restoration, and final-lease restoration. readLine(): ?string remains included with its existing limited scope and no timeout parameter. No new exception types, token methods, or line-editor features are added.
The earlier draft left line input out because PHP already provides stream line-reading functions. It is included to keep sequential input on the same object as key and secret reads, support native Windows console line input, and check for conflicting managed raw-mode leases. It does not promise a portable line editor.
The RFC remains Under Discussion. The version 0.4 implementation has been pushed to the implementation PR for review. Validation and review remain ongoing; this revision does not announce voting.
The following are outside this RFC. Some are available or have been explored in the reference extension:
These may be proposed separately.
Primary Vote requiring a 2/3 majority to accept the RFC:
Vote: Implement Io\Terminal for PHP 8.7 as outlined in the RFC? — Yes: 0 · No: 0 · Abstain: 0 (closed)
Acceptance requires at least two-thirds of the Yes and No votes to be Yes. Abstentions are excluded from that calculation. There are no secondary votes.
Voting is not open. The opening and closing dates will be announced on internals when voting starts, after the required cooldown and an Intent to Vote notice.
Implementation PR: https://github.com/php/php-src/pull/23941
Reference extension: https://github.com/prateekbhujel/php-terminal
The implementation includes PHPTs for creation, sizing, scripted-stream input, raw-mode leases, key decoding, timeouts, resize handling, UTF-8 fragments, line input, buffered and redirected streams, and secret input.
POSIX PTY tests cover multiple wrappers for one terminal, unrelated terminals, out-of-order lease release, cross-wrapper restoration, token lifetime, EOF, restoration after closing the original stream, and line reads from nonblocking terminal descriptors. Additional tests cover output-mode preservation, fork ownership, and acquisition/restoration failures.
Input-transition regressions cover retained UTF-8 bytes and CRLF handling across secret, key, and line reads, including split delimiters and preservation of line data. Timeout tests cover nonblocking reads and overall deadlines during input and escape-sequence processing.
Native Windows console checks use a separate harness that injects console input records to exercise key decoding, Unicode, repeat counts, secret input, line input, and mode restoration. Ordinary-stream tests exercise the byte-stream path and do not replace native console checks.
Current build and test results are available in the implementation PR.
The implementation is in ext/standard, alongside the existing Io code:
https://github.com/php/php-src/pull/23941
POSIX uses native terminal APIs and PHP's polling support. Windows uses the Console API.
Previous discussion:
Related work:
Earlier drafts considered a larger API. The following were left out:
Terminal constructor alongside the factoriesgetWidth() and getHeight() methodsTerminal and ModeToken classes, support scripted ordinary streams and no-op mode leases, require explicit-token restoreMode(): void, remove retained latest-token behavior, add Insert and ShiftTab, and clarify mode lifetime, input state, factory binding, and validation status.Terminal and ModeToken, rename the native implementations to SystemTerminal and SystemModeToken, add readLine(): ?string, and clarify token retention, EOF, argument errors, and POSIX partial-input behavior.null for absence/timeouts, add a timeout to readSecret(), and expand the raw-mode documentation.TerminalSize construction, error and timeout semantics, and shared raw-mode ownership.