# [PHP DEV] [Discussion] Native terminal helpers for PHP CLI

- Thread: https://externals.io/message/132530
- Original archive: https://news-web.php.net/php.internals/132530
- Started by Pratik Bhujel on 2026-09-18T02:19:42+00:00
- Messages: 6
- RFC: Io\Terminal (Under discussion) — https://externals.io/rfc/io_terminal.md
- Reply by email to internals@lists.php.net
- 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

---

## #132530 — Pratik Bhujel — 2026-09-18T02:19:42+00:00

URL: https://externals.io/message/132530#132530

Hi Tim, hi internals,

Thank you for the detailed feedback. Your observations on namespace
grouping, instance handles, unbacked enums, and standard library
conventions made complete sense.

Instead of just replying in theory, I took the time over the past couple of
days to step back, study how other ecosystems handle terminal interfaces,
and rebuild the extension accordingly. Version 0.8.0 is now released with
that exact architecture.

To answer your question regarding the domain problem:

The primary issue is that PHP CLI currently lacks native primitives for
non-canonical raw terminal mode and single keypress reading. Modern
interactive CLI tools in userland (such as Laravel Prompts, Symfony
Console, and interactive tools) want to deliver rich experiences like
searchable selection menus, autocomplete, multi-choice checkboxes,
spinners, and hidden password prompts.

Today, userland libraries face two severe problems:

1. On Linux and macOS, packages work around the missing primitives by
executing the external stty tool using proc_open or exec. However, inside
minimal Docker containers (such as Alpine or slim environments where stty
is absent) or environments where process execution functions are
restricted, interactive CLI tools fail completely.

2. On Windows, stty does not exist. Windows uses the Win32 Console API.
Because PHP has lacked these primitives, interactive CLI packages on
Windows either crash or fall back to crude text prompts where the user must
type choices manually and hit Enter. Reading password input without
displaying characters on Windows even required distributing separate helper
executables.

3. When a script exits unexpectedly or an unhandled exception occurs while
the terminal is in raw mode, the user shell is left corrupted with disabled
echo and hidden cursor.

Looking at how other languages tackle this:

* Rust (termion and crossterm): Rust relies on RAII guards where entering
raw mode returns a handle whose destructor automatically restores canonical
cooked mode on drop or panic.
* Go (golang.org/x/term): Provides low-level primitives in the extended
standard library (MakeRaw, Restore, GetSize, ReadPassword) on top of which
tools like Bubbletea are built.
* Node.js: Integrates raw mode toggling and size queries directly onto TTY
streams via libuv.
* Python: Provides termios on POSIX and msvcrt on Windows in the standard
library to guarantee cross-platform terminal parity.

Following your guidance, version 0.8.0 adopts the following design:

1. Namespace grouping:
All symbols are placed under the Io\Terminal namespace, avoiding top-level
name reservation and aligning with modern asynchronous and stream polling
conventions.

2. Instance-based Terminal handle:
Classes are no longer static wrappers. The Io\Terminal\Terminal class
encapsulates a stream descriptor, created via named constructors stdin(),
stdout(), stderr(), or by wrapping custom stream resources.

3. Automatic RAII cleanup:
Both Terminal instances and ModeToken objects track active raw mode and
automatically restore terminal state upon destruction. If an uncaught
exception terminates script execution, the destructor restores canonical
mode so the developer shell is never left broken.

4. Unbacked enums:
Backend, Stream, ColorDepth, and Key are now unbacked enums matching core
standards. Methods such as bits() on ColorDepth supply numerical details
without scalar backing.

5. Free-standing procedural functions:
Io\Terminal provides procedural functions like is_tty(), read_key(), and
get_size() for functional script usage.

6. Compatibility:
The legacy Terminal facade and class aliases remain in place so existing
userland code continues to work smoothly.

