I would like to formally propose the Io\Terminal API for PHP 8.7:
This follows the earlier php-internals discussion about native
terminal helpers and the API work that came out of it.
The RFC proposes a small Io\Terminal API in ext/standard for terminal
sessions, native terminal size queries, raw mode with safe
restoration, normalized key input, and hidden input.
The existing reference implementation is ext-terminal, distributed
through PIE/Packagist as prateekbhujel/php-terminal:
A draft php-src implementation is available here:
It follows the reduced core API rather than copying the full
ext-terminal 1.x surface.
The proposed core API uses Time\Duration for timeout parameters, does
not expose a public constructor, and leaves compatibility aliases,
environment-based size fallbacks, and convenience APIs out of the
initial core proposal.
The earlier internals discussion and the Symfony Console/TUI
integration have helped narrow the proposal to the parts that require
native cross-platform support. Some of the earlier replies were
accidentally split into separate archive threads, so the RFC collects
the full prior discussion history in one place.
I would appreciate feedback on the proposed API and semantics before voting.
Thank you for the RFC. Some questions to start of the discussion:
1. Should TerminalSize have a regular constructor? It seems to be safe to allow constructing it from userland, e.g. for testing purposes.
2. It would help readability if the stub would indicate the non-serializability (and strict properties) instead of mentioning it in the prose. Basically you can just take the stub file from your PR and include it in the RFC.
3. Terminal::create() should probably be ::fromStdio() or similar.
4. I'm not sure about false vs Exception for the various methods. `enableRawMode()` should probably be Exception, for `readKey()` the `false` return is not explained. Also the behavior of what happens when a timeout strikes is not explained.
5. Should ModeToken have a property that points back to the corresponding Terminal? Overall the interaction between the destructors and ModeToken and restoreMode should be explained more. As an example, what happens if the Terminal object dies before the ModeToken object? What if I create two Terminal objects for the same terminal?
Thank you for the RFC. Some questions to start of the discussion:
Should TerminalSize have a regular constructor? It seems to be safe
to allow constructing it from userland, e.g. for testing purposes.
It would help readability if the stub would indicate the
non-serializability (and strict properties) instead of mentioning it in
the prose. Basically you can just take the stub file from your PR and
include it in the RFC.
Terminal::create() should probably be ::fromStdio() or similar.
I’m not sure about false vs Exception for the various methods. enableRawMode() should probably be Exception, for readKey() the false return is not explained. Also the behavior of what happens when
a timeout strikes is not explained.
Should ModeToken have a property that points back to the
corresponding Terminal? Overall the interaction between the destructors
and ModeToken and restoreMode should be explained more. As an example,
what happens if the Terminal object dies before the ModeToken object?
What if I create two Terminal objects for the same terminal?
Related to ModeToken, what about a refcount instead? One record per terminal, shared by all its tokens: the first enableRawMode() saves the original termios and dup()s the fd into the record, the next ones only increment a counter, and releasing any token decrements it. At zero, the original state is restored through the record’s own fd. That’s order-independent by construction and immune to fclose(). The RFC could then spec it in one sentence: raw mode stays on as long as one token for that terminal is alive, and the state from before the first token is restored when the last one goes away. Today it says a token “restores its saved terminal mode when it is destroyed”, which isn’t what happens out of order. If more modes are added later, the record can keep the set of active requests and recompute the target state from the original on each change.
Thanks both for the feedback. I went through the API and the
implementation again and updated the RFC and PR to reflect it.
For Tim's points:
- TerminalSize now has a normal public constructor, with positive
dimensions enforced.
- The RFC now shows the actual stub, including strict-properties /
non-serializable declarations.
- Terminal::create() has been renamed to Terminal::fromStdio().
- enableRawMode() now returns ModeToken and uses TerminalException for
operational/native failures.
- readKey() now has a defined contract: false means timeout/no
complete key before the deadline; non-TTY input, EOF and
native/polling failures are exceptions. The timeout and
sequenceTimeout behavior is now described explicitly in the RFC.
- ModeToken lifetime and restoreMode() behavior are now described in detail.
For the ownership question, I kept ModeToken opaque rather than adding
a public Terminal back-reference.
Nicolas, I also changed the implementation to the shared-per-terminal
model you suggested. The first active token captures the original mode
and owns the restoration descriptor/handle, subsequent tokens share
that state, and the original mode is restored when the last active
token is released. This makes release order irrelevant and also means
closing the original PHP stream does not prevent restoration.
I also reworked the terminal identity handling from the earlier
st_rdev-only approach so different descriptors for the same logical
terminal can coordinate without conflating unrelated PTYs.
The RFC has been updated here:
Implementation:
These are substantive API/semantic changes made during discussion, so
this reply is also the announcement of those RFC changes.
Thanks again for taking the time to review this. The
lifecycle/ownership feedback in particular made the model much
cleaner.
Best regards,
Pratik
On Mon, 28 Sep 2026 15:00:09 +0200, Nicolas Grekas
<nicolas.grekas+php@gmail.com> wrote:
Hi Pratik,
thanks for the RFC, PHP definitely needs native terminal support, calling stty is a workaround we've been carrying since way too long.
Le lun. 28 sept. 2026 à 11:49, Tim Düsterhus <tim@bastelstu.be> a écrit :
Hi
On 2026-09-27 19:19, Pratik Bhujel wrote:
> I would like to formally propose the Io\Terminal API for PHP 8.7:
Thank you for the RFC. Some questions to start of the discussion:
1. Should TerminalSize have a regular constructor? It seems to be safe
to allow constructing it from userland, e.g. for testing purposes.
2. It would help readability if the stub would indicate the
non-serializability (and strict properties) instead of mentioning it in
the prose. Basically you can just take the stub file from your PR and
include it in the RFC.
3. Terminal::create() should probably be ::fromStdio() or similar.
4. I'm not sure about false vs Exception for the various methods.
`enableRawMode()` should probably be Exception, for `readKey()` the
`false` return is not explained. Also the behavior of what happens when
a timeout strikes is not explained.
5. Should ModeToken have a property that points back to the
corresponding Terminal? Overall the interaction between the destructors
and ModeToken and restoreMode should be explained more. As an example,
what happens if the Terminal object dies before the ModeToken object?
What if I create two Terminal objects for the same terminal?
Related to ModeToken, what about a refcount instead? One record per terminal, shared by all its tokens: the first enableRawMode() saves the original termios and dup()s the fd into the record, the next ones only increment a counter, and releasing any token decrements it. At zero, the original state is restored through the record's own fd. That's order-independent by construction and immune to fclose(). The RFC could then spec it in one sentence: raw mode stays on as long as one token for that terminal is alive, and the state from before the first token is restored when the last one goes away. Today it says a token "restores its saved terminal mode when it is destroyed", which isn't what happens out of order. If more modes are added later, the record can keep the set of active requests and recompute the target state from the original on each change.
One quick follow-up after my earlier mail: I've now finished the
corresponding cleanup in the php-src PR as well.
Tim, I went through the implementation/test review comments and
addressed the remaining points there.
Nicolas, the shared raw-mode record and terminal identity changes are
now in place too, including restoration through the record's own
descriptor and keeping unrelated PTYs separate.
The RFC text and the implementation should now describe the same
lifetime/error semantics:
I also fixed the Linux PTY EIO test expectation on the current head.
I don't want to keep adding noise to the list, so I'll leave it here
unless I've missed something. Thanks again for the detailed review.
Best,
Pratik
On Mon, 28 Sep 2026 15:00:09 +0200, Nicolas Grekas
<nicolas.grekas+php@gmail.com> wrote:
Hi Pratik,
thanks for the RFC, PHP definitely needs native terminal support, calling stty is a workaround we've been carrying since way too long.
Le lun. 28 sept. 2026 à 11:49, Tim Düsterhus <tim@bastelstu.be> a écrit :
Hi
On 2026-09-27 19:19, Pratik Bhujel wrote:
> I would like to formally propose the Io\Terminal API for PHP 8.7:
Thank you for the RFC. Some questions to start of the discussion:
1. Should TerminalSize have a regular constructor? It seems to be safe
to allow constructing it from userland, e.g. for testing purposes.
2. It would help readability if the stub would indicate the
non-serializability (and strict properties) instead of mentioning it in
the prose. Basically you can just take the stub file from your PR and
include it in the RFC.
3. Terminal::create() should probably be ::fromStdio() or similar.
4. I'm not sure about false vs Exception for the various methods.
`enableRawMode()` should probably be Exception, for `readKey()` the
`false` return is not explained. Also the behavior of what happens when
a timeout strikes is not explained.
5. Should ModeToken have a property that points back to the
corresponding Terminal? Overall the interaction between the destructors
and ModeToken and restoreMode should be explained more. As an example,
what happens if the Terminal object dies before the ModeToken object?
What if I create two Terminal objects for the same terminal?
Related to ModeToken, what about a refcount instead? One record per terminal, shared by all its tokens: the first enableRawMode() saves the original termios and dup()s the fd into the record, the next ones only increment a counter, and releasing any token decrements it. At zero, the original state is restored through the record's own fd. That's order-independent by construction and immune to fclose(). The RFC could then spec it in one sentence: raw mode stays on as long as one token for that terminal is alive, and the state from before the first token is restored when the last one goes away. Today it says a token "restores its saved terminal mode when it is destroyed", which isn't what happens out of order. If more modes are added later, the record can keep the set of active requests and recompute the target state from the original on each change.
On Mon, Sep 28, 2026, at 12:17 PM, Pratik Bhujel wrote:
Hi Tim, Nicolas,
One quick follow-up after my earlier mail: I've now finished the
corresponding cleanup in the php-src PR as well.
Tim, I went through the implementation/test review comments and
addressed the remaining points there.
Nicolas, the shared raw-mode record and terminal identity changes are
now in place too, including restoration through the record's own
descriptor and keeping unrelated PTYs separate.
The RFC text and the implementation should now describe the same
lifetime/error semantics:
I also fixed the Linux PTY EIO test expectation on the current head.
I don't want to keep adding noise to the list, so I'll leave it here
unless I've missed something. Thanks again for the detailed review.
Best,
Pratik
Please remember to bottom-post.
I am in favor of this RFC in general. It addresses the sort of problem that does belong in stdlib.
- getSize()'s error return is null. Not false. False-on-error is an anti-pattern we should be exterminating with extreme prejudice. Or possibly an exception, but not false.
- As I'm not familiar with the underlying OS tools... what is raw mode? That seems to be just glossed over. It looks like the only useful API method (readKey() ) requires going into raw mode, so I wonder what its purpose is.
- That said, raw mode looks like a textbook case for a context manager.
- Again, readKey() should return null, not false, for all the same reasons.
- Why does readSecret() not need the same duration/timeout controls as readKey()?
- I understand all of the usual arguments for making the Terminal class final. However, it also has no interface. That means it's basically impossible to mock for testing purposes. That strikes me as a problem, because any IO boundary should be mockable. I don't know that multiple non-testing implementations makes sense (maybe alternatives to the static constructors?), but we do need some straightforward mechanism to mock a Terminal object. (I assume someone is going to respond with "it's an implementation detail of something else," which is only partially true; I don't want to have to create a pass-through wrapper for something just for testing purposes, and even then, it would be the same API in name only, since it cannot share a type. That hurts interoperability.)
I am in favor of this RFC in general. It addresses the sort of problem
that does belong in stdlib.
- getSize()'s error return is null. Not false. False-on-error is an
anti-pattern we should be exterminating with extreme prejudice. Or
possibly an exception, but not false.
That makes sense. The original extension used false more broadly, but
for the core API the operational failures are already separate and
throw TerminalException.
I've changed getSize() to return ?TerminalSize, with null meaning that
a usable native size couldn't be obtained.
- As I'm not familiar with the underlying OS tools... what is raw mode?
That seems to be just glossed over. It looks like the only useful API
method (readKey() ) requires going into raw mode, so I wonder what its
purpose is.
Fair point. The RFC was assuming too much terminal background there.
In canonical mode the terminal normally buffers input until a line is
complete, may echo it, and handles some control characters itself. Raw
mode turns off that line-oriented processing so the application can
react to individual key presses and terminal sequences directly.
One thing that also wasn't clear is that callers don't need to call
enableRawMode() before readKey() or readSecret(). Both handle the
temporary mode change internally.
enableRawMode() is there for longer-running interactive code that
wants to keep the terminal raw across multiple reads/redraws.
I've clarified that in the RFC.
- That said, raw mode looks like a textbook case for a context manager.
Conceptually, yes. That's what I was trying to model with ModeToken:
it represents the active lease, and restoreMode() gives an explicit
way to release it in a try/finally.
PHP doesn't have a general language-level context-manager construct,
and I didn't want to add a terminal-specific callback abstraction just
for this, so I kept the primitive explicit.
- Again, readKey() should return null, not false, for all the same reasons.
Changed that as well. readKey() now returns Key|string|null, with null
meaning the timeout expired before a complete input value was
available. Operational failures still throw TerminalException.
- Why does readSecret() not need the same duration/timeout controls as
readKey()?
There wasn't a good reason for the overall timeout to be missing, so
readSecret() now accepts an optional Time\Duration too.
It returns null when that timeout expires, while "" still means the
user actually submitted an empty secret.
I didn't add sequenceTimeout to readSecret(). Unlike readKey(), it
doesn't expose terminal escape sequences to the caller, so that
ambiguity handling can stay internal.
- I understand all of the usual arguments for making the Terminal class
final. However, it also has no interface. That means it's basically
impossible to mock for testing purposes. That strikes me as a problem,
because any IO boundary should be mockable. I don't know that multiple
non-testing implementations makes sense (maybe alternatives to the
static constructors?), but we do need some straightforward mechanism to
mock a Terminal object. [...]
I agree with the testing concern. I've added TerminalInterface while
keeping the native Terminal final, so application and library code can
type against something that can be replaced by a userland fake.
I also added ModeTokenInterface for fake implementations. The native
Terminal still only accepts a native token belonging to the same
logical terminal; foreign, stale, or unrelated tokens are rejected
with ValueError.
I've updated the RFC to match these changes and expanded the
raw-mode/testability sections as well.
On Mon, Sep 28, 2026, at 11:13 PM, Pratik Bhujel wrote:
On Mon, Sep 28, 2026, Larry Garfield wrote:
- As I'm not familiar with the underlying OS tools... what is raw mode?
That seems to be just glossed over. It looks like the only useful API
method (readKey() ) requires going into raw mode, so I wonder what its
purpose is.
Fair point. The RFC was assuming too much terminal background there.
In canonical mode the terminal normally buffers input until a line is
complete, may echo it, and handles some control characters itself. Raw
mode turns off that line-oriented processing so the application can
react to individual key presses and terminal sequences directly.
One thing that also wasn't clear is that callers don't need to call
enableRawMode() before readKey() or readSecret(). Both handle the
temporary mode change internally.
enableRawMode() is there for longer-running interactive code that
wants to keep the terminal raw across multiple reads/redraws.
I've clarified that in the RFC.
Thanks, that does make it clearer! So the reason to use raw mode yourself would be, for instance, for a game where you're capturing the arrow keys and WASD, or something like that? (Concrete examples would help.).
This also makes me think that a readLine() method makes sense in this base tool, not at a higher level. (I haven't done much console-GUI works so I am not familiar with the typical patterns, but it seems like the natural complement to readKey().)
- That said, raw mode looks like a textbook case for a context manager.
Conceptually, yes. That's what I was trying to model with ModeToken:
it represents the active lease, and restoreMode() gives an explicit
way to release it in a try/finally.
PHP doesn't have a general language-level context-manager construct,
and I didn't want to add a terminal-specific callback abstraction just
for this, so I kept the primitive explicit.
Yes, that's more of an aside for the audience. Arnaud and I have an RFC out (currently on hold) for context managers, and this would be another very good use case for them.
- I understand all of the usual arguments for making the Terminal class
final. However, it also has no interface. That means it's basically
impossible to mock for testing purposes. That strikes me as a problem,
because any IO boundary should be mockable. I don't know that multiple
non-testing implementations makes sense (maybe alternatives to the
static constructors?), but we do need some straightforward mechanism to
mock a Terminal object. [...]
I agree with the testing concern. I've added TerminalInterface while
keeping the native Terminal final, so application and library code can
type against something that can be replaced by a userland fake.
I also added ModeTokenInterface for fake implementations. The native
Terminal still only accepts a native token belonging to the same
logical terminal; foreign, stale, or unrelated tokens are rejected
with ValueError.
Conventions for Internals say to not use a *Interface suffix. It's unnecessary. I would suggest either
interface Terminal {
public function readKey();
// ...
}
class SystemTerminal implements Terminal {
public static function fromStdIo(): self {}
public static function fromStreams(): self {}
}
or possibly:
class StdIoTerminal implements Terminal {
// .. No factories.
}
class StreamsTerminal implements Terminal {
public function __construct($in, $out = null) {}
}
For ModeToken, I'm not sure if it makes sense to have separate classes for each core terminal. It's just an opaque value object, really, so I don't know what pattern we'd want here.
Thanks, that does make it clearer! So the reason to use raw mode yourself would be, for instance, for a game where you're capturing the arrow keys and WASD, or something like that? (Concrete examples would help.)
Yep, exactly. Games are one example, but interactive TUIs are probably
the more common case here: menus, search/select UIs, editors, anything
that needs to react to individual key presses and redraw without
waiting for Enter.
This also makes me think that a readLine() method makes sense in this base tool, not at a higher level.
I did think about that. My hesitation is that normal PHP streams
already handle line-oriented reads pretty well, while this proposal is
mainly trying to cover the terminal-specific bits that aren't exposed
portably today.
So for the first core version I'd rather keep readLine() out instead
of duplicating fgets()-style behavior. The extension can still be
useful as a backport/reference implementation and carry convenience
APIs that don't necessarily need to be in core.
Conventions for Internals say to not use a *Interface suffix. It's unnecessary.
Thanks, I hadn't considered that core naming convention when I added
the interfaces.
I used the *Interface names mainly so I could add the mockable
contract without renaming the existing Terminal and ModeToken classes.
I see the naming issue though. I want to check whether changing the
class/interface split is worth the additional public API churn before
changing it again.
For ModeToken, I'm not sure if it makes sense to have separate classes for each core terminal. It's just an opaque value object, really, so I don't know what pattern we'd want here.
Yeah, I agree. I don't see much value in separate token classes
either. The main thing I need to preserve is validating native tokens
against the logical terminal they belong to, while still keeping
userland implementations mockable.
Sorry, I sent this update separately by mistake and left the list off
Cc. Posting it here to keep the discussion in the RFC thread.
I've updated Io\Terminal to version 0.3 and pushed the corresponding
implementation changes:
Thanks for the feedback. I revisited line input and naming after my
last reply, and wanted to explain the decisions together here.
1. Naming and testability
Terminal and ModeToken are now the interfaces, with final
SystemTerminal and SystemModeToken as the native implementations. This
follows the naming convention Larry pointed out and lets libraries use
userland implementations for testing.
I kept one native terminal class with fromStdio() and fromStreams().
Selecting the streams does not need a separate public class when the
operations are otherwise the same. There is also one native token
class.
TerminalSize has a public constructor requiring positive dimensions,
so tests can construct sizes directly. The RFC includes the actual
stub and its strict-properties and non-serializable annotations.
2. Why readLine() is included
My earlier reasoning focused too much on the overlap with fgets().
Having line input on Terminal gives libraries the same interface for
keys, lines and hidden input, including native Windows console
handling and coordination with raw-mode leases.
readLine() removes LF/CRLF, preserves other whitespace, returns null
on immediate EOF, and returns a final unterminated line at EOF. POSIX
uses the existing line discipline; Windows uses native console line
input and restores the previous mode. Redirected PHP streams retain
their buffering, blocking and read-timeout behavior.
It throws TerminalException while a managed raw-mode lease is active
for that terminal, including one acquired through another wrapper.
Silently overriding the mode would interfere with the code holding
that lease.
I kept the timeout parameter out because enforcing a portable
whole-line deadline while preserving native editing would require more
control over the editing loop. History, completion and richer editing
remain userland concerns.
3. Raw-mode ownership and token lifetime
The implementation uses the shared record model Nicolas suggested. The
first lease saves the original mode and holds an independent
restoration descriptor or handle. Later leases share that record, and
the last release restores the saved mode, regardless of release order.
Closing the original stream does not by itself prevent restoration.
Wrappers for the same logical terminal coordinate, while unrelated
PTYs remain separate. The token is opaque and can outlive its wrapper.
SystemTerminal retains its latest token, so unsetting only the
caller's variable may leave that lease active.
Destruction and request shutdown attempt cleanup; explicit
restoreMode() reports restoration failures. Native restoration rejects
foreign, consumed or unrelated tokens with ValueError. Without an
argument, restoreMode() returns false when the wrapper has no active
retained token, including one already released through another
wrapper.
4. When explicit raw mode is useful
Games, arrow-key menus, search/select UIs and editors need input
without waiting for Enter. An explicit lease keeps the terminal raw
between reads and redraws.
readKey() and readSecret() manage their own temporary mode, so
individual reads do not require enableRawMode() first. Longer sessions
use a lease with try/finally and restoreMode().
5. Return values, timeouts and secret input
getSize() returns null when no usable size is available. Key and
secret reads return null on timeout. Operational failures, key-input
EOF and secret cancellation throw TerminalException.
readSecret() accepts an overall Time\Duration timeout. Echo is
disabled during entry; the method does not display the typed
characters. An empty submitted secret returns "", while a timeout
returns null. Escape, Ctrl+C and Ctrl+D cancel with TerminalException.
For timeout arguments, null means no finite deadline, zero makes a
non-blocking attempt, and negative durations throw ValueError.
readSecret() keeps escape-sequence handling internal.
6. Partial input and regression coverage
The RFC now distinguishes incomplete UTF-8 bytes retained across
key-read timeouts from incomplete POSIX escape sequences, which may
return a consumed prefix. The POSIX sequence ambiguity window cannot
extend the overall timeout.
Regression tests cover cross-wrapper restoration and subsequent line
reads, token lifetime, unrelated PTYs, and native POSIX line reads
encountering EAGAIN/EWOULDBLOCK. Temporary unavailability now causes
those native line reads to wait for input rather than being treated as
EOF.
I'd like to keep the feature set steady now and give version 0.3 the
full 14-day cooldown. Once that has elapsed and substantive discussion
is settled, I'll post a separate Intent to Vote. Implementation review
and platform testing can continue alongside that.
- I understand all of the usual arguments for making the Terminal class final. However, it also has no interface. That means it's basically impossible to mock for testing purposes. That strikes me as a problem, because any IO boundary should be mockable. I don't know that multiple non-testing implementations makes sense (maybe alternatives to the static constructors?), but we do need some straightforward mechanism to mock a Terminal object. (I assume someone is going to respond with "it's an implementation detail of something else," which is only partially true; I don't want to have to create a pass-through wrapper for something just for testing purposes, and even then, it would be the same API in name only, since it cannot share a type. That hurts interoperability.)
I very strongly disagree with the interface suggestions for the exact “implementation detail” reason you mentioned and the introduction of the interface has made the API much worse:
The (System)Terminal class *is* an implementation detail of something else and it is only exercised as part of the glue code within an integration test. It is not the IO boundary, the “TTY stream” you provide to the Terminal is. You need the TTY stream separately anyway, because the (System)Terminal does not provide any mechanism to write to the output (and the underlying stream is not exposed either). In your tests you would then attach an appropriate stream to the terminal (e.g. using `proc_open()` with `pty` descriptors, which is what the RFC’s own implementation already uses for testing).
This is very much like the final `Random\Randomizer` where you would have unit tests for the “number consumer”, passing hardcoded values. And then you have an integration test where you provide a class implementing the `Random\Engine` interface as the IO boundary. How the glue code turns a “Random\Engine” into a “random number” that is passed to the unit-tested “number consumer” is an implementation detail that is not worth testing.
There are additional indicators that the `SystemTerminal` class is more akin to a “helper” class:
1. Large parts of the `Terminal`’s API surface don’t need to be instance methods, because they don’t really care about the object state (beyond the stream). They could also be bare functions and they were in the initial pre-RFC implementation. Having them as instance methods makes the API a little more convenient to use and enables the RAII reset.
2. The interface’s contract is “What `SystemTerminal` does”. As you say yourself, you are not sure if there can be multiple non-testing implementations, and I can't think of any either: The `Terminal` interface does not define an “abstract concept” with multiple equally legal implementations.
This is contrary to, say, Time\Clock where the Clock *is* the IO boundary and you can have SystemClock, GpsClock (when a GPS receiver is attached to your computer), HttpClock (fetching an HTTP service to obtain the current time, not useful, but theoretically meaningful), ….
3. The interface is humongous and not well-defined: Terminals expose a broad API surface and this is reflected by the `SystemTerminal` class and the RFC even cut out some of the methods that are there in the pre-RFC implementation. It is likely that the API surface will grow in future PHP versions with additional helper functionality. This also means that the interface needs to grow, which is an obvious breaking change. Or new non-well-defined interfaces need to be added, which is bad API design. In fact v0.3 of the RFC added a `readLine()` method which (at least on POSIX) is effectively redundant with `fgets()`, but allows consistent access to the “input side” of the terminal. It is not unlikely that a future PHP version might want to add `readUntilEof()` or similar.
4. The interface being a direct mapping of the Terminal API surface also resulted in the `ModeToken` interface being added, which is an interface for something that is an opaque “token” (value) object. And then due to interface constraints / the lack of generics, the `Terminal` interface requires any `Terminal` to accept any `ModeToken` in the signature of `restoreMode()` just to validate that it is the `ModeToken` implementation that belongs to the `Terminal`. The RFC specifies a `ValueError` here, but it really is a `TypeError` that cannot be expressed by the type system.
5. This has also resulted in the naming weirdness: The obvious name is “Terminal”, the “System” part in “SystemTerminal” carries no additional information.
On Thu, Oct 1, 2026, at 10:49 AM, Tim Düsterhus wrote:
Hi
On 2026-09-29 00:18, Larry Garfield wrote:
- I understand all of the usual arguments for making the Terminal class
final. However, it also has no interface. That means it's basically
impossible to mock for testing purposes. That strikes me as a problem,
because any IO boundary should be mockable. I don't know that multiple
non-testing implementations makes sense (maybe alternatives to the
static constructors?), but we do need some straightforward mechanism to
mock a Terminal object. (I assume someone is going to respond with
"it's an implementation detail of something else," which is only
partially true; I don't want to have to create a pass-through wrapper
for something just for testing purposes, and even then, it would be the
same API in name only, since it cannot share a type. That hurts
interoperability.)
I very strongly disagree with the interface suggestions for the exact
“implementation detail” reason you mentioned and the introduction of the
interface has made the API much worse:
The (System)Terminal class *is* an implementation detail of something
else and it is only exercised as part of the glue code within an
integration test. It is not the IO boundary, the “TTY stream” you
provide to the Terminal is. You need the TTY stream separately anyway,
because the (System)Terminal does not provide any mechanism to write to
the output (and the underlying stream is not exposed either). In your
tests you would then attach an appropriate stream to the terminal (e.g.
using `proc_open()` with `pty` descriptors, which is what the RFC’s own
implementation already uses for testing).
This is very much like the final `Random\Randomizer` where you would
have unit tests for the “number consumer”, passing hardcoded values. And
then you have an integration test where you provide a class implementing
the `Random\Engine` interface as the IO boundary. How the glue code
turns a “Random\Engine” into a “random number” that is passed to the
unit-tested “number consumer” is an implementation detail that is not
worth testing.
There are additional indicators that the `SystemTerminal` class is more
akin to a “helper” class:
1. Large parts of the `Terminal`’s API surface don’t need to be instance
methods, because they don’t really care about the object state (beyond
the stream). They could also be bare functions and they were in the
initial pre-RFC implementation. Having them as instance methods makes
the API a little more convenient to use and enables the RAII reset.
2. The interface’s contract is “What `SystemTerminal` does”. As you say
yourself, you are not sure if there can be multiple non-testing
implementations, and I can't think of any either: The `Terminal`
interface does not define an “abstract concept” with multiple equally
legal implementations.
This is contrary to, say, Time\Clock where the Clock *is* the IO
boundary and you can have SystemClock, GpsClock (when a GPS receiver is
attached to your computer), HttpClock (fetching an HTTP service to
obtain the current time, not useful, but theoretically meaningful), ….
3. The interface is humongous and not well-defined: Terminals expose a
broad API surface and this is reflected by the `SystemTerminal` class
and the RFC even cut out some of the methods that are there in the
pre-RFC implementation. It is likely that the API surface will grow in
future PHP versions with additional helper functionality. This also
means that the interface needs to grow, which is an obvious breaking
change. Or new non-well-defined interfaces need to be added, which is
bad API design. In fact v0.3 of the RFC added a `readLine()` method
which (at least on POSIX) is effectively redundant with `fgets()`, but
allows consistent access to the “input side” of the terminal. It is not
unlikely that a future PHP version might want to add `readUntilEof()` or
similar.
4. The interface being a direct mapping of the Terminal API surface also
resulted in the `ModeToken` interface being added, which is an interface
for something that is an opaque “token” (value) object. And then due to
interface constraints / the lack of generics, the `Terminal` interface
requires any `Terminal` to accept any `ModeToken` in the signature of
`restoreMode()` just to validate that it is the `ModeToken`
implementation that belongs to the `Terminal`. The RFC specifies a
`ValueError` here, but it really is a `TypeError` that cannot be
expressed by the type system.
5. This has also resulted in the naming weirdness: The obvious name is
“Terminal”, the “System” part in “SystemTerminal” carries no additional
information.
Best regards
Tim Düsterhus
My main concern is being able to mock the terminal in order to effectively test code that uses the terminal, without putting a proprietary thin wrapper around it (which largely defeats the purpose of having a good API in core). Interfaces are the standard way of doing that. If you have a suggestion for a better way, I'm happy to see it.
- I understand all of the usual arguments for making the Terminal class final. However, it also has no interface. That means it's basically impossible to mock for testing purposes. That strikes me as a problem, because any IO boundary should be mockable. I don't know that multiple non-testing implementations makes sense (maybe alternatives to the static constructors?), but we do need some straightforward mechanism to mock a Terminal object. (I assume someone is going to respond with "it's an implementation detail of something else," which is only partially true; I don't want to have to create a pass-through wrapper for something just for testing purposes, and even then, it would be the same API in name only, since it cannot share a type. That hurts interoperability.)
I very strongly disagree with the interface suggestions for the exact “implementation detail” reason you mentioned and the introduction of the interface has made the API much worse:
The (System)Terminal class *is* an implementation detail of something else and it is only exercised as part of the glue code within an integration test. It is not the IO boundary, the “TTY stream” you provide to the Terminal is. You need the TTY stream separately anyway, because the (System)Terminal does not provide any mechanism to write to the output (and the underlying stream is not exposed either). In your tests you would then attach an appropriate stream to the terminal (e.g. using `proc_open()` with `pty` descriptors, which is what the RFC’s own implementation already uses for testing).
This is very much like the final `Random\Randomizer` where you would have unit tests for the “number consumer”, passing hardcoded values. And then you have an integration test where you provide a class implementing the `Random\Engine` interface as the IO boundary. How the glue code turns a “Random\Engine” into a “random number” that is passed to the unit-tested “number consumer” is an implementation detail that is not worth testing.
There are additional indicators that the `SystemTerminal` class is more akin to a “helper” class:
1. Large parts of the `Terminal`’s API surface don’t need to be instance methods, because they don’t really care about the object state (beyond the stream). They could also be bare functions and they were in the initial pre-RFC implementation. Having them as instance methods makes the API a little more convenient to use and enables the RAII reset.
2. The interface’s contract is “What `SystemTerminal` does”. As you say yourself, you are not sure if there can be multiple non-testing implementations, and I can't think of any either: The `Terminal` interface does not define an “abstract concept” with multiple equally legal implementations.
This is contrary to, say, Time\Clock where the Clock *is* the IO boundary and you can have SystemClock, GpsClock (when a GPS receiver is attached to your computer), HttpClock (fetching an HTTP service to obtain the current time, not useful, but theoretically meaningful), ….
3. The interface is humongous and not well-defined: Terminals expose a broad API surface and this is reflected by the `SystemTerminal` class and the RFC even cut out some of the methods that are there in the pre-RFC implementation. It is likely that the API surface will grow in future PHP versions with additional helper functionality. This also means that the interface needs to grow, which is an obvious breaking change. Or new non-well-defined interfaces need to be added, which is bad API design. In fact v0.3 of the RFC added a `readLine()` method which (at least on POSIX) is effectively redundant with `fgets()`, but allows consistent access to the “input side” of the terminal. It is not unlikely that a future PHP version might want to add `readUntilEof()` or similar.
4. The interface being a direct mapping of the Terminal API surface also resulted in the `ModeToken` interface being added, which is an interface for something that is an opaque “token” (value) object. And then due to interface constraints / the lack of generics, the `Terminal` interface requires any `Terminal` to accept any `ModeToken` in the signature of `restoreMode()` just to validate that it is the `ModeToken` implementation that belongs to the `Terminal`. The RFC specifies a `ValueError` here, but it really is a `TypeError` that cannot be expressed by the type system.
5. This has also resulted in the naming weirdness: The obvious name is “Terminal”, the “System” part in “SystemTerminal” carries no additional information.
Best regards
Tim Düsterhus
Well laid out Tim,
the I/O boundary comes from the streams passed to fromStreams().
For any mocking purposes / different terminals, it's a matter of composing the stream. We just need to make sure that it's actually possible to construct such a tty in tests for example, which can intercept the events / set state for size etc..
Terminal (yes, SystemTerminal shouldn't be a thing) is just a class which takes stuff from the stream and outputs it. There's no IO operations in itself (from userland perspective), only on the resource, which is the actual underlying entity forwarding IO operations to the kernel.
My main concern is being able to mock the terminal in order to effectively test code that uses the terminal, without putting a proprietary thin wrapper around it (which largely defeats the purpose of having a good API in core).
It is not clear to me what you mean by “proprietary” here. As I had mentioned in my email, the RFC’s own PR is tested against processes spawned using `proc_open()` with `pty` descriptors. The tests are naturally BSD-licensed, just like PHP itself is (since PHP 8.6).
In your test you would create a process that emits scripted output (possibly in response to some input), just like you would in the “mocked interface implementation” and then pass the PTY input/output streams to whatever you want to test, which will then call `(System)Terminal::fromStreams()` using them as parameters. The logic under test reads inputs from the Terminal instance and writes output using `fwrite()`. The subprocess will also enable you to properly model concurrency, delay and timing in IO processing. Terminal interactions in the real world are not synchronous either: The terminal logic relies on timing to distinguish escape sequences from individual characters (that's what the `$sequenceTimeout` is for) and the user might already provide additional input while your application is still busy rendering output and not yet expecting additional data.
Interfaces are the standard way of doing that. If you have a suggestion for a better way, I'm happy to see it.
My email included 5 arguments as to why an interface is the wrong design here. Do you plan to engage with those?
On Fri, Oct 2, 2026, at 10:59 AM, Tim Düsterhus wrote:
Hi
On 2026-10-02 16:19, Larry Garfield wrote:
My main concern is being able to mock the terminal in order to
effectively test code that uses the terminal, without putting a
proprietary thin wrapper around it (which largely defeats the purpose
of having a good API in core).
It is not clear to me what you mean by “proprietary” here. As I had
mentioned in my email, the RFC’s own PR is tested against processes
spawned using `proc_open()` with `pty` descriptors. The tests are
naturally BSD-licensed, just like PHP itself is (since PHP 8.6).
I didn't mean proprietary as in license. I mean, for example, a SymfonyTerminal that wraps a Terminal so that SymfonyTerminal can be mocked. Which of course is different than LaravelTerminal. That would be a bad place to end up, and we should avoid that. ("Wrap 3rd party code so you can mock it" is a common recommendation, though as in this case it can lead to other problems.)
In your test you would create a process that emits scripted output
(possibly in response to some input), just like you would in the “mocked
interface implementation” and then pass the PTY input/output streams to
whatever you want to test, which will then call
`(System)Terminal::fromStreams()` using them as parameters. The logic
under test reads inputs from the Terminal instance and writes output
using `fwrite()`. The subprocess will also enable you to properly model
concurrency, delay and timing in IO processing. Terminal interactions in
the real world are not synchronous either: The terminal logic relies on
timing to distinguish escape sequences from individual characters
(that's what the `$sequenceTimeout` is for) and the user might already
provide additional input while your application is still busy rendering
output and not yet expecting additional data.
Interfaces are the standard way of doing that. If you have a
suggestion for a better way, I'm happy to see it.
My email included 5 arguments as to why an interface is the wrong design
here. Do you plan to engage with those?
No, because I am not advocating for interfaces. I am advocating for a clean and obvious mocking/testing mechanism, for which interfaces are a common solution. I am not wedded to interfaces as the solution, just that there is a reliable one that is self-evident (and/or documented). That is, my invitation to suggest a better way was in no way factious or snarky.
If I understand what you and Bob (thanks Bob) are suggesting, one would do something like:
Is that correct? Pratik, are you able to confirm (via tests) that this would work as a testing approach, including for key and secret reads?
If so, then I agree the interfaces become unnecessary, and the above should be included in the RFC as the recommended testing approach.
Although, it occurs to me while typing the above, the Terminal accepts an output stream, but doesn't appear to have any output API. When is the output stream even used? Should it be removed, or a print() (or similar) API added?
However for `readKey()` et al you need a “PTY” handle, so you would, as I mentioned in both of my replies, spawn sub-processes with stdin / stdout being of type `pty` in the `descriptor_spec` (this incidentally doesn't seem to be documented yet, except for the user comments) and appropriate environment variables. Spawning the side using the `Terminal` in the sub-process avoids weird inverted logic with regard to which descriptor is the logical input and which one is the logical output: Writing to the input handle emulates keyboard input and you can assert on whatever you get back on the output handle:
<?php
use Io\Terminal\SystemTerminal;
function app(): void
{
$terminal = SystemTerminal::fromStdio();
// The mode is kept on the terminal, we only need the token
// for explicit resets.
(void)$terminal->enableRawMode();
// Synchronization point
echo "test ready\n";
$key = $terminal->readKey();
var_dump($key);
// Simulate timing / computation that happens.
sleep(1);
// Wait for the synchronization point that confirms the application is up and running
// and accepting keyboard input. In the real world this happens through the user
// observing the output (which is slower than the application start).
fgets($pipes[1]);
// And now press the “Up” arrow.
fwrite($pipes[0], "\e[A");
echo fgets($pipes[1]);
// Write the password in anticipation of the prompt (which is delayed by computation,
// simulating user behavior).
fwrite($pipes[0], "hunter2\n");
// Assert the prompt and output confirming the password input.
$prompt = trim(fgets($pipes[1]));
assert($prompt === "Password?");
var_dump(trim(fgets($pipes[1])));
Spawning a process is comparatively heavy, but “testing the IO glue code” is integration test territory, the business logic - e.g. the actual password verification - is already tested independently as part of a unit test.
If so, then I agree the interfaces become unnecessary, and the above should be included in the RFC as the recommended testing approach.
Although, it occurs to me while typing the above, the Terminal accepts an output stream, but doesn't appear to have any output API. When is the output stream even used? Should it be removed, or a print() (or similar) API added?
The output stream is used to determine the terminal size - which makes sense, since you want to know the size of what you're writing to. For output you can just `fwrite()` to the handle directly, but having some output functionality makes sense to me as future scope (e.g. a function to print some text with ANSI color escapes, which may be what's meant by “ANSI support” in the future scope).
I also checked empty lines, immediate EOF and a final unterminated line. Those behave as specified.
For readKey() and readSecret(), ordinary streams like php://memory or php://temp do not work, and are intentionally rejected because those operations require an actual terminal device. For example:
$t->readKey();
// Io\Terminal\TerminalException: Failed to read key: input stream is not a terminal
On POSIX, Tim’s proc_open() + PTY example is exactly the right kind of test for this. It gives the code a real terminal while still letting the test script the input from pure PHP.
I also checked Windows because I did not want to assume the POSIX PTY mechanism translated there. It does not directly, since proc_open() on Windows does not provide the same PTY descriptor. I built the current branch on Windows 11 ARM64 and exercised the native console path instead. The PHP side was just:
A small harness attached to a real Windows console injected KEY_EVENT records into CONIN$ with WriteConsoleInputW(). Injecting Up produced:
enum(Io\Terminal\Key::Up)
I also checked Down, Left, Right, Enter, Tab, Backspace, Escape, F1, ordinary characters, repeat counts and Unicode including a UTF-16 surrogate pair. readSecret() was exercised through the same native console path for normal input, empty input, backspace, Unicode, Escape/Ctrl+C cancellation (which throws TerminalException) and zero-timeout return (which returns null). readLine() was exercised through the native ReadConsoleW() path as well. The ARM64 build needed an unrelated local Zend SIMD compiler workaround, but I did not change Io\Terminal for these tests.
So the direct answer is yes for line input, but with a platform distinction for interactive keys. Ordinary stream behavior can be scripted with normal PHP streams. Terminal-specific behavior needs a real terminal. On POSIX that is conveniently available through a PTY in pure PHP, as Tim showed. On Windows the native implementation is testable too, but the equivalent test currently needs a real console/native helper rather than the same PTY recipe. That means Tim and Bob’s point about the stream/terminal being the underlying I/O boundary holds, while Larry’s concern about a straightforward cross-platform testing seam is still relevant for Windows code that directly consumes readKey().
Although, it occurs to me while typing the above, the Terminal accepts
an output stream, but doesn’t appear to have any output API. When is
the output stream even used? Should it be removed, or a print() (or
similar) API added?
I checked this in the implementation. The output stream is not unused. getSize() queries the output side for terminal dimensions. readKey() also checks the output side for size changes so it can return Key::Resize, and on Windows a WINDOW_BUFFER_SIZE_EVENT causes the output side to be queried again for the current dimensions. So I don’t think the output stream should be removed. I also don’t think we need print() or write() just to justify it; actual application output can continue to use fwrite()/echo and normal stream APIs.
On the interface question, Tim’s objections to the current Terminal/ModeToken shape still make sense to me, especially the ModeToken case where a userland token can satisfy the interface but cannot actually be restored by the native terminal. At the same time, after checking Windows I don’t think I can honestly say that php://memory or stream injection alone makes the interface unnecessary everywhere. It clearly solves readLine(), and the PTY gives us a good POSIX integration seam, but Windows userland code directly consuming readKey() does not have the same pure-PHP PTY seam today.
I have not changed the RFC or implementation yet. I would like to settle this point before making another API change, so that the RFC can go into the next stage with this part resolved rather than carrying the disagreement forward. The remaining question for me is what testing seam we want cross-platform userland code which directly consumes readKey() to have, without keeping the ModeToken problem Tim pointed out.
I have not changed the RFC or implementation yet. I would like to settle this point before making another API change, so that the RFC can go into the next stage with this part resolved rather than carrying the disagreement forward. The remaining question for me is what testing seam we want cross-platform userland code which directly consumes readKey() to have, without keeping the ModeToken problem Tim pointed out.
I agree with Tim and Bob: drop both interfaces and rename SystemTerminal
to Terminal. The Windows gap closes if readKey() and readSecret() stop
rejecting non-terminal input. A pipe or php://memory stream already
delivers bytes unprocessed, which is all raw mode does to a tty. So on
a non-terminal stream, readKey() can run the POSIX byte decoder,
enableRawMode() can return a lease that changes nothing, and
readSecret() can read to the newline. Tests then use php://memory on
every OS. Callers that need a real terminal have stream_isatty().
readLine() already accepts redirected input, so this keeps the API
consistent.
Three other things:
Specify raw mode. On POSIX the patch clears OPOST and ISIG
(cfmakeraw). Symfony’s “-icanon -echo” and Laravel Prompts’
“-icanon -isig -echo” clear neither. With OPOST off, every “\n”
output during a lease staircases on POSIX but not on Windows,
because the console’s output mode is never touched. Keep OPOST, or
state the flags in the RFC and make Windows match.
pcntl_fork(): the child inherits the lease list, and its RSHUTDOWN
restores the shared tty, so a TUI loses raw mode when a worker
exits. Store the pid that acquired the lease and skip restoring in
any other process.
Drop the retained token and the no-argument restoreMode(). A lease
that stays active after the caller’s variable is unset undoes the
RAII guarantee. With the token as the only owner, Nicolas’s refcount
design covers everything.
Also, adding a Key case later breaks any match() without a default arm,
so add Insert and Shift+Tab now.
I also checked Windows because I did not want to assume the POSIX PTY
mechanism translated there. It does not directly, since proc_open() on
Windows does not provide the same PTY descriptor. I built the current
branch on Windows 11 ARM64 and exercised the native console path instead.
The PHP side was just:
A small harness attached to a real Windows console injected KEY_EVENT
records into CONIN$ with WriteConsoleInputW(). Injecting Up produced:
enum(Io\Terminal\Key::Up)
[…]
straightforward cross-platform testing seam is still relevant for Windows
code that directly consumes readKey().
I know nothing about Windows, but am seeing that `proc_open()` offers a `create_new_console` option that is documented as:
create_new_console (windows only): the new process has a new console, instead of inheriting its parent's console
Is that perhaps already all that is required? Or would a (Windows-specific) helper `Io\Terminal\inject_fake_keypress()` outside of the main terminal API be helpful?
On the interface question, Tim's objections to the current
Terminal/ModeToken shape still make sense to me, especially the ModeToken
case where a userland token can satisfy the interface but cannot actually
be restored by the native terminal. At the same time, after checking
Windows I don't think I can honestly say that php://memory or stream
injection alone makes the interface unnecessary everywhere. It clearly
solves readLine(), and the PTY gives us a good POSIX integration seam, but
Windows userland code directly consuming readKey() does not have the same
pure-PHP PTY seam today.
It sounds to me that “PHP is unable to spawn terminals on Windows” is an orthogonal concern and a pre-existing limitation of e.g. the `proc_open()` API. The lack of Windows support must not influence the design of the `Io\Terminal` API to introduce a “short term” workaround that will become obsolete as soon as PHP will be able to spawn terminals on Windows, but that will affect the `Io\Terminal` API in a negative way for eternity. We should try very hard that every new API we add to the standard library is well-designed and convenient to use for 15+ years without introducing breaking changes. Perhaps Ilia’s suggestion of just allowing a `php://memory` stream is okay, keeps `readKey()` capabilities in sync with `readLine()` and fills gap until `proc_open()` is extended for proper end-to-end testing?
Also, adding a Key case later breaks any match() without a default arm,
so add Insert and Shift+Tab now.
This is a textbook case of a non-exhaustive enum. I have a very rough draft RFC in PHP: rfc:non_exhaustive_marker that would allow for a clear indicator of such enums. I am not currently working on it (and I'm not sure between interface and attribute). If you (or someone else) wants to pick that up and build a proper design around it, please let me know and I'm happy to add you as a coauthor and let you manage the RFC