Conversation
An inline-editable cell was a write-only surface. Its root carries `wire:ignore.self` so a morph cannot reset the optimistic state it holds mid-edit — and Livewire then stops updating that element's own attributes for the rest of the page's life. `data-server-value` and `data-record-version` were whatever the FIRST render wrote, the MutationObserver watching them had nothing to wake it, and the documented reconcile could not run. Confirmed in a browser: after a header action rewrote the same column, a plain TextColumn in that row refreshed while the editable cell kept the old value, kept the old version, and the user's own next edit came back "Record was modified by another user". The value now arrives on a sync node — a child element the morph does reach — so a poll tick, a modal write or another session's change reconcile the value and the lock version together. The optimistic lock was silently OFF for any model naming its timestamp column something other than `updated_at`. Three hand-rolled copies of the stamp survived on the table side, all reading the literal attribute; on such a model the client rendered the '0' sentinel, which RecordVersion reads as "the client never had a version", so the check was skipped and the write went through unguarded. All three now delegate to RecordVersion. Inline edits no longer skip the render — summaries, rollups and anything derived from the edited value were stale until something else forced one. `refreshAfterEdit(false)` opts back out. `cacheQuery()` served pre-write rows for the whole TTL after any write; keys now carry a write generation that one bump retires. A page change riding in the same commit as a cell edit was swallowed: `setPage()` is a call, the browser queues the edit first, so marking the request has to take a granted skip back rather than refuse a later one. And a save that failed without a server reply reported nothing at all, because init() read its messages out of itself. New: `Table::live()` (poll + change detection, plus the write generation that lets detection see a write landing in the same second as the last checksum), `live(broadcast: true)` for push, and `Action::optimisticLock()` for the modal window, which was the longer and entirely unguarded one. `TableRecordsChanged` is `ShouldBroadcastNow` deliberately: as a queued broadcast it was swallowed whole by a configured queue with no worker, found against a real Reverb where the socket connected, the channel authorized, and no event ever arrived — silently, because polling covered for it. No broadcaster is a dependency; the client half calls only `window.Echo.private()` and `.leave()`, pinned by BroadcasterAgnosticTest. Verified by the browser drivers (verify-live-refresh, verify-live-broadcast, and verify-live-broadcast-real against a real Reverb, which installs on demand and skips otherwise).
The channel named the model with dots (`wire-table.App.Models.Invoice`),
chosen for readability, and that made a wildcard impossible: Laravel compiles
a `{placeholder}` to `([^.]+)`, so the pattern could never match a dotted
class. Every model therefore needed its own hand-written
`Broadcast::channel()` line — and a typo in one raises nothing, because the
subscription is simply refused, the push stops arriving, and polling covers
for it. The broadcast half dies and the table looks fine.
The scope is now one segment (`wire-table.App-Models-Invoice`, `-` for `\`,
which is legal in a Pusher-protocol channel name and cannot occur in a PHP
class name, so it round-trips). `LiveChannel` owns the naming, the decoding
and the registration together, since those three disagreeing is exactly the
failure above:
LiveChannel::authorize(fn ($user, string $model) => $user->can('viewAny', $model));
The callback receives the class name, already decoded, so the wire format
stays inside the package.
Authorizing stays the application's decision, in routes/channels.php, where Laravel expects it: the package registers no channel and calls no policy of its own. Making that automatic would mean either allowing everyone — a security choice nobody asked it to make — or reaching for a `viewAny` policy, which returns false when none exists and so reproduces exactly the silent death this closes. What is automatic now is the reporting. A refused subscription is the one failure that looks like success: the table keeps refreshing on its interval, so nothing appears broken while the push half is dead, and there is no way to notice short of timing the refreshes. Echo's error callback now writes one console warning naming the channel, the status and the call that fixes it. A warning rather than an error, because the fallback is working as designed; and a report only — nothing retries, downgrades the channel or works around a policy. The agnosticism guard matched bare words and failed on a comment using "connector" correctly to explain what hands back the refusal. It matches code shapes now: a guard that punishes writing about the thing it guards gets weakened rather than obeyed. Verified it still catches real coupling by injecting Echo.connector.pusher and watching it fail.
Review of my own code, and this is the one that mattered. `announceTableWrite()` runs after the transaction has committed, and all three callers wrap it in a try/catch that turns a throw into "the save failed". A broadcaster that is down, slow or misconfigured therefore made `updateTableCell()` answer `success: false` for a write that had already landed — and the cell rolls back to the value the database no longer holds, so the user retypes an edit that was never lost. `fillTableCells()` did the same for a whole dragged range. Switching the event to ShouldBroadcastNow is what made it likely: that put the broadcaster's HTTP call inline in the same try. The push is an optimisation with a working fallback, so it is never worth a wrong answer about whether the write landed. Reported rather than swallowed — it belongs in the log, it just does not belong in the response. Both paths are pinned by tests that fail without the guard. Three smaller ones from the same pass: - LiveChannel::authorize() handed the app callback a class-SHAPED string straight off the wire. The documented callback passes it to Gate and a plausible one would do `$model::query()`, so anything that does not name a loadable class is refused before it gets there. - The refusal warning fired on every retry, while its own comment promised one. A console filling with the same paragraph is a console people stop reading. - refresh() rescheduled itself for as long as any cell reported `saving`, which a request that never settles would make permanent: a timer that never stops over a table that never refreshes. Bounded to ~2s, after which the interval has it anyway.
Second review pass, on the parts the first one did not look at. The counter is stored forever, and the docblock said that made it safe. Forever is not a promise a store under memory pressure keeps: `allkeys-lru` evicts a key with no TTL like any other, and the old fallback then handed every key back to generation 0 — a namespace that may still hold slices cached before the first write, whose own TTL has not run out. Losing the counter brought stale rows back. It seeds a fresh generation on a miss instead, so eviction costs a cache miss rather than a wrong answer. The first attempt seeded from `time()` and the test caught it: bump() is seed+1, so re-seeding inside the same second lands exactly on the namespace the first seed used. Random start, which cannot walk back into a range already served. Also documented, not changed: the live listener rides the polling wrapper, so pausing the poll pauses the push. That is right for the Stop control, where "stop the table changing under me" should mean all of it, and arguable for pollWhen(), which is a cost condition rather than a statement of intent — splitting them needs a second wrapper and a push-only host method, which is a product decision rather than a defect.
Measured before touching anything: rendering 500 rows across three editable columns took 453ms, 302µs per cell. It is 303ms now, 202µs — a third off. Three things, in order of how much they cost: - Every editable cell asked for its optimistic-lock stamp TWICE, once as view data and once to build the sync node, and each ask re-read a cast attribute. Mine, from the sync-node work; computing it once per cell is the bulk of the saving. - RecordVersion was never registered, so `app()` built it by reflection on every one of those asks — ~14µs each, for a stateless object with no constructor. Registered as a singleton next to CellSync. - Even a bound singleton walks the container's resolve path (~5µs), which is not free 1,500 times, so both resolvers are now held on the column for the render. The same memoisation the column already does for its input attributes and its skeleton. Guarded by counting rather than by timing: a millisecond threshold fails on a loaded CI box and gets deleted, while "how many times was it computed" is the property that regressed. Lives in TablePrimitiveRenderTest, which is already the per-row-waste fuse. Not done, and worth measuring before anyone tries: at 202µs a cell the remaining cost is the Blade render and the Eloquent attribute read, not the plumbing around them.
The editable-cell optimisation was measured on 500 rows x 3 editable columns,
which is not the shape these tables have: 25 columns over 20 rows a page means
most cells are plain, so the saving is proportionally smaller and the
plain-column path is what dominates.
Interleaved ON/OFF/ON with the 0-editable row as a control group, because the
first attempt at this comparison was contaminated — it showed the control
moving 3.5x, which my change cannot cause, so the numbers were thrown away
rather than reported.
editable before after
0 of 25 46.3 39.8 <- control; the ~6ms between them is drift
5 of 25 77.0 63.3
10 of 25 107.3 87.4
25 of 25 206.4 167.4
Reports, never asserts, and does not run in `composer test` — the root
phpunit.xml carries Unit and Feature only.
Typing "Ada Lovelace" could not find the row whose first name is in one column
and surname in another, and a number could be searched for but never compared.
Three capabilities are now opt-in per table through Table::search(): tokenize()
splits on spaces and ANDs the words, each still ORing across all columns, which
is what lets a name spanning two columns match; ranges() reads >100, 10..20 and
the same over dates; wildcards() lets * and ? stand in. Everything is off by
default, so an unconfigured table matches byte for byte what it always did.
A comparison is only ever asked of a column that can answer it — the value type
comes from the model's casts, or from Column::searchAs() where the casts cannot
speak for the column — and one no column can answer is searched as the literal
text typed, rather than contributing an empty WHERE group that matches every
row. searchAs('code') covers the structured reference (8866 01, 8866 02): the
space inside such a code is also what splits the term, and since 8866 01..08 and
praha 10..20 cannot be told apart syntactically, the range carries the word
typed before it and each column takes the reading it can answer. A code column
completes both bounds into one BETWEEN; a numeric column ignores the word.
Four things it was getting wrong underneath:
- A typed % was a live LIKE metacharacter, so searching "50%" returned the whole
table. Escaping is back, with ! as the escape character rather than the
backslash: ESCAPE '\' is a syntax error on MySQL and MariaDB, which SQLite and
PostgreSQL accept happily — the reason the first attempt passed its tests and
died on MariaDB.
- ILIKE against a numeric or date column is an error on PostgreSQL, not a miss,
so a searchable amount column took the page down there while working
everywhere else. Cast to text. Found by running the behaviour suite against a
real PostgreSQL, not by reading the code.
- Column::searchable(['first_name', 'last_name']) was stored and never read on
an ordinary column; only StackedColumn and SplitColumn declared the contract,
while the docs described the array form as working.
- Searching for "0" searched for nothing.
The docs claimed MySQL used MATCH ... AGAINST and PostgreSQL to_tsvector. There
is no fulltext code in the repository and there never was; both languages now
describe the LIKE/ILIKE match that actually runs.
Verified against MySQL 8, MariaDB 11 and PostgreSQL 16 as well as SQLite.
A page is a slice of an ordering, and the query carried none unless the user
had sorted. LIMIT/OFFSET over an unordered result is undefined, and nothing
says two pages were sliced from the same order.
SQLite and MySQL/InnoDB hand back primary-key order, so it never showed.
PostgreSQL stores rows in a heap and an UPDATE writes a new tuple at the end
of it rather than in place, so editing a row on page one shifts everything
behind it forward by one:
page 1 (LIMIT 2 OFFSET 0) -> T1, T2
edit T1 -> UPDATE
page 2 (LIMIT 2 OFFSET 2) -> T4, T5 <- T3 is simply gone
The user never sees T3 and nothing reports an error. The same hole exists on
every engine whenever the sort column has duplicate values, since ties come
back in whatever order the engine found them.
Every table query now ends with its primary key as a tiebreaker, following the
direction already in force so that newest-first stays newest-first among rows
the sort calls equal. It is appended after everything else that orders,
including a column's own sortUsing() callback, which runs outside the query
pipeline — applied any earlier it becomes the primary sort and silently
replaces the ordering it exists to stabilise, which is what the first attempt
at this did. Skipped where a key is not a legal ordering term: GROUP BY
(PostgreSQL rejects an ungrouped term, MySQL too under ONLY_FULL_GROUP_BY),
DISTINCT (PostgreSQL wants ordering terms in the select list) and unions.
Found while diagnosing seven PostgreSQL failures in PerPageAndQueryCacheTest
and PerPageMergedCommitTest that read like a caching bug and were not: the
write committed and the cache was retired correctly, the rows had moved. Those
seven now pass untouched, and the PostgreSQL suite is green for the first time.
… layouts The 1.15.0 batch, committed together. Added: - Builder — a repeater whose every item picks its own block type. Extends Repeater deliberately: the form runtime identifies a repeated subtree by instanceof in ten places. Items store ['type' => …, 'data' => […]], so a field named "type" inside a block cannot collide with the discriminator. - ColorColumn, CheckboxColumn, RatingColumn and TagsColumn, closing the gap where a table could not show what an infolist entry already could. None re-encodes a palette; the tag chip is the same badge chrome as BadgeColumn. - TrashedFilter — soft deletes were not covered by any filter. It constrains no column: it switches which global scope applies. - CheckboxList::segmented() / ::buttons(), the multiple-choice half of a vocabulary Radio already had. The shared part moved into HasChoiceVariants rather than being written twice. - Repeater::table() — repeat short rows as a table instead of a card each. Hiding the per-cell label is a new canonical HasLabel::hiddenLabel(): the label still resolves, so it can head the column and serve accessibility. - A shared harness for the CDP drivers (workbench/scripts/lib/cdp.mjs), so a driver file is only its checks. Existing drivers are left alone. Fixed: - Column::editable() stopped pretending it can choose an editor. - Column::authorizeInline() was a silent no-op — the ability it names was never checked and every inline edit went through. - hintIcon() and hintColor() did nothing, on all 41 field types. - extraAttributes() reached nothing on a form field, and neither it nor extraHeaderAttributes() reached anything on a table column. - extraInputAttributes() moved to the fields that actually have an input. - A Select whose column is cast to an enum threw the moment a user cleared it. - TrashedFilter::options() accepted a value it could never apply. Docs: Builder::table() and TrashedFilter::options() are declared and throw, so both are now on their pages with the reason. The Block API heading was renamed to "Declaring a Block": it was the first heading matching the API check, which therefore read Block's methods as Builder's and flagged Block::icon() — which does exist, via HasIcon — as a method Builder does not have. docs:check is green for the first time.
Twenty-eight entries had accumulated under `## [1.15.0]`, a section the 1.15.0 branch shows was released with twelve. Everything written on this branch since went into it — Table::live(), Action::optimisticLock() and the TimePicker work before this batch, then the Builder field, the four new columns, TrashedFilter and the search work — so the changelog claimed a released version contained features that were not in it. 1.16.0 rather than 1.15.1: nine of the twenty-eight are new features, and a patch release advertising a new Builder field would mislead anyone who updates only patches. The 1.15.0 section is restored verbatim from the release branch, so the twelve entries it shipped with are exactly what it says again. Note the branch is still named 1.15.1.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
The source page was rewritten in f6b86d8 but the copy wire-boost ships was last touched in 1.11.0, so the docs-check drift guard has been failing on it since. No content of its own — purely what boost:sync-docs mirrors.
Both were reproduced before being touched: an eight-run loop failed three times, which is how a single run occasionally reported two. FillHandleTest read the versions it holds BEFORE freezing the clock. A version is the row's stored updated_at, so the test's premise — held version and write stamp inside one second — only held while beforeEach's seeding and the freeze happened to land in the same second. A real second boundary between them made the second fill genuinely stale and the refusal fired, so the test failed for the very reason it exists to document. Freeze first, restamp onto the frozen second, and the premise holds by construction. Its reset also moved to afterEach: both clock-driving tests reset as their last statement, which a failing assertion skips, leaving the freeze in place for everything after it. The benchmarks asserted flat millisecond ceilings, which say as much about the runner as the code: the tightest sat 61ms under a 100ms ceiling idle and reached 87ms with the CPU contended. benchBudget() applies headroom for load (calibration cannot help there — a trivial loop keeps its cache and its scheduler slice while real work nearly doubles) and a calibration ratio for a slower machine. Verified 12/12 under twelve-way contention, and still catching a deliberately slowed getResponsiveClasses().
…n owners Table was 2,847 lines: a god object by breadth, and the four clusters that carried real logic rather than configuration are the ones worth moving. All 211 public signatures are unchanged, so no docs page moves with this. Polling: PollingConfig owns the eight settings and the interval format, a thin HasPolling exposes them. The wire:poll attribute itself turned out to have a second hand-written copy in the widget concern — missing keep-alive — so both now delegate to Foundation\ValueObjects\PollDirective. Whether polling is on deliberately stays with each surface: a widget polls when it has an interval, a table when it was told to. Introspection: ~250 lines of column info, schema reads and query planning were reachable only through the dump/dd sugar, and had never been covered by a test. TableIntrospector takes the table as a parameter, so every answer can be exercised without a Livewire host. dump()/dd() stay on Table, where a developer types them. Writing the tests turned up that debugQueryPlan() falls back to defaultSort()'s column but not its direction — preserved, and now written down. Data source: HasDataSource draws the line the two halves kept blurring — where the rows come from, against TableQueryService's how they are narrowed. Columns: ColumnSet answers the name lookup from a map built once instead of a scan, and keeps first-declaration-wins on a duplicate name, which is what the scan did. TableQueryService keeps its own private copy: it is handed an array a plugin hook may have replaced, not the table's set, so folding it in would build a map to do two lookups.
->perPageOptions([10, 25, 50, 'all'])
->perPage('all') // or as the table's own default
The word never survives configuration: it is stored as Table::PER_PAGE_ALL,
an integer, because the select, the per_page query-string parameter and the
query cache key all compare page sizes strictly as integers. It sorts last
however it was declared — the sentinel is negative, so sort() would otherwise
put it first.
The sentinel cannot be handed to the paginator. A negative limit is dropped
by the query builder, so the rows would be right, while the paginator still
divides the total by it, so the page count would be negative. paginateQuery()
counts first instead, and max(1) keeps an empty table from dividing by zero.
Not among the shipped options, and the gate is a guard that already existed:
normalizePerPage() falls back to perPage() for any size the table does not
offer — the same line that stops a forged perPage: 500000 — so a table reads
its whole source into memory only when it said 'all' itself. There is no
ceiling behind it, unlike bulkMaxRecords(), which the docs say plainly.
Verified in a browser, not only in tests: the editable-per-page driver now
also drives the 'all' option and asserts the server keeps the sentinel rather
than clamping it (14/14).
The table coverage floor rises 88 -> 90, earned mostly by the introspection
tests in the previous commit.
The docs-check workflow and the npm scripts both invoke verify-docs-standard.mjs and verify-site-ui.mjs, and build.php and the page/home templates include not-found.php and head-meta.php, but none of those files were ever added to the index. CI got as far as the build step — include() only warns on a missing partial — and then died on the standard check with MODULE_NOT_FOUND. Adds the two checkers, the 151-entry baseline of pages that predate AI_DOCS_STANDARD.md, the standard itself, and the two templates.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Added
Table::search(). The box matched whatever was typed as a singleLIKE '%term%'across every searchable column, which meantAda Lovelacecould not find the row whose first name is in one column and surname in another, and a number could only ever be searched for, never compared. Three capabilities are now opt-in per table, through a fluentSearchConfig:tokenize()splits on spaces and ANDs the words — each word still ORs across all columns, which is exactly what makes a name spanning two columns match — with double quotes keeping a phrase together and never being read as an operator;ranges()reads>100,>=100,<10,<=10,=42,10..20,10..,..20and the same over dates;wildcards()lets*and?stand for runs and single characters. Everything is off by default, so an unconfigured table matches byte-for-byte what it always did — the whole term, one group, one substring. A typed date is read at the granularity it was written (2026-01-31is that day,2026-01that month,2026that year), so<=2026-01-31still includes a row stamped 23:30 on the 31st, which is the off-by-a-day this kind of feature usually ships with. A comparison is only ever asked of a column that can answer it — the value type comes from the model's casts, or from the newColumn::searchAs('numeric'|'date')where the casts cannot speak for the column — and a comparison no column can answer (>100on a table of names) is searched as the literal text that was typed rather than contributing an empty WHERE group that matches every row. The engine lives inwire-core(Core\Query\Search\) as a parser producing tokens, a compiler turning a token plus a clause into SQL, and the three driver strategies reduced to the one thing that genuinely differs between engines:LIKEversusILIKE. Comparisons are plain portable SQL and are therefore built once rather than three times over.searchAs('code')covers the structured reference —8866 01,8866 02— where the series is shared and the tail is zero-padded: typing8866 01..08yields oneBETWEEN '8866 01' AND '8866 08'rather than aLIKEper number in the range. The space inside such a code is also what splits the term, so the range arrives separated from its series; rather than letting one reading win at parse time (8866 01..08andpraha 10..20are the same shape and cannot be told apart syntactically), the range carries the word typed before it and each column takes the reading it can answer — a code column completes both bounds with the series, a numeric column ignores it and compares1..8. Comparing as text only orders correctly while the width is constant, which is the assertionsearchAs('code')makes, so the number must be typed as it is stored; a range crossing a width boundary is completed rather than refused (8866 50..100reads as050..100, since a hundredth member can only exist in a three-digit series). Seedocs/table/overview.md§ Search syntax.ColorColumnrenders a stored CSS color as a swatch plus its value (swatchOnly()for a narrow column), the table-side counterpart ofColorEntry.CheckboxColumnis an inline checkbox writing a boolean straight to the record — the same optimistic write path, the same server-sidecanEdit()guard and the same sync node asToggleColumn, for tables too dense for a switch track.RatingColumndraws a numeric score as stars (max(),allowHalf(),showValue()), the read-only half of theRatingfield's vocabulary.TagsColumnrenders a multi-value state — array, JSON cast,Arrayablerelation collection, or aseparator()-split string — as chips, withlimitList()collapsing the overflow into a "+N" chip. None of them re-encodes a palette: the tag chip is the sameRendersBadgeSurfacechrome asBadgeColumnand takes the samecolors()map, and rating/checkbox colors resolve through the canonical Foundation owners. The three state-driven ones (ColorColumn,RatingColumn,TagsColumn) memoise their view render by its data, so a page of rows sharing a color, a score or a tag set costs one render each rather than one per row.ToggleColumnandCheckboxColumnnow shareCanEditBooleanCell, which owns the server-side disabled guard — the point being that a new boolean cell cannot ship without it. Browser-verified over CDP byworkbench/scripts/verify-column-surfaces.mjs(20/20) against a new/previews/table-column-surfaces: the swatch colors as the browser actually parsed them, the seeded row whose stored value isred; background-image: url(…)drawing no background and issuing no request for it, a half star clipped only where halves are allowed, the+Noverflow chip, and a checkbox cell committing through Livewire and surviving a fresh GET. Seedocs/table/columns/.TrashedFilter— soft deletes were not covered by any filter at all. Unlike every other filter it constrains no column: it decides which global scope applies, mapping towithTrashed()/onlyTrashed(). Three states of which only two are options — "without deleted" is the placeholder, i.e. clearing the filter — rendered through the same select surface asSelectFilter, so an open soft-delete filter looks like any other. ItbypassesPlanner(), since a scope change is not a column/operator/value definition. A model withoutSoftDeletesnow fails with aTableConfigurationExceptionnaming both the filter and the model, rather than as an undefinedonlyTrashed()deep inside the query builder — and the check runs only when the filter is active, so a cleared filter never inspects the model. It extendsSelectFilterrather thanFilter: the shared select panel callsisSearchable()on whatever it is handed, so the first version rendered a 500 on any table that used it — a failure every unit test missed, because they only asked the filter for its view's name. Browser-verified byworkbench/scripts/verify-trashed-filter.mjs(14/14) against a new/previews/table-trashed-filter, which counts the rows that actually come back: 4 live, 2 withonly, 6 withwith, back to 4 when cleared. Seedocs/table/filters/trashed.md.CheckboxList::segmented()/::buttons()— the multiple-choice half of the toggle-button vocabulary.Radiohas hadsegmented()andbuttons()for a while; picking several options in that shape had no equivalent, so a multi-select of three short values was a column of checkboxes. Rather than adding a parallel field, the shared part of Radio's API — the variants, per-optionicons()andcolors(),inline(), and the size/color resolvers — moved intoHasChoiceVariants, which both fields now use: one vocabulary, one chrome, and a single-choice and multi-choice control that look alike. In these variants the field shows the options alone; search, bulk toggle, grouping and columns are list chrome and do not apply. Radio's owncardsvariant stays with Radio. Browser-verified byworkbench/scripts/verify-choice-variants.mjs(15/15): the peer-checked pill actually paints, and — the part markup cannot show — a second click adds to the selection rather than replacing it. Seedocs/forms/fields/checkbox-list.md.Repeater::table()— repeat short rows as a table instead of a card per item. One column per schema field, headed once, with the per-cell label hidden so it is not repeated on every row; same state paths, same add/remove/reorder endpoints, only the arrangement differs. Hiding that label is a new canonicalHasLabel::hiddenLabel()— the label still resolves, so it can head the column and serve accessibility, which is what separates it from clearing the label. Per-item collapsing has no meaning for a row, socollapsible()is ignored in this layout. Browser-verified byworkbench/scripts/verify-repeater-table.mjs(16/16): each field heads one column, every row's inputs still bind to their own item path, and add/remove run through the same endpoints. Seedocs/forms/fields/repeater.md§ Table Layout.workbench/scripts/lib/cdp.mjs. Each of the ~50 existing drivers carries its own copy of the same 90 lines: spawn headless Chrome, speak DevTools over a raw WebSocket, collect console errors and 4xx/5xx, screenshot, print the summary the sweep greps for. The five new drivers import it instead, so a driver file is now only its checks.finish()always asserts a clean console and no 419 — a driver that renders the right markup over a broken Livewire roundtrip has verified nothing. Existing drivers are deliberately left alone: they pass, and rewriting 50 working files to prove a point is how a green suite stops being trustworthy. New workbench fixture alongside it: aDocumentmodel with soft deletes, a stored CSS color, a score and a tag list — its own model rather than columns bolted ontoTask, which dozens of tests query and which addingSoftDeletesto would change every one of.Builder— a repeater whose every item picks its own block type.Builder::make('content')->blocks([Block::make('heading')->schema([…]), …])renders an "add" trigger that opens a picker of the declared blocks; each stored item is['type' => …, 'data' => […]]and its fields bind under<path>.<index>.data, so a field namedtypeinside a block cannot collide with the item's own discriminator. It extendsRepeaterdeliberately rather than standing beside it: the form runtime identifies a repeated subtree byinstanceof Repeaterin ten places (reactivity, flattening, save, relationship handling), and a sibling class would have had to be threaded through every one of them. It therefore inherits add/remove/reorder, per-item reactivity and item limits unchanged; onlyrelationship()does not apply, since mixed block types have no single related model. Block rules mount at<path>.*.data.<field>— and because the resolver validates by wildcard path, blocks sharing a field name share its rules, which is stated in the docs rather than papered over. An item whose stored type names no declared block renders its type and no fields instead of breaking the form: stored content outlives the code that declared it, and a renamed block must leave the content recognisable, movable and deletable. Browser-verified byworkbench/scripts/verify-builder.mjs(15/15) against a new/previews/forms-builder: the picker lists every declared block, choosing one appends an item edited with that block's schema, typing stays inside the item it was typed into, and removing one re-binds the paths of the rest. Seedocs/forms/fields/builder.md.Table::live(). Polling and change detection turned on together (live()is exactly->poll($interval)->pollChangeDetection()), plus the piece that was missing under both: a write generation, a cross-process counter scoped by model that every write through a table moves on. Without it change detection is blind to a write landing in the same second as the previous checksum —updated_atis stored to the second, so such an edit is indistinguishable from no edit, and the next tick compares against that same second again. It was not shown late; it was not shown at all. The counter also retires every cached slice of the table at once, which is what makescacheQuery()and a live table usable together — the cache namespace is derived from the SQL, so every filter and search term a user ever opened has its own entry and the writer knows none of them to delete.live(broadcast: true)adds the push half:TableRecordsChangedfires wherever a write retires the cache, and the page subscribes, so another session's write arrives on commit instead of on the next tick (measured at ~690ms end to end against a 2s interval). The event carries no data — a nudge to re-read, not a payload to apply — so each client refreshes through its own component and re-evaluates its own authorization, filters, sort and page server-side; the channel therefore has nothing on it but the scope name, and is authorized by the app like any other (TableRecordsChanged::channelFor()names it readably:wire-table.App.Models.Invoice). No broadcaster is a dependency and none is privileged — the client half calls nothing butwindow.Echo.private()/.leave(), a surfaceBroadcasterAgnosticTestpins — though Reverb is the only one the path has actually been run against, by a driver that runs on demand locally and is not part of CI. It isShouldBroadcastNow, notShouldBroadcast, and that word is the feature: a queued broadcast is swallowed whole by the very common setup of a configured queue with no worker running for it — silently, because polling covers for it and the table refreshes a moment later anyway. Found exactly that way, against a real Reverb: the socket connected, the private channel authorized, and no event ever arrived, while the run looked healthy. The trade is stated in the docs — the write now waits on the broadcaster's HTTP call before it answers. Every way the transport can fail is harmless — no Echo on the page, no connection configured, authorization refused, a dropped socket — because the interval is still running underneath: a slower table, never a stale one. A burst of writes coalesces into one re-read, and a re-read is held off while one of your own cells has a save in flight. Seedocs/table/advanced.md§ Live Tables.Action::optimisticLock()— refuse a record action whose record moved while its modal was open. An inline cell edit has been locked since it shipped; the modal path, which has by far the longer window (open, read, type, walk away, submit), carried nothing and overwrote whatever had happened in between. The baseline is captured when the frame is pushed and compared on submit throughRecordVersion— the same object and the same convention the cell edit compares with, so there is one answer to "has this row moved" rather than two that can drift. On refusal the modal closes and a warning is raised; leaving the form up would put the user back in front of values that are no longer there with no way to tell. Off by default, deliberately: a moved record only invalidates an action that decided something from what it read. Approving an invoice whose total changed underneath is a lost update; deleting a record someone else renamed is not, and refusing it would be a new failure mode in exchange for nothing. Lives onActionrather thanBaseActionbecause only a row action has one record to lock against. Seedocs/table/actions.md.Fixed
LIMIT/OFFSETover an unordered result is undefined, and nothing says two pages were sliced from the same order. SQLite and MySQL/InnoDB happen to hand back primary-key order so it never showed — but PostgreSQL stores rows in a heap and anUPDATEwrites a new tuple at the end of it rather than in place. Editing a row on page one therefore shifted everything behind it forward by one, and the record that would have led page two was skipped: the user never saw it, and nothing reported an error. Demonstrated against a live PostgreSQL — page 1 shows T1, T2; edit T1; page 2 shows T4, T5, and T3 is simply gone. The same hole exists on every engine whenever the sort column has duplicate values, since ties are returned in whatever order the engine found them. Every table query now ends with its primary key as a tiebreaker (Core\Query\StableOrder), following the direction already in force so newest-first stays newest-first among equals. It is appended after everything else that orders — including a column's ownsortUsing()callback, which runs outside the query pipeline; applied any earlier it becomes the primary sort and silently replaces the ordering it exists to stabilise, which is exactly what the first attempt at this did. Skipped where a key is not a legal ordering term:GROUP BY(PostgreSQL rejects an ungrouped term, MySQL too under ONLY_FULL_GROUP_BY),DISTINCT(PostgreSQL requires ordering terms in the select list) and unions. Found while diagnosing seven PostgreSQL test failures that looked like a caching bug and were not: the write and the cache invalidation were both correct, the rows had simply moved.%typed into the search box matched every row. The term went into the LIKE pattern raw, so its%and_were live metacharacters: searching50%returned the whole table and turned every search into a full scan, and_quietly matched any character. They are escaped now — and the escape character is!, not the backslash. This was fixed once before and reverted, becauseESCAPE '\'is a syntax error on MySQL and MariaDB (the backslash inside a string literal escapes the closing quote) while SQLite and PostgreSQL accept it happily, so the test suite stayed green and every search on MariaDB died.!has no special meaning in a string literal on any supported engine, so one pattern shape works everywhere; the clause is always declared explicitly because SQLite's LIKE has no default escape character at all. A test now pins that the escape character is not a backslash, with the reason next to it.amount ILIKE '%50%'on anumericcolumn is not a search that misses — it isoperator does not exist: numeric ~~* text, an error. MySQL and SQLite coerce silently, soTextColumn::make('amount')->searchable()worked everywhere else and failed only on PostgreSQL, and only once somebody typed into the box. The column is now cast to text there. The cast is unconditional rather than driven by the inferred value type: the type is a guess assembled from casts and registered schema and can be absent or wrong, while the column's real type is what the server enforces — andILIKE '%…%'was never going to use an index either way. Found by running the behaviour suite against a real PostgreSQL 16, not by reading the code.Column::searchable(['first_name', 'last_name'])did nothing on an ordinary column. The list was stored and never read: onlyStackedColumnandSplitColumndeclaredHasSearchColumns, so the planner searched a plainTextColumn's own name alone whiledocs/table/columns/index.mddocumented the array form as working.Columnimplements the contract now, so the columns listed are the columns searched.0searched for nothing.! empty($search)treated the string"0"as an absent term, so the table answered with every row instead of the ones containing a zero. A whitespace-only term is still no search.docs/table/overview.mdclaimed MySQL usedMATCH … AGAINSTfulltext and PostgreSQLto_tsvector / ts_query, with SQLiteLIKEas a "fallback". There is no fulltext code anywhere in the repository and there never was: all three engines do aLIKE/ILIKEsubstring match. Both language versions now describe what actually runs.Column::editable()stopped pretending it can choose an editor. Its$type/$optionsarguments were documented as picking a'text'/'select'/'toggle'editor, and no view has read an editor type in any revision since the first commit — verified against every historical revision, not inferred.TextInputColumn,SelectColumnandToggleColumnhave existed since that same commit and always did the actual rendering, so this was never a regression: it was a second route that was drawn and never connected. An ordinary column with->editable(true, 'select', […])rendered the plain value, and the fill handle skipped it too, since the client looks for an editable root ([data-record-key][data-column-name]) that only a dedicated column emits. The parameters are gone from the signature; a variadic swallows and refuses them, naming the column type to use instead, and the properties and their two getters are deleted. The variadic is not decoration: PHP drops surplus positional arguments without a word, so simply removing the parameters would have leteditable(true, 'select', […])— the exact call the docs taught — go on doing the silent nothing this removed. A namedtype:argument lands in the same variadic, so both call styles get the same message.editable(bool)stays and is not deprecated — on a dedicated column it is the switch that renders the editor or the plain value (TextInputColumn::make('name')->editable(false)shows text), andisEditable()has three real consumers: the write guard,isFillable(), and suppressing the row link on an editable cell. The docs that taught the dead form —table/columns/editing.mdandauthorization.md, in both languages — are rewritten, which mattered most: they were the only place the pattern was coming from.Column::authorizeInline()was a silent no-op — the ability it names was never checked, and every inline edit went through.permission()guards seeing a column;authorizeInline()was added to guard writing it inline, which is a different and narrower question (show a price to everyone, let only a manager edit it).CellEditPipeline::guard()consulted the first and never the second, socanInlineEdit()had zero callers in the whole repository: an author who wrote->authorizeInline('edit-prices')believed the cell was protected, the UI rendered an editable cell, and the write was accepted. Now checked alongside the other guards, and refused with the same message. Note the fail-closed consequence, which is intended:Gate::allows()denies a guest unless the ability accepts a nullable user, so an unauthenticated visitor cannot edit an ability-guarded cell. Found by an audit that walked every fluent setter on every built-in component type and asked whether the value it stores ever reaches anything that renders or acts on it.hintIcon()andhintColor()did nothing, on all 41 field types. The hint vocabulary ishint()+ an icon + a color, but the shared field wrapper rendered only the text — both other setters stored their value and nothing ever read it. The wrapper now renders the icon next to the hint and colors the row through the canonicalHasColorpalette, defaulting to the muted gray it always used. Closures andColorenums resolve as they do everywhere else.extraInputAttributes()moved to the fields that actually have an input, and now works there. It lived onHasExtraAttributes, which every component shares, so 49 types offered it — widgets, infolist entries,Placeholder,Alert— and not one view implemented it. Counting the field views showed why a blanket implementation was the wrong answer: 13 have exactly one input, 8 have two to four (aRadiohas one per option,KeyValueone per row — no single element the attributes could mean), and 10 have none at all. So the setter moved to its ownHasExtraInputAttributesconcern, mixed into the ten fields where one element carries the value (TextInput, Textarea, Checkbox, Hidden, Toggle, Slider, Select — and through it BelongsToSelect/MorphToSelect — ColorPicker, DateTimePicker, TimePicker), and is rendered on that element. The attribute fragment is built and escaped once in PHP (getExtraInputAttributesHtml()) rather than looped in ten views,truerenders as a bare boolean attribute andfalse/nullare dropped. Removed from every other type — where calling it was already a no-op, so no working code can break. The shared combobox partial takes the fragment as a parameter, since more than one host renders it.extraAttributes()(the outer element) stays universal.extraAttributes()reached nothing on a form field, and neitherextraAttributes()norextraHeaderAttributes()reached anything on a table column. Three setters, declared and documented, whose values no view read: on a field the attributes now land on the wrapper element (one place, every field type), and on a column they land on the cell and on the header cell respectively. Both column values are resolved once per column in the render-once preamble rather than per cell, so a table of N rows does not pay for them N times; header attribute values are escaped on the way out, while the cell setter stays the raw attribute string its signature promises.Selectwhose column is cast to an enum threw the moment a user cleared it. The empty choice is what a native<select>submits for the placeholder and it arrives as''— not a valid backing value for any enum, so the cast raised"" is not a valid backing value for enum …on save; on a plain nullable column it quietly wrote an empty string where the author meant "nothing".Selectnow implementsDehydratesStateand stores an unselected single select as null. Multi-selects are untouched: their empty state is already[], which the array cast stores correctly.TrashedFilter::options()accepted a value it could never apply. Inherited fromSelectFilter, it wrote an option map thatgetOptions()overrides — the filter switches a soft-delete scope rather than matching values, so there is nothing for an arbitrary option to do. It now throwsTableConfigurationException::fixedFilterOptions()naming the filter and pointing atwithTrashedLabel()/onlyTrashedLabel(), which are the two labels that can change.wire:ignore.selfso a morph cannot reset its Alpine state; what was not accounted for is that Livewire then stops updating that element's own attributes for the rest of the page's life.data-server-valueanddata-record-versionwere therefore whatever the FIRST render wrote, theMutationObserverwatching them had nothing to wake it, andsyncFromServer()could not run. Confirmed in a real browser rather than reasoned about: after a header action rewrote the same column, a plainTextColumnin that row refreshed while the editable cell kept the old value, kept the old version, and the user's own next inline edit came back "Record was modified by another user". The fresh value now arrives on a sync node — a small child element the morph does update and the cell watches — so a poll tick, a modal write or another session's change all reconcile the value and the lock version. Two comments anddocs/table/columns/editing.mdhad asserted the opposite ("Polling refreshes each cell's version on the next cycle"); the claim was never true.updateTableCell()skipped unconditionally to protect the cell's Alpine state — which the sync node above now does properly — so summaries, rollups, any column derived from the edited value and the row's own position under the current sort all kept their pre-write values until something else forced a render. A write renders by default;Table::refreshAfterEdit(false)opts back out for a table where the query is expensive and nothing on screen depends on the edited value.updated_at. Three hand-rolled copies of the version stamp survived on the table side — theHasRecordVersiontrait plus inline@phpin the text-input and select cell views — all reading the literal->updated_at. On a model withconst UPDATED_ATthat attribute is absent, so the client rendered the'0'sentinel, and'0'means "the client never had a version" toRecordVersion::conflicts(): the check was skipped and the edit went through unguarded. Demonstrated end to end — a concurrent change overwritten withsuccess: trueand no warning, while the same edit carrying the real stamp is correctly refused as a conflict. All three now delegate toRecordVersion, the canonical owner, which resolves the column viagetUpdatedAtColumn(); the panel side had already been moved onto it when this was first found, and the table side had not.cacheQuery()served stale rows for the whole TTL after any write.invalidateTable()cleared the in-memory caches and never touched theCache::remember()entry, so a modal action, an inline edit or a fill left the table showing pre-write data — through a full page reload, not merely until the next poll. Cache keys now carry the write generation, so one counter bump retires every cached slice at once.skipTableRender()was enough.setPage()is a call, ordered by when the browser queued it, and the browser queues the edit first — clicking a pagination link blurs the input on the way, and the blur is what commits the cell. The skip was already granted by then.markTableViewChanged()therefore takes a skip back rather than merely refusing to grant one, via the same storeskipRender()writes to; it is now the one way to say "this request changed what the table renders", and covers the page, column visibility andresetColumns()alongside the paths that already worked.wireEditableCell.init()read its three messages out ofthis.messagesand assigned them straight back to it, so all three wereundefinedfor the life of the component while thedata-msg-*attributes carrying the real translations were never read by anyone. The visible consequence was in thecatchbranch: offline, or on a 500, the value rolled back anderrorwas set toundefined, whichx-showtreats as false — the user's edit disappeared with no message. Read off the DOM now.