The complete codebase, 49 tests, and the continuous integration matrix
covering Unix and Windows across PHP versions 8.1 through 8.5 are live on
GitHub and installable via PIE (package prateekbhujel/php-terminal).

The GitHub repository is here:
https://github.com/prateekbhujel/php-terminal

Release v0.8.0 with prebuilt Windows DLL assets:
https://github.com/prateekbhujel/php-terminal/releases/tag/v0.8.0

I would love to hear any further thoughts on whether this design aligns
with what you would like to see for an RFC targeted at PHP 8.7.

Thanks,
Pratik

---

## #132531 — Rob Landers — 2026-09-18T07:35:26+00:00

URL: https://externals.io/message/132530#132531 · In reply to: #132530

> Hi Tim, hi internals,
> 
> Thank you for the detailed feedback. Your observations on namespace grouping, instance handles, unbacked enums, and standard library conventions made complete sense.
> 
> Instead of just replying in theory, I took the time over the past couple of days to step back, study how other ecosystems handle terminal interfaces, and rebuild the extension accordingly. Version 0.8.0 is now released with that exact architecture.
> 
> To answer your question regarding the domain problem:
> 
> The primary issue is that PHP CLI currently lacks native primitives for non-canonical raw terminal mode and single keypress reading. Modern interactive CLI tools in userland (such as Laravel Prompts, Symfony Console, and interactive tools) want to deliver rich experiences like searchable selection menus, autocomplete, multi-choice checkboxes, spinners, and hidden password prompts.
> 
> Today, userland libraries face two severe problems:
> 
> 1. On Linux and macOS, packages work around the missing primitives by executing the external stty tool using proc_open or exec. However, inside minimal Docker containers (such as Alpine or slim environments where stty is absent) or environments where process execution functions are restricted, interactive CLI tools fail completely.
> 
> 2. On Windows, stty does not exist. Windows uses the Win32 Console API. Because PHP has lacked these primitives, interactive CLI packages on Windows either crash or fall back to crude text prompts where the user must type choices manually and hit Enter. Reading password input without displaying characters on Windows even required distributing separate helper executables.
> 
> 3. When a script exits unexpectedly or an unhandled exception occurs while the terminal is in raw mode, the user shell is left corrupted with disabled echo and hidden cursor.
> 
> Looking at how other languages tackle this:
> 
> * Rust (termion and crossterm): Rust relies on RAII guards where entering raw mode returns a handle whose destructor automatically restores canonical cooked mode on drop or panic.
> * Go (golang.org/x/term): Provides low-level primitives in the extended standard library (MakeRaw, Restore, GetSize, ReadPassword) on top of which tools like Bubbletea are built.
> * Node.js: Integrates raw mode toggling and size queries directly onto TTY streams via libuv.
> * Python: Provides termios on POSIX and msvcrt on Windows in the standard library to guarantee cross-platform terminal parity.
> 
> Following your guidance, version 0.8.0 adopts the following design:
> 
> 1. Namespace grouping:
> All symbols are placed under the Io\Terminal namespace, avoiding top-level name reservation and aligning with modern asynchronous and stream polling conventions.
> 
> 2. Instance-based Terminal handle:
> Classes are no longer static wrappers. The Io\Terminal\Terminal class encapsulates a stream descriptor, created via named constructors stdin(), stdout(), stderr(), or by wrapping custom stream resources.
> 
> 3. Automatic RAII cleanup:
> Both Terminal instances and ModeToken objects track active raw mode and automatically restore terminal state upon destruction. If an uncaught exception terminates script execution, the destructor restores canonical mode so the developer shell is never left broken.
> 
> 4. Unbacked enums:
> Backend, Stream, ColorDepth, and Key are now unbacked enums matching core standards. Methods such as bits() on ColorDepth supply numerical details without scalar backing.
> 
> 5. Free-standing procedural functions:
> Io\Terminal provides procedural functions like is_tty(), read_key(), and get_size() for functional script usage.
> 
> 6. Compatibility:
> The legacy Terminal facade and class aliases remain in place so existing userland code continues to work smoothly.
> 
> The complete codebase, 49 tests, and the continuous integration matrix covering Unix and Windows across PHP versions 8.1 through 8.5 are live on GitHub and installable via PIE (package prateekbhujel/php-terminal).
> 
> The GitHub repository is here:
> https://github.com/prateekbhujel/php-terminal
> 
> Release v0.8.0 with prebuilt Windows DLL assets:
> https://github.com/prateekbhujel/php-terminal/releases/tag/v0.8.0
> 
> I would love to hear any further thoughts on whether this design aligns with what you would like to see for an RFC targeted at PHP 8.7.
> 
> Thanks,
> Pratik

