php-tui/term
php-tui/term is a PHP library for terminal control and TUI development, with ANSI escape sequences, cursor movement, text styling, screen management, and input handling. Build responsive command-line interfaces and interactive CLI tools without heavy dependencies.
php-tui/term is the low-level I/O layer beneath the larger php-tui/php-tui framework. It gives you raw terminal control: ANSI escape sequences, cursor movement, colors, raw stdin reading (keys/mouse), terminal size, and alternate-screen handling. It is not a widget/framework library — you render strings and sequences yourself.
Install and check the floor:
composer require php-tui/term
It targets PHP 8.1+ and expects a POSIX-ish terminal. There is no Laravel service provider shipped; you integrate it yourself.
Where to look first, in order:
PhpTui\Term\TerminalInterface — the whole contract. Every capability you get day-to-day is a method here (execute(), drainEvents(), size(), enableRawMode()).PhpTui\Term\Terminal — the concrete implementation you instantiate.PhpTui\Term\Action\* — the command objects you pass to execute() (PrintText, MoveCursor, Clear, SetForegroundColor, SetBackgroundColor, SetModifier, SetCursorVisibility, EnableRawMode, …).PhpTui\Term\Event\* — KeyEvent, MouseEvent, resize events returned from drainEvents().The smallest useful thing — clear the screen, print colored text, restore:
use PhpTui\Term\Terminal;
use PhpTui\Term\Action\{Clear, ClearType, MoveCursor, SetForegroundColor, PrintText, ResetColor};
use PhpTui\Term\Color;
$term = Terminal::new();
$term->execute(
Clear::new(ClearType::All),
MoveCursor::position(2, 2),
SetForegroundColor::new(Color::Green),
PrintText::new('Hello from raw terminal land'),
ResetColor::new(),
);
First real use case for a Laravel dev: an Artisan command that renders a live, self-updating dashboard (queue depth, cache hits, whatever) instead of dumping a line per tick, or a command that reads single keypresses without requiring Enter — something Laravel Prompts and Symfony Console cannot do.
class WatchCommand extends Command
{
protected $signature = 'watch:queue';
public function handle(): int
{
$term = Terminal::new();
$term->enableRawMode();
try {
while (true) {
$term->execute(
Clear::new(ClearType::All),
MoveCursor::position(1, 1),
PrintText::new('Queue: ' . Queue::size() . ' (q to quit)'),
);
foreach ($term->drainEvents() as $event) {
if ($event instanceof KeyEvent && $event->key() === 'q') {
return self::SUCCESS;
}
}
usleep(250_000);
}
} finally {
$term->disableRawMode();
}
}
}
Once that loop feels natural, the rest of the package is just more commands and events.
Wrap the terminal once, inject it. Do not call Terminal::new() scattered through your code. Bind TerminalInterface in a service provider (or a small TerminalManager) so commands and services can receive a fake in tests:
$this->app->singleton(TerminalInterface::class, fn () => Terminal::new());
Double-buffer your own screen. php-tui/term gives you primitives, not a diff engine. For anything animated, keep a $previous array of rendered lines and only emit MoveCursor + PrintText for lines that changed. Full Clear::new() every tick is visually fine at 1–4 Hz, but flickers badly at 30 Hz. If you want a proper diffing renderer, step up to php-tui/php-tui and use its Screen/Buffer abstractions rather than reinventing them.
Event loop shape. The idiomatic pattern is poll → drainEvents → render → sleep. drainEvents() is non-blocking; combine it with a tick interval rather than busy-looping. If you need blocking reads, you will instead read from the input stream in a tighter loop — but a 100–250 ms tick is what most dashboards actually want.
Compose commands fluently. Every render pass is a batch of actions passed to one execute() call. That reduces syscalls and, more importantly, keeps rendering atomic — no half-drawn frame where a cursor move has landed but the text has not.
Fallback for non-interactive contexts. Laravel commands run under CI, in php artisan pipelines, under Horizon, and in Docker exec without a TTY. Gate on:
if (! stream_isatty(STDOUT)) {
// plain $this->line() output path
}
Treat the pretty path as an enhancement, never the only path. Same for --no-interaction and -v verbosity flags — respect them.
Route diagnostics away from stdout. Once you own stdout with cursor positioning, any other write — dump(), $this->line(), Log::info() configured to single, PHP warnings, deprecations — corrupts the frame. Either log to stderr/file explicitly, or capture ob_start() around the render and discard accidental output. A common trick is fwrite(STDERR, ...) for debug output during development.
Sanitize before you print. Any string originating from the DB or an external API must be stripped of \e / \x1b and control characters before it reaches PrintText. One poisoned record with an escape sequence can move your cursor, clear the screen, or change colors mid-frame.
Signals and cleanup. Raw mode left on after a crash is the single most annoying failure mode — the terminal echoes nothing and the shell looks broken (stty sane recovers it). Register pcntl_signal(SIGINT, ...) and SIGTERM handlers that disable raw mode, show the cursor, and exit; and always use try/finally around the raw-mode block.
Testing. Swap in a fake TerminalInterface that records executed actions into an array, and assert on the action stream rather than on rendered text. Keep raw mode off in tests entirely — CI has no TTY.
try/finally and handle SIGINT/SIGTERM. If you kill your terminal session while developing, reset or stty sane restores it.cmd.exe, redirection, and raw key input. If Windows support matters, feature-detect and fall back to Symfony Console output.strlen and even mb_strlen are wrong for East Asian wide characters and emoji. You need wcwidth semantics to align columns; check whether the version you pin handles it, or normalize/limit input. This is the most common source of "my borders don't line up" bugs.Terminal::new() on a pipe or in CI can fail or write garbage into your log stream. Always guard on stream_isatty(STDOUT) before constructing.$term->size() at tick boundaries, or handle SIGWINCH if pcntl is available. Assume the terminal can change size between any two frames and re-layout from size() each tick.SetCursorVisibility/ResetCursor and restore the alternate screen in your finally. Forgetting leaves the user with an invisible cursor after the command exits.composer.lock and treat it as a leaf dependency: keep the surface you touch small (a TerminalManager wrapper) so you can swap it out or fork it if the project stalls.usleep vs hrtime. For anything with a target frame rate, measure with hrtime(true) and sleep the remainder of the frame budget — usleep() drifts and will make your 10 FPS dashboard into 7 FPS.term, stop: use php-tui/php-tui (which depends on this package) or fall back to Symfony Console + Laravel Prompts. php-tui/term is the right tool only when you need direct control over the byte stream.How can I help you explore Laravel packages today?