# RFC: Io\Terminal

- Status: Under discussion
- Authors: Pratik Bhujel, prateekbhujelpb@gmail.com
- Date: 2026-09-27
- Version: 0.4
- Target: PHP 8.7
- Implementation: https://github.com/php/php-src/pull/23941
- Original: https://wiki.php.net/rfc/io_terminal
- Page: https://externals.io/rfc/io_terminal
- More for agents: search with https://externals.io/search.md?q={query} · RFCs at https://externals.io/rfc.md · JSON API at https://externals.io/api · documentation at https://externals.io/llms.txt

## Votes

### Implement Io\Terminal for PHP 8.7 as outlined in the RFC? (closed)

Yes: 0 · No: 0 · Abstain: 0

## Discussions on the mailing list

- [\[Discussion\] Native terminal helpers for PHP CLI](https://externals.io/message/131004.md) — started on 2026-05-24, 4 messages
- [\[PHP DEV\] \[Discussion\] Native terminal helpers for PHP CLI](https://externals.io/message/132523.md) — started on 2026-09-17, 2 messages
- [\[PHP DEV\] \[Discussion\] Native terminal helpers for PHP CLI](https://externals.io/message/132526.md) — started on 2026-09-17, 2 messages
- [\[PHP DEV\] \[Discussion\] Native terminal helpers for PHP CLI](https://externals.io/message/132530.md) — started on 2026-09-18, 6 messages
- [\[RFC\] Io\Terminal](https://externals.io/message/132663.md) — started on 2026-09-27, 24 messages

---

## Introduction

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
<?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";
}
```

## Proposal

The proposed API is:

```php
<?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.

### Creating a terminal

`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.

```php
$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.

### Terminal size

`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:

```php
$size = new Io\Terminal\TerminalSize(80, 24);
```

Both dimensions must be greater than zero; otherwise the constructor throws `ValueError`.

### Raw mode

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:

- Input is available without waiting for Enter.
- Typed input is not automatically echoed.
- The terminal's usual line buffering and editing are disabled.
- Ctrl+C and Ctrl+D are available as input rather than being consumed by the terminal's usual signal or end-of-input processing. A signal delivered independently of terminal input is unaffected.
- Acquiring raw mode does not change output post-processing. In particular, existing POSIX output settings are preserved, whether output post-processing was enabled or disabled.

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.

```php
$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:

```php
$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.

### Reading keys

`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 deadline
- zero: make a non-blocking attempt
- positive: wait up to that duration
- negative: throw `ValueError`

The 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.

### Reading lines

`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:

- POSIX terminals use the existing line discipline; the method does not switch them into raw mode.
- Windows consoles use native line input, with the previous console mode restored after the read.
- Ordinary PHP streams use the stream layer, preserving its buffering, blocking mode, and read-timeout behavior. Use a blocking stream when a complete line is required.

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.

### Reading secret input

`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`.

### Input state and sequential reads

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.

## Error Model

The return values for absence, EOF, and timeout are:

- `getSize()`: `null` when no usable size is available
- `readKey()`: `null` when no input result is available before the timeout
- `readLine()`: `null` on EOF before any line data
- `readSecret()`: `null` on timeout before submission
- `restoreMode($mode)`: normal completion with a `void` return after successful lease release

Operational 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.

## Backward Incompatible Changes

Existing PHP APIs are unchanged. The following names are added:

- `Io\Terminal\Terminal`
- `Io\Terminal\TerminalSize`
- `Io\Terminal\ModeToken`
- `Io\Terminal\TerminalException`
- `Io\Terminal\Key`

Userland 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.

## Proposed PHP Version(s)

PHP 8.7.

## RFC Impact

### To the Ecosystem

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.

```php
$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.

### To Existing Extensions

The implementation is in `ext/standard`. No existing extension API is removed or changed. The optional readline extension is unaffected.

### To SAPIs

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.

## Discussion Status

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:

- requires an explicit token for `restoreMode()` and changes its return type to `void`
- removes the terminal object's retained latest token
- allows ordinary streams for key and secret input, and gives them no-op raw-mode leases
- adds `Key::Insert` and `Key::ShiftTab`
- documents restoration retries, process ownership, output-mode preservation, factory binding, and per-instance input state

The 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.

## Future Scope

The following are outside this RFC. Some are available or have been explored in the reference extension:

- lower-level input events, including modifiers and key releases
- ANSI support and color-depth detection
- setting the terminal title
- a terminal bell
- terminal-size fallbacks using environment variables
- prompt rendering
- history and completion
- configurable key bindings
- multiline or rich line editing
- syntax highlighting
- timed or higher-level editable line input

These may be proposed separately.

## Voting Choices

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.

## Patches and Tests

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.

## Implementation

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.

## References

Previous discussion:

- <https://news-web.php.net/php.internals/131004>
- <https://news-web.php.net/php.internals/132523>
- <https://news-web.php.net/php.internals/132526>
- <https://news-web.php.net/php.internals/132530>
- <https://news-web.php.net/php.internals/132663>

Related work:

- <https://github.com/prateekbhujel/php-terminal>
- <https://github.com/php/php-src/pull/23941>
- <https://github.com/symfony/symfony/pull/66173>
- <https://github.com/symfony/symfony/pull/66271>
- <https://github.com/symfony/symfony/pull/66272>

## Rejected Features

Earlier drafts considered a larger API. The following were left out:

- procedural functions duplicating the object methods
- separate stdin, stdout, and stderr terminal objects
- separate native classes for stdio and supplied streams
- a public `Terminal` constructor alongside the factories
- separate `getWidth()` and `getHeight()` methods
- a complete portable line editor
- compatibility-only APIs from older ext-terminal releases
- the complete ext-terminal 1.x API

## Changelog

- 2026-10-09: Add the closed primary voting widget with Yes, No, and Abstain options. The proposed API is unchanged.
- 2026-10-06: Version 0.4. Use final native `Terminal` 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.
- 2026-09-30: Version 0.3. Rename the public contracts to `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.
- 2026-09-29: Add replaceable terminal and mode-token contracts, use `null` for absence/timeouts, add a timeout to `readSecret()`, and expand the raw-mode documentation.
- 2026-09-28: Address discussion feedback on API naming, `TerminalSize` construction, error and timeout semantics, and shared raw-mode ownership.
- 2026-09-28: Clarify scope, implementation status, test coverage, and open review questions.
- 2026-09-27: Initial draft.