Meta question: can you please use your email’s “reply” feature instead of writing a new email with the same subject each time. That allows people to follow the thread. 

— Rob

---

## #132532 — tim@bastelstu.be — 2026-09-18T07:46:05+00:00

URL: https://externals.io/message/132530#132532 · In reply to: #132530

Hi

it seems your replies don't contain proper `in-reply-to` or `references` 
headers, which breaks the threading. As an example in the archives at 
https://news-web.php.net/php.internals/132530 no other emails from the 
discussion are shown and https://news-web.php.net/php.internals/132528 
only shows your email and my reply. On externals.io new threads are 
created for every email. Can you check the configuration of your email 
client and make sure to always use real “Reply” instead of sending a 
fresh email?


> To answer your question regarding the domain problem:
> 
> The primary issue is that PHP CLI currently lacks native primitives for
> non-canonical raw terminal mode and single keypress reading. Modern
> interactive CLI tools in userland (such as Laravel Prompts, Symfony
> Console, and interactive tools) want to deliver rich experiences like
> searchable selection menus, autocomplete, multi-choice checkboxes,
> spinners, and hidden password prompts.

Yes, I roughly understand the problem that is being solved. It's just 
something I never had to deal with, so I can't comment on whether the 
solution is complete and ergonomic.

> 2. Instance-based Terminal handle:
> Classes are no longer static wrappers. The Io\Terminal\Terminal class
> encapsulates a stream descriptor, created via named constructors 
> stdin(),
> stdout(), stderr(), or by wrapping custom stream resources.

For consistency with the recently introduced APIs, the named 
constructors should start with a prefix that clearly indicates that the 
method is a constructor. As of now we have the following:

- Dom\HTMLDocument::createEmpty(), ::createFromFile(), 
::createFromString()
- Time\Duration::fromSeconds(), ::fromMilliseconds(), …, 
::fromIso8601DurationString()
- Uri\Rfc3986\Uri::parse() (though this one also has a real 
constructor).

For the Terminal I wonder why we need to have stdin, stdout, and stderr 
separately. For me logically a “Terminal” is a combination of all three 
and I would expect them to be “synchronized” if that makes sense?

> 3. Automatic RAII cleanup:
> Both Terminal instances and ModeToken objects track active raw mode and
> automatically restore terminal state upon destruction. If an uncaught
> exception terminates script execution, the destructor restores 
> canonical
> mode so the developer shell is never left broken.

That makes sense to me.

> 5. Free-standing procedural functions:
> Io\Terminal provides procedural functions like is_tty(), read_key(), 
> and
> get_size() for functional script usage.

I've taken another look at these and have some point that stood out to 
me:

- Do we need all three of get_size(), get_width(), get_height()? The 
latter two seem redundant.
- Should related methods have a common prefix for discoverability? So 
mode_enable_raw() and mode_restore() instead of enable_raw_mode() and 
restore_mode()?
- Many (all?) of the functions also exists as methods on the Terminal 
class. This redundant procedural + object-oriented API design is 
something we no longer do. Decide on one of the two, depending on what 
is more useful. I expect the OO variant to be the right choice due to 
the RAII support.

Best regards
Tim Düsterhus

---

