What Is Symfony TUI? A Comprehensive Guide

Wait 5 sec.

For over a decade, PHP developers have relied on the&nbsp;symfony/console&nbsp;component as the gold standard for building CLI applications. It gave us beautifully formatted output, robust input validation, and progress bars. But fundamentally, the paradigm remained the same:&nbsp;Immediate Mod.\In immediate mode, your script executes top-to-bottom. If you want to show a progress bar, you must calculate the state, format a string, and explicitly echo ANSI escape codes to redraw that specific terminal line. If an HTTP request blocks the main thread, your entire terminal interface freezes.\But what if your CLI application could behave like a modern frontend application?&nbsp;What if you could declare a tree of widgets — containers, text inputs, markdown renderers — and let a rendering engine intelligently diff the screen state, capturing keystrokes and updating UI components asynchronously?symfony/tuiCurrently in the&nbsp;experimental phase, this groundbreaking new component shifts PHP CLI development to a Retained Mode architecture, powered by PHP 8.4 Fibers and the Revolt Event Loop.\In this comprehensive guide, we are going to build two robust applications using the exact bleeding-edge code of the&nbsp;symfony/tui component. We will cover environment setup, responsive styling, event dispatching, focus management, and true concurrency.Fibers, Event Loops, and PHP 8.4Before we write code, we must understand the architectural shift.&nbsp;symfony/tui&nbsp;is strictly locked to PHP 8.4+.\Why? Because it relies heavily on&nbsp;native PHP Fibers&nbsp;to manage state without blocking the execution thread. It pairs Fibers with Revolt, a robust event loop for PHP.\This means your TUI is single-threaded but fully concurrent. Animations (like loaders) keep spinning, API requests are processed in the background, and user keystrokes are captured instantly without interrupting the rendering cycle.Bleeding-Edge Installation & SetupAs of this writing,&nbsp;symfony/tui&nbsp;is an active Pull Request on the main symfony/symfony repository. You cannot run composer require symfony/tui just yet. We must manually map the experimental branch via Composer.\Create a Symfony 8 Projectcomposer create-project symfony/skeleton "8.0.*" my-tui-appcd my-tui-appClone the Experimental BranchClone Fabien Potencier’s specific branch into a local vendor-src directorymkdir -p vendor-srcgit clone --branch tui --single-branch https://github.com/fabpot/symfony.git vendor-src/symfonyConfigure Composer Path RepositoryTell Composer to look in our local checkout for the Tui component:composer config repositories.symfony-tui path vendor-src/symfony/src/Symfony/Component/TuiInstall Required DependenciesWe will install the component itself, the Revolt event loop, and standard Markdown parsing libraries for our rich text widgets.composer require symfony/tui:dev-tui \ revolt/event-loop \ league/commonmark \ tempest/highlight\Ensure your&nbsp;composer.json&nbsp;reflects&nbsp;PHP ^8.4&nbsp;and the packages above. You can run php -v to confirm your local CLI environment.Core Concepts: Widgets, Stylesheets, and EventsTo transition from standard CLI commands to the TUI, you must adopt a DOM-like mindset.The Widget TreeEverything is a subclass of&nbsp;AbstractWidget. You compose a hierarchy by taking a&nbsp;ContainerWidget&nbsp;and calling&nbsp;$container->add($childWidget). When a widget’s internal state changes (e.g., calling&nbsp;$textWidget->setText()), it marks itself as dirty. The engine recalculates constraints and flushes only the necessary&nbsp;ANSI escape codes to the terminal.The StyleSheetStyling is no longer limited to basic ANSI foreground/background colors.&nbsp;symfony/tui&nbsp;implements a&nbsp;cascading style system. You can define a Stylesheet with CSS-like selectors or use built-in Tailwind-like utility classes directly on the widgets.// Stylesheet approach$stylesheet->addRule('.sidebar:focused', new Style( border: Border::all(1, 'rounded', 'cyan'), color: 'gray'));// Tailwind utility approach$widget->addStyleClass('p-2 bg-emerald-500 bold border-rounded');Event DispatcherThe component natively integrates with&nbsp;symfony/event-dispatcher. Widgets emit events like&nbsp;SelectEvent,&nbsp;SelectionChangeEvent,&nbsp;FocusEvent,&nbsp;and&nbsp;CancelEvent.Tui’s complete widgets set:TextWidget&nbsp;for labels, headings, and FIGlet ASCII art bannersInputWidget&nbsp;for single-line text fields with cursor, scrolling, and paste supportEditorWidget&nbsp;is a full multi-line text editor with word wrap, undo/redo, a kill ring, and autocompleteSelectListWidget&nbsp;for scrollable, filterable pick listsSettingsListWidget&nbsp;for preference panels with value cycling and submenusTabsWidget&nbsp;for multi-view interfaces with horizontal or vertical headers (follow-up PR)MarkdownWidget&nbsp;with full&nbsp;CommonMark&nbsp;support and syntax-highlighted code blocksImageWidget&nbsp;and&nbsp;AnimatedImageWidget&nbsp;for inline images (via the Kitty graphics protocol) and animated GIF playback as ASCII art (follow-up PR)OverlayWidget&nbsp;for modal dialogs, dropdowns, and floating panels (follow-up PR)LoaderWidget,&nbsp;CancellableLoaderWidget,&nbsp;and&nbsp;ProgressBarWidget&nbsp;for background operationsThe Reactive Server DashboardLet’s start by building a classic operational dashboard. We want a scrollable list of servers at the bottom and a reactive header on top that changes text color depending on the user’s current selection.namespace App\Command;use Symfony\Component\Console\Attribute\AsCommand;use Symfony\Component\Console\Command\Command;use Symfony\Component\Console\Input\InputInterface;use Symfony\Component\Console\Output\OutputInterface;use Symfony\Component\Tui\Tui;use Symfony\Component\Tui\Widget\ContainerWidget;use Symfony\Component\Tui\Widget\TextWidget;use Symfony\Component\Tui\Widget\SelectListWidget;use Symfony\Component\Tui\Style\StyleSheet;use Symfony\Component\Tui\Style\Style;use Symfony\Component\Tui\Style\Border;use Symfony\Component\Tui\Style\Padding;use Symfony\Component\Tui\Style\Direction;use Symfony\Component\Tui\Event\SelectEvent;use Symfony\Component\Tui\Event\CancelEvent;#[AsCommand( name: 'app:server-dashboard', description: 'Launches the interactive server management TUI.')]class ServerDashboardCommand extends Command{ protected function execute(InputInterface $input, OutputInterface $output): int { // 1. Initialize the StyleSheet $stylesheet = new StyleSheet(); $stylesheet->addRule('.dashboard-container', new Style( padding: Padding::all(2), border: Border::all(1, 'double', 'blue') )); // 2. Build the Header $header = new TextWidget('Server Status Dashboard'); $header->addStyleClass('font-big text-cyan-400 bold mb-2'); // 3. Build the Interactive List // Note: The experimental API expects associative arrays, not objects. $serverList = new SelectListWidget( items: [ ['value' => 'srv-01', 'label' => 'Web Server 01', 'description' => 'Healthy - 20ms ping'], ['value' => 'srv-02', 'label' => 'Database Primary', 'description' => 'Warning - 80% CPU'], ['value' => 'srv-03', 'label' => 'Worker Node', 'description' => 'Healthy - Idle'], ], maxVisible: 10 ); // 4. Handle State and Events (Using ->on() instead of addEventListener) $serverList->on(SelectEvent::class, function (SelectEvent $event) use ($header) { $header->setText(sprintf('Monitoring: %s', $event->getValue())); $header->addStyleClass('text-emerald-500'); }); // 5. Compose the Layout Tree $container = new ContainerWidget(); $container->setStyle(new Style(direction: Direction::Vertical)); $container->add($header); $container->add($serverList); $container->addStyleClass('dashboard-container'); // 6. Boot the TUI Engine $tui = new Tui($stylesheet); $tui->add($container); // 7. Graceful Exits $serverList->on(CancelEvent::class, function () use ($tui) { $tui->stop(); }); // Takes over the terminal buffer $tui->run(); $output->writeln('Dashboard session ended successfully.'); return Command::SUCCESS; }}How the Code WorksSeparation of Concerns:&nbsp;We define our layout structure (ContainerWidget,&nbsp;TextWidget) independently of the terminal’s physical rendering engine.Reactive State:&nbsp;When the&nbsp;SelectEvent&nbsp;fires (triggered when a user navigates to an item and hits Enter), we&nbsp;mutate the $header widget. The TUI engine automatically detects this mutation and flushes the minimal required ANSI escape codes to the terminal to update only the header.Graceful Exits:&nbsp;Calling $tui->run() takes exclusive control of the terminal buffer. Once exited, the terminal state is completely restored, preventing the “garbled output” issue common in older CLI tools.API Evolution:&nbsp;If you read early blogs on the TUI component, you might have seen&nbsp;$stylesheet = new Stylesheet()&nbsp;and&nbsp;$widget->addEventListener(). The actual, current implementation enforces strict casing (StyleSheet) and uses a concise&nbsp;->on(Event::class, callback)&nbsp;method.Object-Oriented Styling:&nbsp;Passing padding: 2 will throw a TypeError. You must use strongly typed immutable value objects:&nbsp;Padding::all(2)&nbsp;and&nbsp;Border::all(…).Widget Composition:&nbsp;Instead of passing children arrays via constructors, we instantiate empty&nbsp;ContainerWidgets&nbsp;and use the fluent&nbsp;->add()&nbsp;interface.The “Kitchen Sink” Widget DemoTo truly appreciate the power of&nbsp;Symfony TUI, we must explore its advanced widgets:&nbsp;Text Inputs,&nbsp;Multiline Editors,&nbsp;Markdown Renderers,&nbsp;and background-driven&nbsp;Progress Bars.\We are going to build a complex, multi-pane layout that simulates a&nbsp;Tabbed Interface. We will have a persistent navigation sidebar on the left and a dynamic content pane on the right.Layout & Custom Focus ManagementBy default, the experimental TUI uses F6 to cycle focus. For a standard user experience, we want to use the TAB key. We also want to visually indicate which “window” has focus by turning its border Cyan.namespace App\Command;use Symfony\Component\Console\Attribute\AsCommand;use Symfony\Component\Console\Command\Command;use Symfony\Component\Console\Input\InputInterface;use Symfony\Component\Console\Output\OutputInterface;use Symfony\Component\Tui\Tui;use Symfony\Component\Tui\Widget\ContainerWidget;// ... (omitting widget imports for brevity, see later sections)use Symfony\Component\Tui\Style\StyleSheet;use Symfony\Component\Tui\Style\Style;use Symfony\Component\Tui\Style\Border;use Symfony\Component\Tui\Style\Padding;use Symfony\Component\Tui\Style\Direction;use Symfony\Component\Tui\Event\SelectEvent;use Symfony\Component\Tui\Event\SelectionChangeEvent;use Symfony\Component\Tui\Event\CancelEvent;use Symfony\Component\Tui\Event\InputEvent;use Symfony\Component\Tui\Event\FocusEvent;use Symfony\Component\Tui\Input\Keybindings;use Symfony\Component\Tui\Input\Key;use Revolt\EventLoop;#[AsCommand(name: 'app:widgets-demo', description: 'Demonstrates all available widgets.')]class WidgetsDemoCommand extends Command{ protected function execute(InputInterface $input, OutputInterface $output): int { $stylesheet = new StyleSheet(); $stylesheet->addRule('.sidebar', new Style(padding: Padding::all(1))); $stylesheet->addRule('.content-pane', new Style(padding: Padding::all(1))); // Dynamic classes applied via Focus events $stylesheet->addRule('.active-pane', new Style(border: Border::all(1, 'rounded', 'cyan'))); $stylesheet->addRule('.inactive-pane', new Style(border: Border::all(1, 'rounded', 'gray'))); // ... [Widget construction goes here, we'll cover it below] ... // The TUI initialization with Custom Keybindings $keybindings = new Keybindings([ 'focus_next' => [Key::TAB], 'focus_previous' => ['shift+tab'], ]); $tui = new Tui(styleSheet: $stylesheet, keybindings: $keybindings); $tui->add($mainLayout); // Workaround: Intercept raw InputEvents to force TAB navigation $tui->on(InputEvent::class, function (InputEvent $event) use ($tui, $keybindings) { $data = $event->getData(); if ($keybindings->matches($data, 'focus_next')) { $tui->getFocusManager()->focusNext(); $event->stopPropagation(); } elseif ($keybindings->matches($data, 'focus_previous')) { $tui->getFocusManager()->focusPrevious(); $event->stopPropagation(); } }); // Visually change the active pane border based on FocusEvent $tui->on(FocusEvent::class, function (FocusEvent $event) use ($sidebar, $contentPane, $inputField, $editorField) { $target = $event->getTarget(); $previous = $event->getPrevious(); if ($target === $sidebar) { $sidebar->removeStyleClass('inactive-pane')->addStyleClass('active-pane'); $contentPane->removeStyleClass('active-pane')->addStyleClass('inactive-pane'); } else { $sidebar->removeStyleClass('active-pane')->addStyleClass('inactive-pane'); $contentPane->removeStyleClass('inactive-pane')->addStyleClass('active-pane'); } // ... [Placeholder logic goes here] ... }); $tui->run(); return Command::SUCCESS; }}Notice how we rely on FocusEvent to manipulate CSS classes (removeStyleClass/addStyleClass). The framework completely abstracts away terminal coordinates. We simply alter the DOM, and Symfony handles the visual repainting.Input and Editor Widgets (Handling Placeholders)The&nbsp;InputWidget&nbsp;and&nbsp;EditorWidget&nbsp;provide robust input handling, including cursor movement, scrolling, and paste support. Let’s create an input and a multi-line editor and build custom placeholder logic using the&nbsp;FocusEvent&nbsp;we defined above.// 2. InputWidget $inputContainer = new ContainerWidget(); $inputContainer->setStyle(new Style(direction: Direction::Vertical, gap: 1)); $inputField = new InputWidget(); $inputField->setValue("Type something here..."); $inputField->setStyle(new Style(border: Border::all(1, 'rounded', 'green'))); $inputContainer->add(new TextWidget("Single-line text field:"))->add($inputField); // 3. EditorWidget $editorContainer = new ContainerWidget(); $editorContainer->setStyle(new Style(direction: Direction::Vertical, gap: 1)); $editorField = new EditorWidget(); $editorField->setText("Write your multiline text here.\n\nEnjoy the full editing capabilities!"); $editorField->setStyle(new Style(border: Border::all(1, 'rounded', 'yellow'))); $editorField->expandVertically(true); // Fills available terminal height $editorContainer->add(new TextWidget("Multi-line text editor:"))->add($editorField);\Inside our&nbsp;FocusEvent&nbsp;listener, we can add this logic to simulate HTML placeholder attributes:// InputWidget placeholder logic: hide on focus, restore on blur if ($target === $inputField && $inputField->getValue() === "Type something here...") { $inputField->setValue(""); } if ($previous === $inputField && $inputField->getValue() === "") { $inputField->setValue("Type something here..."); } // EditorWidget placeholder logic if ($target === $editorField && $editorField->getText() === "Write your multiline text here.\n\nEnjoy the full editing capabilities!") { $editorField->setText(""); } if ($previous === $editorField && $editorField->getText() === "") { $editorField->setText("Write your multiline text here.\n\nEnjoy the full editing capabilities!"); }Markdown and SettingsThe&nbsp;MarkdownWidget&nbsp;is a powerhouse. Using&nbsp;league/commonmark&nbsp;for parsing and&nbsp;tempest/highlight&nbsp;for&nbsp;tokenization, it renders fully syntax-highlighted code blocks natively in the terminal.// 5. MarkdownWidget $mdText = "# MarkdownWidget\n\nSupports **CommonMark** with syntax highlighting!\n\n```php\n// Look at this code\necho 'Hello TUI!';\n```\n\n- Lists are supported too."; $markdownWidget = new MarkdownWidget($mdText);\The&nbsp;SettingsListWidget&nbsp;operates as an interactive preference panel, allowing users to hit&nbsp;&nbsp;or&nbsp;Right/Left&nbsp;arrows to cycle through enumerated values.// 4. SettingsListWidget $settingItems = [ new SettingItem(id: 'theme', label: 'Theme', currentValue: 'Dark', description: 'Application visual theme.', values: ['Dark', 'Light', 'System']), new SettingItem(id: 'telemetry', label: 'Telemetry', currentValue: 'Opt-out', description: 'Share usage statistics.', values: ['Opt-in', 'Opt-out']), ]; $settingsList = new SettingsListWidget($settingItems, 10);True Concurrency with Revolt and LoadersThe absolute magic of the&nbsp;symfony/tui&nbsp;component lies in its event loop. We can render a&nbsp;ProgressBarWidget&nbsp;and an animated&nbsp;LoaderWidget&nbsp;side-by-side and update them using a background timer without ever halting the user’s ability to type in the&nbsp;InputWidget&nbsp;or navigate menus.// 6. Loaders & Progress Bar $loadersContainer = new ContainerWidget(); $loadersContainer->setStyle(new Style(direction: Direction::Vertical, gap: 1)); $loader = new LoaderWidget('Booting system...'); $cancellableLoader = new CancellableLoaderWidget('Downloading updates...'); // Customizing the ProgressBar visualization via Stylesheet and Setters $stylesheet->addRule(ProgressBarWidget::class.'::bar-fill', new Style(color: 'cyan')); $progressBar = new ProgressBarWidget(100); $progressBar->setBarCharacter('━'); // The filled portion $progressBar->setEmptyBarCharacter('─'); // The empty background $progressBar->setProgressCharacter('╸'); // The leading edge $progressBar->start(); // Simulate asynchronous background progress via Revolt EventLoop EventLoop::repeat(0.1, function() use ($progressBar, $loader, $cancellableLoader) { if ($progressBar->getProgress() < 100) { $progressBar->advance(1); } else { $progressBar->setProgress(0); } // Sync text to the progress bar's state $percent = $progressBar->getProgress(); $loader->setMessage("Booting system... {$percent}%"); $cancellableLoader->setMessage("Downloading updates... {$percent}%"); }); $loadersContainer->add($loader)->add($cancellableLoader)->add($progressBar);Connecting the TabsFinally, we map our&nbsp;“Tabs” (the Sidebar)&nbsp;to our&nbsp;Content Panes. Whenever a user triggers a&nbsp;SelectionChangeEvent&nbsp;on the sidebar, we simply call&nbsp;$contentPane->clear()&nbsp;and&nbsp;$contentPane->add($panes[$value]). The DOM updates instantly.// Map the options to the containers $panes = [ 'input' => $inputContainer, 'editor' => $editorContainer, 'settings' => $settingsContainer, 'markdown' => $markdownWidget, 'loaders' => $loadersContainer, ]; // The active content pane container $contentPane = new ContainerWidget(); $contentPane->addStyleClass('content-pane'); $contentPane->addStyleClass('inactive-pane'); $contentPane->expandVertically(true); $contentPane->add($inputContainer); // default view // Sidebar Navigation $sidebar = new SelectListWidget( items: [ ['value' => 'input', 'label' => 'Input Field'], ['value' => 'editor', 'label' => 'Editor'], ['value' => 'settings', 'label' => 'Settings List'], ['value' => 'markdown', 'label' => 'Markdown'], ['value' => 'loaders', 'label' => 'Loaders & Progress'], ], maxVisible: 10 ); $sidebar->addStyleClass('sidebar'); $sidebar->addStyleClass('active-pane'); // Swap out DOM content on selection change $sidebar->on(SelectionChangeEvent::class, function (SelectionChangeEvent $event) use ($contentPane, $panes) { $value = $event->getValue(); if (isset($panes[$value])) { $contentPane->clear(); $contentPane->add($panes[$value]); } }); // TUI Main Layout $mainLayout = new ContainerWidget(); $mainLayout->setStyle(new Style(direction: Direction::Horizontal, gap: 2)); $mainLayout->add($sidebar); $mainLayout->add($contentPane);The Future of the TerminalBuilding with the experimental symfony/tui component feels revolutionary. It takes the lessons we’ve learned from decades of frontend browser development — the DOM tree, the event loop, cascading styles, and distinct focus states — and injects them seamlessly into the terminal.\While currently in its raw PHP object-oriented form, the planned roadmap includes bringing this exact retained-mode engine into Twig. Imagine writing your CLI tools using familiar declarative and tags, backed by powerful PHP Controllers.\While the component is still in its experimental phase, cloning the PR and building side-projects today will give you a massive head start. Terminal apps are about to become a whole lot richer, and Symfony is leading the charge.\Source Code:&nbsp;You can find the full implementation and follow the project’s progress on GitHub: [https://github.com/mattleads/TuiComponent]Let’s Connect!If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:LinkedIn: [https://www.linkedin.com/in/matthew-mochalkin/]X (Twitter): [https://x.com/MattLeads]Telegram: [https://t.me/MattLeads]GitHub: [https://github.com/mattleads]\