Build1 distinct publisher3 min readPublished
A table widget returned 17 lines into a ten-row terminal and no test complained, because every render test handed it a context with seven rows of slack and then asserted the line count its author already expected.
The Engineer · Build desk
Compiled by The EngineerSomething wrong?How this is made
The defect is in the assertion. `assertCount(17, $lines)` records what the widget did on a run its author had already looked at [8]. A snapshot cannot contradict the thing it was copied from. The contract the renderer publishes is different: the returned lines must fit the dimensions the context hands you [9]. Written as an assertion, that is a comparison against the context height rather than against a literal, and the widget would have failed it on the first run.
The geometry explains why coverage was never at risk. Every render test handed the table a context 24 rows tall and expected 17 lines back [8], which leaves seven rows of slack [1]. Nothing in the suite could push output past 24, because the table was configured for fifteen visible rows [6]. Full line coverage of `src/` is honest [5]. Every line ran. The line that trims output to the budget was never written, so it could not show up as an uncovered line either [7].
In a ten-row window the same 17 lines arrive [6], seven of them with nowhere to go [2]. The layout engine pads short output and never trims long output [7], so the surplus scrolls the header off the top. What surfaced it was resizing a terminal [10].
Take the 92 tests and the level-max PHPStan run as claims about someone else's workload [5]. They transfer to your code only if your assertions are derived from the component's contract rather than from an observed run. If they were written by pasting output, the percentage tells you the code executes, and nothing about whether the inputs were honored.
Three of the remaining findings are not code at all. The empty-state string was hardcoded English inside a `final` class, so an otherwise Russian interface printed `No matches` and could not subclass its way out [11]. Paging still moved by the configured window size, so PageDown advanced ten rows in a window showing forty [12], a quarter of what the user could see [3]. A status line naming the sort column had to rebuild the widget's own map of column keys to headers, because the widget would not hand it over [13]. Those are properties of the API shape, and a unit test written against that shape agrees with it.
Two upstream bugs are worth reading for the arithmetic. `Tui::stop()` computes `line_count - cursor_row`, a count minus a zero-based index, which already lands one line below the frame, and then writes `\r\n` for a second [14]. A five-line frame in a six-row terminal fits alongside the shell prompt and still loses its top line [15]. Separately, a pseudo-terminal whose size was never set reports `0 0`; the terminal class takes that as real, the renderer floors the content area at one column, the width check rejects one column against zero available, and the process dies with exit code 255 [16]. That is the failure mode waiting in `pty.fork()` wrappers and some CI harnesses [17].
Adoption cost, stated plainly. symfony/tui had zero dependent packages on Packagist on 9 August, and its documentation is still two open pull requests [3][4], while php-tui carries around ninety times the downloads [2]. Declining to pull a second framework in for one console command is a defensible trade [18]. It also makes you the first integrator, and that bill arrived after one evening of real use [19].
Ranked by verification strength, evidence, and original report placement.
Symfony shipped a TUI component in 8.1, comprising a widget tree, a layout engine and a renderer for building full-screen terminal applications in PHP.
php-tui/php-tui has existed much longer, is mature, and has around ninety times the downloads of symfony/tui on Packagist.
On 9 August, when the author started, symfony/tui had zero dependent packages on Packagist.
There was no documentation page for symfony/tui; the documentation is still only two open pull requests.
The author built a table widget that scrolls, sorts and filters over an array of rows; by the time it was tagged 0.1.0 it had 92 tests, full line coverage of src/, and PHPStan at level max with no baseline.
The widget never read the height it was given and returned its configured number of rows whatever the terminal was: at stty rows 10 it produced 17 lines, being a header, fifteen rows and a scroll indicator, into a window with room for ten.
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · September 3, 2026
Follow any of these and your For You feed starts watching them — no settings page required.
build
Thirty lines of Doctrine filter, and the query paths where it is simply not there1 distinct publisher
build
Shopware concedes @deprecated was the wrong signal, and splits it in 6.7.14.01 distinct publisher
build
Rewritten tags beat your pin: what laravel-lang says about Composer trust1 distinct publisher
build
A 100-worker PHP-FPM pool tops out at 500 req/sec on a 200ms endpoint1 distinct publisher
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Reproducible mechanics, one witness
The technical detail is the kind you cannot fabricate casually: stty rows 10 producing 17 lines, a count subtracted from a zero-based index, a width check rejecting one column against zero, exit code 255. Every bit of it comes from the same developer on dev.to, working on his own package, and the component has no documentation page to check his statement of the renderer's contract against. Strong as testimony, unreplicated as fact.
One command, one developer
Zero dependent packages on Packagist when the work began, roughly a ninetieth of the incumbent's downloads, documentation that exists only as two pull requests, and exactly one production use anywhere in this reporting — the author's own console command. That is not a knock on the component; it is why an obvious height bug and an exit-path off-by-one survived to a patch release without anyone else tripping over them.
Buries the part that affects everyone else
The headline sells a testing lesson, and the author is scrupulous about not overselling it — he says outright that his 92 tests were not bad tests and that this is no argument against testing. Meanwhile the two findings that affect every application built on the component, the blank line on exit and the death-by-exit-code-255 in a size-less pseudo-terminal, arrive two thirds of the way down under 'two more turned up that were not mine'. The reporting is quieter than its own material warrants.
Author of the package, author of the patches
He wrote the widget, found the bugs, sent the fixes and then wrote the post — so a story shaped as 'my coverage lied to me, and I patched the framework' flatters both the package and the patcher. What pulls against that: he leads by telling you the rival library is mature and ninety times more downloaded, quotes his own passing assertions verbatim, and does not claim the discovery for his engineering discipline. The self-interest is visible rather than hidden.
Believe the bug, not the size of the lesson
Confidence splits by layer. The specific defects are described precisely enough by the person who hit them that doubting them would be perverse. How far the moral travels — that a green suite tells you nothing about the parts of reality you did not model — is one anecdote on a component almost nobody runs, and the claim about what 8.1.5 contains rests on the patch author's word alone.