## #132533 — Pratik Bhujel — 2026-09-18T08:16:51+00:00

URL: https://externals.io/message/132530#132533 · In reply to: #132532

Hi Rob, hi Tim,

@Rob: Thanks for pointing that out! My bad on the broken threads earlier. I
was sending from a separate setup that stripped the reference headers.
Replying directly via Reply-All now so this stays in the same thread on
both the archives and Discourse.

@Tim: Thanks a lot for this feedback, it makes total sense.

1. Unifying the streams:
You're completely right about stdin/stdout/stderr. Under the hood, the
terminal is a single interactive TTY session anyway. Managing them as
separate handles just invites state desync and conflicting destructors.
I'll consolidate this into a single handle representing the terminal
session. e.g. `Terminal::open()` / `Terminal::create()`, plus maybe
`Terminal::fromStreams()` if someone needs custom redirection or testing.

2. Named constructors:
Got it. I'll switch to the `create*()` / `from*()` prefixes to align with
modern core APIs like Dom\HTMLDocument and Time\Duration instead of bare
names.

3. OO-only vs procedural:
I completely agree. The procedural duplication was carried over from older
habits, but since RAII cleanup via the destructor is the entire safety
backbone of this extension, an OO-only API (`Io\Terminal\Terminal`) is much
cleaner and avoids unnecessary duplication. I'll drop the procedural
functions.

4. Redundant getters & discoverability:
Dropping `getWidth()` / `getHeight()` in favor of just
`$terminal->getSize()` returning a small size object/tuple makes the
interface a lot leaner. I'll also make sure the mode methods have clear,
grouped naming.

I'll start working on these refactors in the codebase and update the RFC
draft accordingly.

Thanks again for steering this in the right direction!

Best,
Pratik

---

## #132653 — Pratik Bhujel — 2026-09-26T11:26:15+00:00

URL: https://externals.io/message/132530#132653 · In reply to: #132532

Hi Tim, hi internals,

Following up on this thread now that the API work we discussed here
has actually settled.

I made the changes from your review in 0.9.0: a unified Terminal
session, create/from constructors, an OO-only API, and TerminalSize
instead of separate width/height helpers. 1.0.0 then froze that
session API as the stable 1.x contract. 1.1.0 added readEvent after
working through what Symfony TUI actually needs; readKey is useful for
normalized keys, but TUI also needs raw POSIX chunks and native
Windows event details.

Symfony Console/TUI PR #66173 is now using ext-terminal optionally for
terminal size, hidden input and raw mode:
https://github.com/symfony/symfony/pull/66173

Nicolas Grekas has approved it, and GromNaN also approved it after
testing and benchmarking the native size path on macOS. That
integration work has been useful because it exposed real compatibility
and behavior questions rather than only API design in isolation.

Yesterday I also did a fresh audit on real Windows, Linux and macOS
runners. It found five edge cases around stale mode tokens,
overlapping raw-mode ownership, POSIX SIGWINCH coordination, split
Windows surrogate input and split POSIX UTF-8 input. Those fixes are
all merged on main now and the final CI is green. I have not released
those fixes yet; the latest public release is still 1.1.0.

At this point I do not think the RFC should simply move the extension
into core unchanged. My current thought is a smaller Io\Terminal
surface for PHP 8.7, with the extension remaining useful as the
reference implementation and backport, and compatibility-only pieces
left out of the core proposal.

Before I write the formal RFC, I would like to sanity-check that
direction here. Would you prefer the initial RFC to stay with the
higher-level primitives such as session creation, TTY/size, raw mode,
hidden input and readKey, or does including the lower-level readEvent
primitive also make sense given the Symfony TUI use case?

Thanks,
Pratik


> Hi
>
> it seems your replies don't contain proper `in-reply-to` or `references`
> headers, which breaks the threading. As an example in the archives at
> https://news-web.php.net/php.internals/132530 no other emails from the
> discussion are shown and https://news-web.php.net/php.internals/132528
> only shows your email and my reply. On externals.io new threads are
> created for every email. Can you check the configuration of your email
> client and make sure to always use real “Reply” instead of sending a
> fresh email?
>
> 
> > […]
>
> Yes, I roughly understand the problem that is being solved. It's just
> something I never had to deal with, so I can't comment on whether the
> solution is complete and ergonomic.
>
> > […]
>
> For consistency with the recently introduced APIs, the named
> constructors should start with a prefix that clearly indicates that the
> method is a constructor. As of now we have the following:
>
> - Dom\HTMLDocument::createEmpty(), ::createFromFile(),
> ::createFromString()
> - Time\Duration::fromSeconds(), ::fromMilliseconds(), …,
> ::fromIso8601DurationString()
> - Uri\Rfc3986\Uri::parse() (though this one also has a real
> constructor).
>
> For the Terminal I wonder why we need to have stdin, stdout, and stderr
> separately. For me logically a “Terminal” is a combination of all three
> and I would expect them to be “synchronized” if that makes sense?
>
> > […]
>
> That makes sense to me.
>
> > […]
>
> I've taken another look at these and have some point that stood out to
> me:
>
> - Do we need all three of get_size(), get_width(), get_height()? The
> latter two seem redundant.
> - Should related methods have a common prefix for discoverability? So
> mode_enable_raw() and mode_restore() instead of enable_raw_mode() and
> restore_mode()?
> - Many (all?) of the functions also exists as methods on the Terminal
> class. This redundant procedural + object-oriented API design is
> something we no longer do. Decide on one of the two, depending on what
> is more useful. I expect the OO variant to be the right choice due to
> the RAII support.
>
> Best regards
> Tim Düsterhus

---

## #132654 — tim@bastelstu.be — 2026-09-26T11:58:43+00:00

URL: https://externals.io/message/132530#132654 · In reply to: #132653

Hi


> I made the changes from your review in 0.9.0: a unified Terminal
> session, create/from constructors, an OO-only API, and TerminalSize
> instead of separate width/height helpers. 1.0.0 then froze that
> session API as the stable 1.x contract. 1.1.0 added readEvent after
> working through what Symfony TUI actually needs; readKey is useful for
> normalized keys, but TUI also needs raw POSIX chunks and native
> Windows event details.

Thank you. I have taken another look at the API:

1. The timeouts should be specified as Time\Duration objects (which was 
added specifically to PHP 8.6 for this kind of purpose; take a look at 
Io\Poll for an example).

2. Is Terminal::__construct() identical to Terminal::fromStreams()? 
Generally when you have named constructors, you shouldn't also offer a 
regular constructor.

> Symfony Console/TUI PR #66173 is now using ext-terminal optionally for
> terminal size, hidden input and raw mode:
> https://github.com/symfony/symfony/pull/66173
> 
> Nicolas Grekas has approved it, and GromNaN also approved it after
> testing and benchmarking the native size path on macOS. That
> integration work has been useful because it exposed real compatibility
> and behavior questions rather than only API design in isolation.

That is great and inspires additional confidence that the API design is 
right.

> At this point I do not think the RFC should simply move the extension
> into core unchanged. My current thought is a smaller Io\Terminal
> surface for PHP 8.7, with the extension remaining useful as the
> reference implementation and backport, and compatibility-only pieces
> left out of the core proposal.

Yes, the compatibility-only parts should not be included in PHP itself.

> Before I write the formal RFC, I would like to sanity-check that
> direction here. Would you prefer the initial RFC to stay with the
> higher-level primitives such as session creation, TTY/size, raw mode,
> hidden input and readKey, or does including the lower-level readEvent
> primitive also make sense given the Symfony TUI use case?
Again, not an expert on the domain, but I think including everything 
that is useful and where we are confident it won't need any further 
changes makes sense to me.

Best regards
Tim Düsterhus
