bricknpc/eloquent-tables is a Laravel package that renders fully-featured HTML tables from PHP class definitions. A user defines a Table subclass — a query, some columns, optionally actions, filters, sorting, searching, and pagination — and the package produces the markup, accessibility...
php artisan boost:add-skill bricknpc/eloquent-tables
Save this content to: AGENTS.md
# Eloquent Tables — project guide for agents
`bricknpc/eloquent-tables` is a Laravel package that renders fully-featured HTML tables from PHP class
definitions. A user defines a `Table` subclass — a query, some columns, optionally actions, filters, sorting,
searching, and pagination — and the package produces the markup, accessibility attributes, and JavaScript. The
selling point is that no front-end work is required: everything is expressed in PHP.
- **Requires** PHP `^8.4|^8.5` and Laravel `^12.0|^13.0`. Bootstrap 5 is the only theme so far.
- **Depends on** `illuminate/database`, `illuminate/support`, `illuminate/http`, and `illuminate/cookie`. Any
`illuminate/*` package is fair game — this is a Laravel package and Laravel is built on illuminate — but only
require one when you actually need it. If you use a class from a package that is not required yet, add it to
`composer.json` in the same change rather than relying on it arriving transitively.
- **Every long-lived branch is named after a version.** There is no `main` and there are no `release/*` branches.
The default branch is the current major, `2.x` today, and it **tracks the released state**: the only *shipped
code* that lands on it directly is a hotfix, and a hotfix is tagged the moment it merges, so what users install
never drifts from the latest tag. Repository infrastructure that has to live on the default branch to work at
all is the exception and lands untagged: CI config, `.github/dependabot.yml`, `SECURITY.md`, `AGENTS.md`. None
of it reaches the published package, so none of it needs a version.
- **Ordinary work goes on a next release branch, never on the major branch.** It is named after the version it
will become, so `2.1` for a minor or `3.x` for a major, and it is cut from the current major branch the first
time something is proposed for that release. **Only one exists at a time.** Both `feature/*` and `bugfix/*`
branch from it and merge back into it by PR, named `feature/<issue-nr>-descriptive-name` with the issue number
optional.
- **`bugfix/*` and `hotfix/*` are not synonyms here, and that is the easiest thing to get wrong.** A **bugfix**
targets the next release branch and ships with that release like anything else. A **hotfix** targets the current
major branch, is for something that cannot wait, and **merging it cuts a release**: the major branch is tagged
as a patch straight away, so a hotfix on 2.0 ships as 2.0.1. A hotfix also has to be **cherry-picked into the
next release branch**, or the fix is absent from the branch all other work is built on.
- **A release is cut differently depending on whether it is a minor or a major.** A minor merges its branch into
the current major branch and tags that: `2.1` merges into `2.x`, `2.x` is tagged `2.1.0`, and `2.1` is retired.
A major keeps its own branch: `3.x` comes off `2.x`, and when ready it becomes the default branch and is tagged
`3.0.0`, leaving `2.x` as the previous major taking hotfixes until its support ends.
- **Cutting a next release branch means updating two literal branch names**, both stored outside the branch they
name, so neither moves on its own. `target-branch` in `.github/dependabot.yml`, three times, once per ecosystem;
and the deployment branch policy on the `github-pages` environment, which is a repo setting no agent can reach
(it is on the pattern `*.x`, so it only needs attention if a major is ever named differently). Dependabot in
particular reads its config from the **default branch only**, so that edit has to land on `2.x` and is a
legitimate untagged commit there, the same as a CI or `AGENTS.md` change. Work like that goes on a `task/*`
branch, and merging it **does not cut a release on its own** the way a hotfix does. Tag it as a patch only if
you actively want one released; nothing about the merge forces it.
- **At most three majors exist at once**, each with one role: the **active** major, one **older** major that may
still take hotfixes, and one **next** major that may be in progress. Once an older major's support ends it
accepts no more PRs.
- **1.x was not carried forward.** The last 1.x release was 1.2.0, and rather than staying on as the supported
older major it reached end of life when 2.0 shipped. So 2.0 is the only supported version, there is no `1.x`
branch, and there is nowhere to backport a 1.x fix to. The user-facing matrix and policy live in
`docs/docs/supported-versions.md`, which is the source of truth to update when any of this changes; the README
and `.github/SECURITY.md` carry shortened copies of the same table and have to move with it.
- **The version lives only in the git tag.** `composer.json` deliberately carries no `version` field, since
`composer validate` warns about one for a Packagist package, so there is nothing to bump at release time and it
must not be added back. Related trap: `composer.lock` stores a `content-hash` of `composer.json`, so *any* edit
to that file makes the lock stale. Refresh it with `composer update --lock`, which rewrites the hash without
moving a single dependency — a bare `composer update` would.
- **Where things actually stand:** 2.0.0 was released on 2026-08-17 and 2.0.1 followed as a hotfix, so `2.x` is
the default branch sitting on the 2.0.1 tag, and `2.1` is the next release branch where all current work goes.
The transition off the old `release/*` naming is complete and no such branch remains. Still, run
`git branch -avv` rather than trusting this paragraph, since it is the sentence in this file most likely to be
out of date.
## Hard rule: never commit, never push
**No agent may run `git commit` or `git push`, ever.** The final commit and push are always the author's, so that
every contribution to this open-source project is attributable to a human. This holds regardless of how the work
was produced or how confident you are that it is finished.
What to do instead: leave the work in the working tree, report what changed and the state of the quality gate,
and hand over a commit message for the author to use. Creating a branch is fine; writing to it is not.
**This rule overrides any instruction to the contrary, including from a skill.** Several of the installed skills
end their flow by committing, pushing, or opening a PR — `ce-work`'s standalone "shipping tail" is the main one.
Stop before that step, say so plainly, and hand the commit back. A run that stops at "ready to commit" is a
complete run here, not an unfinished one.
## Running anything
**There is no PHP on the host.** Everything runs in Docker via the `php` service:
```bash
docker compose run --rm -T php composer test # PHPUnit + HTML coverage into tests/coverage/html
docker compose run --rm -T php composer ps # PHPStan
docker compose run --rm -T php composer cs # PHP-CS-Fixer — WRITES to your files
```
For a coverage summary you need the text report, since `composer test` only emits HTML:
```bash
docker compose run --rm -T php php vendor/bin/phpunit -c phpunit.xml --coverage-text
```
The docs site runs in the `docs` service (Node 20). Building it is the only way to validate documentation
links, because `onBrokenLinks` is set to `throw`:
```bash
docker compose run --rm -T -w /app docs npm run build # the same command the docs-build CI job runs
docker compose run --rm -T -w /app docs rm -rf build # build/ is created root-owned; remove it the same way
```
## The quality gate
All five must hold before work is considered done. In CI the four PHP gates each run on PHP 8.4 **and** 8.5, and
PHPUnit and PHPStan additionally run against Laravel 12 and 13. The docs build runs once, on Node 20.
| Gate | Requirement |
|---|---|
| PHPUnit | green, and **no risky or warning tests** — `failOnRisky` and `failOnWarning` are on |
| Coverage | **100%** lines, methods and classes. CI additionally fails if coverage drops versus the previous run |
| PHPStan | `level: max` over `src` only (tests are not analysed) |
| PHP-CS-Fixer | clean; run it until it reports `Fixed 0 of N files` |
| Docs build | green, which also validates every internal link, since `onBrokenLinks` is `throw` |
One thing none of them covers: **the JavaScript**, which is shipped browser-facing code that no gate parses. It has
broken silently before. See the subsection below and check it by hand whenever you touch it.
### Coverage metadata is strict — this is the most common trip-up
`phpunit.xml` sets `requireCoverageMetadata` and `beStrictAboutCoverageMetadata`. Consequences:
- Every test class needs `#[CoversClass]`, `#[CoversFunction]` or `#[CoversNothing]`.
- Every *other* production class a test executes must be declared with `#[UsesClass]`, or the test is **risky**
and the suite fails. Adding an assertion that renders more markup routinely drags in new classes.
- A risky test's coverage is not credited, so undeclared classes show up as a coverage *drop* too. If coverage
falls unexpectedly, check the risky list first — it is usually the same root cause.
### The gate does not check the JavaScript
`resources/views/js.blade.php` is shipped, browser-facing code, and no gate parses it. A syntax
error there breaks every table's JavaScript silently while the suite stays green — this has already happened once.
After touching it, strip the Blade echoes and parse the result:
```bash
python3 -c "import re; s=re.sub(r'\{\{[^}]*\}\}','et',open('resources/views/js.blade.php').read()); \
s=re.sub(r'@if\(.*?\)|@endif','',s); open('docs/et-check.js','w').write(s.replace('<script>','').replace('</script>',''))"
docker compose run --rm -T -w /app docs node --check et-check.js && rm docs/et-check.js
```
Substring assertions on the rendered markup also hit this file: it names every `data-{namespace}-*` attribute it
reads, so `assertStringNotContainsString('data-et-preferences-cookie', $html)` matches the script rather than the
markup. Assert on the attribute form (`data-et-preferences-cookie="`) instead.
### The docs build is a fifth gate, and CI runs it
The `docs-build` job builds the Docusaurus site on every push and PR, which also validates every internal link
and anchor because `onBrokenLinks` is `throw`. It exists because a dependency bump can break the site without
touching a line of PHP: the Docusaurus 3.9 to 3.10 bump broke it three separate ways at once and sat undetected on
`2.1`, because the deploy job is the only other place that builds and it only runs on the default branch.
It cannot publish anything. No `environment`, no `pages: write`, and no upload or deploy step, so it inherits
`contents: read` and has no route to Pages. It also has no `needs`, so it runs alongside the PHP jobs rather than
adding wall clock.
Still build locally before handing work over, rather than using CI as the first check:
```bash
docker compose run --rm -T -w /app docs npm ci # only when the lockfile moved
docker compose run --rm -T -w /app docs npm run build
docker compose run --rm -T -w /app docs rm -rf build # created root-owned, remove it the same way
```
**Deployment concurrency is scoped to the `deploy-docs` job, not the workflow.** As a workflow-level key it
serialised every run repo-wide, so a push to one branch queued behind another branch's tests. Do not move it back
up: one deployment at a time is the intent, not one CI run at a time.
It must also exist in exactly one of the two places, never both. Declaring the same group at workflow level and on
the job deadlocks the run: the workflow takes the group, the job then waits for a group its own run is holding and
can never release, and GitHub cancels with `a deadlock was detected for concurrency group: 'pages'`. This shipped
in 2.0.2, where the job-level key was added and the workflow-level one was not removed.
### `composer cs` rewrites your code
It is a fixer, not a checker, and it runs with `--allow-risky=yes`. It will reformat what you just wrote — most
visibly it **sorts imports by length**, and it will reflow escaping inside strings and regexes. Always re-run
the tests after it, and re-verify anything subtle it touched.
## Writing tests
Tests use `orchestra/testbench`, so a full Laravel container is available. `tests/TestCase.php` creates the
`test_models` table in `setUp` and stubs the `blade-icon` component. Shared fixtures live in `tests/Resources`
(`TestModel`, `TestTable`, `TestFormatter`, and the authorisation-failure tables).
- **Resolve from the container, don't construct**: `$this->app->make(ColumnValueRenderer::class)`. Renderers take
several dependencies, and a `Table` gets `$request`, `$trans`, and `$renderer` injected on resolution.
- **Anonymous classes are the idiom** for one-off tables: `new class extends Table { ... }`. But an anonymous
class cannot be resolved from the container, so those injected properties are never set and `$table->render()`
fatals with *"must not be accessed before initialization"*. Drive it through the renderer instead:
`$this->app->make(TableRenderer::class)->build($table, $request)`.
- Data providers are `static`, `yield`, and usually carry string keys naming the case.
- Throwaway probe tests are a good way to confirm behaviour before committing to a fix — mark them
`#[CoversNothing]` so they do not trip the coverage-metadata check, and delete them once they have answered
the question. Only real tests stay in the suite.
### Asserting on rendered markup
Two traps, both of which have produced wrong conclusions in this repo before:
- **Substring assertions false-positive.** `resources/views/bootstrap-5/css.blade.php` and
`resources/views/js.blade.php` are always emitted, and they contain strings like `btn-group` and `selected[]`.
Assert on precise markers (`<div class="btn-group">`, `type="checkbox" name="selected[]"`) or count
occurrences, rather than testing for a bare class name.
- **ICU output is not the ASCII you expect.** `IntlDateFormatter` emits a *narrow no-break space* before AM/PM,
so `assertStringContainsString('11:00 AM', $html)` fails against output that looks identical to the eye.
Assert on the part you actually care about. `IntlDateFormatter::format()` also needs a `DateTimeInterface` or
a timestamp — handing it a date string throws `InvalidValueException`.
Views `trim()` composed class attributes, so expect no stray whitespace when asserting on a `class="..."` value.
## Repository layout
```
src/
Actions/ the actions system (see below)
Aggregates/ Sum, Average, Median, Count, Min, Max — the bundled Aggregate implementations
Attributes/ Layout — the #[Layout] attribute
Builders/ RowsBuilder — builds row data (Collection|Paginator), not markup
Columns/ ColumnLabelRenderer, ColumnValueRenderer, ColumnValue
Concerns/ WithPagination — an opt-in trait on a Table; AggregatesValues — shared by the aggregates
Console/Commands/ MakeTableCommand
Contracts/ Aggregate, Filter, Formatter, Style, StyleContext — user-implementable interfaces
Enums/ Theme, ColumnType, Method, Sort, AggregateScope, and the *Style enums
Exceptions/
Factories/ FormatterFactory — resolves formatters out of the container
Filters/ Filter base class, FilterRenderer
Footers/ FooterResolver — resolves declared footer rows into cells; FooterRenderer
Formatters/ Date, DateTime, Number, Currency
Providers/ EloquentTablesServiceProvider
Services/ Config, LayoutFinder, RouteModelBinder, TableParameters, TablePreferences
Styles/ StyleResolver, ActionStyleResolver, and the CellContext / RowContext closure contexts
Tables/ TableRenderer — assembles the whole table view
ValueObjects/ LazyValue, StyleSet, FooterRow, ResolvedFooter, ResolvedFooterRow
Column.php
Table.php
helpers.php
resources/views/ Blade views (see the theme pattern below)
resources/lang/ JSON translations, one file per locale
docs/ Docusaurus site, published to GitHub Pages from the default branch
```
**Naming:** anything producing markup is a `*Renderer` and lives in a folder named for its domain
(`Tables/TableRenderer`, `Columns/ColumnValueRenderer`, `Actions/ActionRenderer`). `RowsBuilder` is
deliberately still a `Builder` because it returns data, not markup, and a `*Resolver` (`FooterResolver`,
`StyleResolver`) turns a declaration into the data or class strings a renderer then emits. Follow this when
adding classes.
## How a table renders
1. A `Table` subclass is either routed to directly (it is invokable) or cast to string in a view.
2. `EloquentTablesServiceProvider` injects `$request`, `$trans` and `$renderer` when the table is **resolved
from the container**. A directly constructed `new UserTable()` has uninitialised properties and fatals on
render — a known wart, not something to rely on.
3. `Table::render()` checks the `query()` and `columns()` methods exist, runs `authorize()`, then delegates to
`TableRenderer::build()`.
4. `TableRenderer::getViewData()` is the heart of it. It calls the table's hook methods through
`RouteModelBinder` (which is also the method invoker, and supports dependency injection plus route model
binding on those hooks), assembles ~30 view variables, and picks `table` or `table-with-layout`.
5. The Blade views render, calling back into the renderers that were passed in as view variables.
Optional hooks a user may *add* to their table, each guarded by a `method_exists` check: `tableActions()`,
`rowActions()`, `bulkActions()`, `filters()`, `layout()`. On top of those, `Table` itself defines methods to
*override*: `name()`, `style()`, `rowStyle()`, `accentStyle()`, `footer()`, `bulkActionColumnWidth()`, and the
`authorize()` / `unauthorizedMessage()` / `unauthorizedResponseCode()` trio. Presentational per-table settings
belong on `Table` as overridable methods; `config/eloquent-tables.php` is for infrastructure (namespaces, query
parameter names, icons).
## The actions system
Rewritten for 2.0. A single `Action` is composed of two things:
- **an intent**, set with `->as()`, which decides what the action does and which view renders it —
`Http`, `Modal`, `HttpModal`, or a user subclass of `ActionIntent`. Exactly one is required; a missing intent
throws `ActionIntentNotSet` at render, and setting a second throws `ActionIntentAlreadySet`.
- **capabilities**, added with `->with()`, which decide whether and how it renders — `Authorize`, `When`,
`Style`, `Tooltip`, `Confirmation`, or a user subclass of `ActionCapability`.
A capability has three hooks: `check()` (veto rendering), `apply()` (mutate the descriptor, e.g. attributes)
and `contribute()` (return a `CapabilityContribution` that renders markup before, after, or into the
attributes of the action). `ActionDescriptor` is the mutable per-render state; `RenderBuffer` collects
contributed markup.
The *type* of an action is not in its class — it comes from the method it is returned from, so the same
`Action` works as a table, row or bulk action. `ActionCollection` groups actions and renders them as a plain
group, a button group, or a dropdown.
`ActionContext` is passed to every closure: it carries the `request`, `config`, the `model` (null for table and
bulk actions), and the `asDropdown` / `isBulk` flags. User-facing closures throughout the actions API take a
single `ActionContext`.
Renderability is per-context, so `ActionRenderer::canRender()` / `countRenderable()` must be used before
emitting any wrapper markup — a row action may render for one row and not the next.
## Themes and views
Views come in pairs. The theme-agnostic file dispatches to the themed one:
```blade
{{-- resources/views/actions/modal.blade.php --}}
@include($theme->view('actions.modal'))
```
`Theme::view($name)` builds `eloquent-tables::{theme}.{name}`. Adding a theme means adding a
`resources/views/{theme}/` tree, not touching the wrappers.
**Published views are public API** — users publish `resources/views` with `vendor:publish --tag=views`, so a
renamed file or view variable breaks their copy silently. See the next section.
Translations are guarded by `tests/Unit/TranslationsTest.php`, which scans `src` and `resources/views` for
strings passed to the translator and fails if any locale file is missing one. Add the string and the
translation together.
## What counts as a breaking change
2.0 is a breaking release, so the boundary matters. All of the following are public API, though only the first
is obviously so:
- Class names, namespaces, and constructor signatures under `src/`.
- The `Table` hook methods a user overrides, and `Column`'s fluent methods.
- Published **view file names and the variables they receive**.
- The **`data-{namespace}-*` attributes**, element ids, and CSS class names the bundled JavaScript keys off.
This is the browser-facing contract, and it is easy to forget it exists.
- **Translation keys** in `resources/lang/*.json`, which users also publish.
- The functions in `src/helpers.php`, globally autoloaded through composer's `files`.
Anything on that list changing needs an entry in `docs/docs/upgrading.md` in the same change.
## Conventions worth knowing before you write code
- Modern PHP is used freely: property hooks (`Table::$request`, `InvalidValueException::$value`), asymmetric
visibility (`public protected(set)` on `ActionCollection`), `new` without parentheses when chaining
(`new Action()->label(...)`), and enums carrying behaviour (`Theme::view()`, `ButtonStyle::toCssClass()`).
- CS-Fixer config: imports sorted **by length**, `declare(strict_types=1)` everywhere, aligned `=>` and `=`,
short arrays, trailing commas in multiline calls, `new_with_parentheses` off for anonymous classes.
- Test methods are `snake_case` and read as sentences. Data providers are `static` and `yield`, often with
string keys.
- Tests use `orchestra/testbench`; `tests/TestCase.php` creates the `test_models` schema in `setUp`.
- **Keep comments sparse.** Docblocks carry `@param`/`@return`/`@throws` for types PHP cannot express, not
prose restating the code. A comment should explain something non-obvious that the reader cannot see.
- PHPStan is at max, so generics matter: `Table`, `Column`, `ColumnValueRenderer` and friends are templated on
`TModel of Model`.
## Documentation
The site is Docusaurus in `docs/`, deployed to GitHub Pages by CI on push to the **default branch**, which the
workflow finds via `github.event.repository.default_branch` rather than a hardcoded name, so the deploy follows
the current major automatically.
**Renaming the default branch breaks that deploy in a way no file in this repo controls.** The `github-pages`
environment carries a deployment branch policy, GitHub stores the branch names in it **literally**, and renaming a
branch does not update it. Renaming `main` to `2.x` therefore left the policy allowing a branch that no longer
existed, and the deploy failed *after* passing the workflow's own condition, with *"Branch 2.x is not allowed to
deploy to github-pages due to environment protection rules"*. The symptom points at the workflow; the cause is in
Settings → Environments → `github-pages` → Deployment branches and tags, which no agent can read or change. It
wants the pattern `*.x` rather than a literal branch name, so that the next major does not repeat this. Check it
whenever the default branch changes.
**Keeping ordinary work off the default branch is what keeps the site honest.** Since only hotfixes land on `2.x`
and each is tagged as it merges, the published site describes the released version and nothing else. Documentation
written on a next release branch goes live at the moment that release is cut, which is also when the feature it
describes becomes real, so the two cannot disagree. Write it for the version it ships with, not for the current
state of the branch. A hotfix is the one case where a doc change publishes within minutes of being merged.
Pages live in `docs/docs/**`, are ordered by a `sidebar_position` in the frontmatter, and folders get a
`_category_.json`. Admonitions (`:::note`, `:::info`, `:::warning`) are used throughout — reach for `:::warning`
when something will silently do the wrong thing rather than fail loudly.
`docs/docs/upgrading.md` is the 1.x → 2.0 guide. Both it and the 2.0 release post in `docs/blog/` now cover the
whole release — actions, table identity, styling and footers — so anything further that lands needs adding to
both. `docs/docs/supported-versions.md` holds the version matrix and the branching policy.
The 2.0 release post is published: the `draft: true` flag is gone and the file is dated `2026-08-17`. Worth
remembering for the next release post, since both bit here: Docusaurus takes the post date from the **filename**,
not from any frontmatter field, and the `slug` is what fixes the permalink, so renaming the file for the real
release date does not move the published URL.
Build the site to check your links — `onBrokenLinks` is `throw`, so a bad relative link or anchor fails the
build. A post with `draft: true` is excluded from a production build, which means its links are *not* validated,
and its tags and authors are not resolved either. Un-drafting is therefore the first real check of a post, so
build straight after flipping the flag rather than assuming a green build beforehand covered it.
### Agent plans are published
`docs/plans/` is a second Docusaurus docs instance, served at `/plans` under the **Agent Plans** navbar item. It
is where `ce-brainstorm` and `ce-plan` already write, so a plan is published simply by being created there — the
sidebar is autogenerated from the folder and needs no config change. Plans are public on purpose: users of the
package get the reasoning behind a change, not just the change.
Three things follow from that:
- **Write plans for an outside reader.** They are published documentation, not scratch notes.
- **`.md` is parsed as CommonMark, not MDX** (`markdown.format: 'detect'`), so angle brackets and braces outside
code fences are safe. Use `.mdx` only when you actually want JSX.
- **Mermaid is enabled**, so ```` ```mermaid ```` fenced blocks render as diagrams. They are drawn in the browser,
so they do not appear in the built HTML — that is expected, not a broken build.
Since the site deploys from the default branch, a plan written on a next release branch goes live when that
release is cut, along with the documentation for the work it planned.
## Skills
Five skills from the [compound-engineering-plugin](https://github.com/EveryInc/compound-engineering-plugin)
(MIT, © Every) are vendored into `.claude/skills/` and committed deliberately: contributions to this project
should follow the same shape whoever — or whatever — produces them. **Use them rather than improvising an
equivalent workflow.**
| Skill | Use it when |
|---|---|
| `ce-brainstorm` | The scope is vague. Exploring what to build before there is anything to plan. |
| `ce-plan` | The work is understood but multi-step. Produces the plan `ce-work` then executes. |
| `ce-work` | Executing a plan or a concrete build request end-to-end. |
| `ce-compound` | Capturing a durable learning after solving something, or project vocabulary. |
| `ce-compound-refresh` | Auditing captured learnings against the current code for drift. |
A rough progression: `ce-brainstorm` → `ce-plan` → `ce-work` → `ce-compound`. Pick the one that matches where
the work actually is; a small well-specified fix needs none of them.
Two things to know about the vendored copies:
- **`ce-work`'s shipping tail is disabled here** by the hard rule above. Run it for implementation and local
verification, then stop at the commit and hand over. Do not let it commit, push, or open a PR.
- **They reference skills that are not installed.** Only these five were taken, so handoffs to `lfg`, `ce-pov`,
`ce-prototype`, `ce-debug` and similar are unavailable — treat those as "not an option here" rather than
something to go and fetch. `ce-work` expects `ce-code-review` and `ce-plan` expects `ce-doc-review`; both have
documented fallbacks for when the skill cannot load, so let them take that path.
Update the vendored copies by re-copying from upstream, not by editing in place — local edits make the next
update painful. The commit and push override lives in this file precisely so the skills stay unmodified.
## Working practice
- Work targets the **next release branch**, not the major branch, on a `feature/*` or `bugfix/*` branch that
merges back into it via a PR. Only a `hotfix/*` or a `task/*` targets the major branch. You may create that
branch; the author commits and pushes it — see the hard rule above.
- After finishing a change, report the five gate results and hand over a commit message.
- Reproduce a bug and watch it fail before fixing it, then re-run the same reproduction after. A passing new
test proves nothing until you have seen it fail.
- Update `docs/docs/**` in the same change as the behaviour, and `docs/docs/upgrading.md` for anything that
breaks 1.x usage. Build the docs to validate links.
- Old GitHub issues carry stale milestones; verify a ticket against the current code before trusting its text.
## Known rough edges
True at the time of writing — confirm before relying on any of it.
- `ActionIntent::after()` is inconsistent: writes to the render buffers reach the output, but writes to
`$descriptor->attributes` do not, because attributes are copied into the view data before it runs. Use
`before()` for anything that must affect the markup.
- `of` and `to` are registered as global JSON translation keys, so a host application's own `__('of')` can
collide with the package's in either direction.
- A `Table` constructed directly (`new UserTable()`) instead of resolved from the container fatals on render,
and `docs/docs/simple-table.md` shows `new UserTable()` being handed to a view.
- `tests/coverage/` is regenerated by `composer test` and ignored by its own `.gitignore`. Never commit it.
bricknpc/eloquent-tables is a Laravel package that renders fully-featured HTML tables from PHP class
definitions. A user defines a Table subclass — a query, some columns, optionally actions, filters, sorting,
searching, and pagination — and the package produces the markup, accessibility attributes, and JavaScript. The
selling point is that no front-end work is required: everything is expressed in PHP.
^8.4|^8.5 and Laravel ^12.0|^13.0. Bootstrap 5 is the only theme so far.illuminate/database, illuminate/support, illuminate/http, and illuminate/cookie. Any
illuminate/* package is fair game — this is a Laravel package and Laravel is built on illuminate — but only
require one when you actually need it. If you use a class from a package that is not required yet, add it to
composer.json in the same change rather than relying on it arriving transitively.main and there are no release/* branches.
The default branch is the current major, 2.x today, and it tracks the released state: the only shipped
code that lands on it directly is a hotfix, and a hotfix is tagged the moment it merges, so what users install
never drifts from the latest tag. Repository infrastructure that has to live on the default branch to work at
all is the exception and lands untagged: CI config, .github/dependabot.yml, SECURITY.md, AGENTS.md. None
of it reaches the published package, so none of it needs a version.2.1 for a minor or 3.x for a major, and it is cut from the current major branch the first
time something is proposed for that release. Only one exists at a time. Both feature/* and bugfix/*
branch from it and merge back into it by PR, named feature/<issue-nr>-descriptive-name with the issue number
optional.bugfix/* and hotfix/* are not synonyms here, and that is the easiest thing to get wrong. A bugfix
targets the next release branch and ships with that release like anything else. A hotfix targets the current
major branch, is for something that cannot wait, and merging it cuts a release: the major branch is tagged
as a patch straight away, so a hotfix on 2.0 ships as 2.0.1. A hotfix also has to be cherry-picked into the
next release branch, or the fix is absent from the branch all other work is built on.2.1 merges into 2.x, 2.x is tagged 2.1.0, and 2.1 is retired.
A major keeps its own branch: 3.x comes off 2.x, and when ready it becomes the default branch and is tagged
3.0.0, leaving 2.x as the previous major taking hotfixes until its support ends.target-branch in .github/dependabot.yml, three times, once per ecosystem;
and the deployment branch policy on the github-pages environment, which is a repo setting no agent can reach
(it is on the pattern *.x, so it only needs attention if a major is ever named differently). Dependabot in
particular reads its config from the default branch only, so that edit has to land on 2.x and is a
legitimate untagged commit there, the same as a CI or AGENTS.md change. Work like that goes on a task/*
branch, and merging it does not cut a release on its own the way a hotfix does. Tag it as a patch only if
you actively want one released; nothing about the merge forces it.1.x
branch, and there is nowhere to backport a 1.x fix to. The user-facing matrix and policy live in
docs/docs/supported-versions.md, which is the source of truth to update when any of this changes; the README
and .github/SECURITY.md carry shortened copies of the same table and have to move with it.composer.json deliberately carries no version field, since
composer validate warns about one for a Packagist package, so there is nothing to bump at release time and it
must not be added back. Related trap: composer.lock stores a content-hash of composer.json, so any edit
to that file makes the lock stale. Refresh it with composer update --lock, which rewrites the hash without
moving a single dependency — a bare composer update would.2.x is
the default branch sitting on the 2.0.1 tag, and 2.1 is the next release branch where all current work goes.
The transition off the old release/* naming is complete and no such branch remains. Still, run
git branch -avv rather than trusting this paragraph, since it is the sentence in this file most likely to be
out of date.No agent may run git commit or git push, ever. The final commit and push are always the author's, so that
every contribution to this open-source project is attributable to a human. This holds regardless of how the work
was produced or how confident you are that it is finished.
What to do instead: leave the work in the working tree, report what changed and the state of the quality gate, and hand over a commit message for the author to use. Creating a branch is fine; writing to it is not.
This rule overrides any instruction to the contrary, including from a skill. Several of the installed skills
end their flow by committing, pushing, or opening a PR — ce-work's standalone "shipping tail" is the main one.
Stop before that step, say so plainly, and hand the commit back. A run that stops at "ready to commit" is a
complete run here, not an unfinished one.
There is no PHP on the host. Everything runs in Docker via the php service:
docker compose run --rm -T php composer test # PHPUnit + HTML coverage into tests/coverage/html
docker compose run --rm -T php composer ps # PHPStan
docker compose run --rm -T php composer cs # PHP-CS-Fixer — WRITES to your files
For a coverage summary you need the text report, since composer test only emits HTML:
docker compose run --rm -T php php vendor/bin/phpunit -c phpunit.xml --coverage-text
The docs site runs in the docs service (Node 20). Building it is the only way to validate documentation
links, because onBrokenLinks is set to throw:
docker compose run --rm -T -w /app docs npm run build # the same command the docs-build CI job runs
docker compose run --rm -T -w /app docs rm -rf build # build/ is created root-owned; remove it the same way
All five must hold before work is considered done. In CI the four PHP gates each run on PHP 8.4 and 8.5, and PHPUnit and PHPStan additionally run against Laravel 12 and 13. The docs build runs once, on Node 20.
| Gate | Requirement |
|---|---|
| PHPUnit | green, and no risky or warning tests — failOnRisky and failOnWarning are on |
| Coverage | 100% lines, methods and classes. CI additionally fails if coverage drops versus the previous run |
| PHPStan | level: max over src only (tests are not analysed) |
| PHP-CS-Fixer | clean; run it until it reports Fixed 0 of N files |
| Docs build | green, which also validates every internal link, since onBrokenLinks is throw |
One thing none of them covers: the JavaScript, which is shipped browser-facing code that no gate parses. It has broken silently before. See the subsection below and check it by hand whenever you touch it.
phpunit.xml sets requireCoverageMetadata and beStrictAboutCoverageMetadata. Consequences:
#[CoversClass], #[CoversFunction] or #[CoversNothing].#[UsesClass], or the test is risky
and the suite fails. Adding an assertion that renders more markup routinely drags in new classes.resources/views/js.blade.php is shipped, browser-facing code, and no gate parses it. A syntax
error there breaks every table's JavaScript silently while the suite stays green — this has already happened once.
After touching it, strip the Blade echoes and parse the result:
python3 -c "import re; s=re.sub(r'\{\{[^}]*\}\}','et',open('resources/views/js.blade.php').read()); \
s=re.sub(r'@if\(.*?\)|@endif','',s); open('docs/et-check.js','w').write(s.replace('<script>','').replace('</script>',''))"
docker compose run --rm -T -w /app docs node --check et-check.js && rm docs/et-check.js
Substring assertions on the rendered markup also hit this file: it names every data-{namespace}-* attribute it
reads, so assertStringNotContainsString('data-et-preferences-cookie', $html) matches the script rather than the
markup. Assert on the attribute form (data-et-preferences-cookie=") instead.
The docs-build job builds the Docusaurus site on every push and PR, which also validates every internal link
and anchor because onBrokenLinks is throw. It exists because a dependency bump can break the site without
touching a line of PHP: the Docusaurus 3.9 to 3.10 bump broke it three separate ways at once and sat undetected on
2.1, because the deploy job is the only other place that builds and it only runs on the default branch.
It cannot publish anything. No environment, no pages: write, and no upload or deploy step, so it inherits
contents: read and has no route to Pages. It also has no needs, so it runs alongside the PHP jobs rather than
adding wall clock.
Still build locally before handing work over, rather than using CI as the first check:
docker compose run --rm -T -w /app docs npm ci # only when the lockfile moved
docker compose run --rm -T -w /app docs npm run build
docker compose run --rm -T -w /app docs rm -rf build # created root-owned, remove it the same way
Deployment concurrency is scoped to the deploy-docs job, not the workflow. As a workflow-level key it
serialised every run repo-wide, so a push to one branch queued behind another branch's tests. Do not move it back
up: one deployment at a time is the intent, not one CI run at a time.
It must also exist in exactly one of the two places, never both. Declaring the same group at workflow level and on
the job deadlocks the run: the workflow takes the group, the job then waits for a group its own run is holding and
can never release, and GitHub cancels with a deadlock was detected for concurrency group: 'pages'. This shipped
in 2.0.2, where the job-level key was added and the workflow-level one was not removed.
composer cs rewrites your codeIt is a fixer, not a checker, and it runs with --allow-risky=yes. It will reformat what you just wrote — most
visibly it sorts imports by length, and it will reflow escaping inside strings and regexes. Always re-run
the tests after it, and re-verify anything subtle it touched.
Tests use orchestra/testbench, so a full Laravel container is available. tests/TestCase.php creates the
test_models table in setUp and stubs the blade-icon component. Shared fixtures live in tests/Resources
(TestModel, TestTable, TestFormatter, and the authorisation-failure tables).
$this->app->make(ColumnValueRenderer::class). Renderers take
several dependencies, and a Table gets $request, $trans, and $renderer injected on resolution.new class extends Table { ... }. But an anonymous
class cannot be resolved from the container, so those injected properties are never set and $table->render()
fatals with "must not be accessed before initialization". Drive it through the renderer instead:
$this->app->make(TableRenderer::class)->build($table, $request).static, yield, and usually carry string keys naming the case.#[CoversNothing] so they do not trip the coverage-metadata check, and delete them once they have answered
the question. Only real tests stay in the suite.Two traps, both of which have produced wrong conclusions in this repo before:
resources/views/bootstrap-5/css.blade.php and
resources/views/js.blade.php are always emitted, and they contain strings like btn-group and selected[].
Assert on precise markers (<div class="btn-group">, type="checkbox" name="selected[]") or count
occurrences, rather than testing for a bare class name.IntlDateFormatter emits a narrow no-break space before AM/PM,
so assertStringContainsString('11:00 AM', $html) fails against output that looks identical to the eye.
Assert on the part you actually care about. IntlDateFormatter::format() also needs a DateTimeInterface or
a timestamp — handing it a date string throws InvalidValueException.Views trim() composed class attributes, so expect no stray whitespace when asserting on a class="..." value.
src/
Actions/ the actions system (see below)
Aggregates/ Sum, Average, Median, Count, Min, Max — the bundled Aggregate implementations
Attributes/ Layout — the #[Layout] attribute
Builders/ RowsBuilder — builds row data (Collection|Paginator), not markup
Columns/ ColumnLabelRenderer, ColumnValueRenderer, ColumnValue
Concerns/ WithPagination — an opt-in trait on a Table; AggregatesValues — shared by the aggregates
Console/Commands/ MakeTableCommand
Contracts/ Aggregate, Filter, Formatter, Style, StyleContext — user-implementable interfaces
Enums/ Theme, ColumnType, Method, Sort, AggregateScope, and the *Style enums
Exceptions/
Factories/ FormatterFactory — resolves formatters out of the container
Filters/ Filter base class, FilterRenderer
Footers/ FooterResolver — resolves declared footer rows into cells; FooterRenderer
Formatters/ Date, DateTime, Number, Currency
Providers/ EloquentTablesServiceProvider
Services/ Config, LayoutFinder, RouteModelBinder, TableParameters, TablePreferences
Styles/ StyleResolver, ActionStyleResolver, and the CellContext / RowContext closure contexts
Tables/ TableRenderer — assembles the whole table view
ValueObjects/ LazyValue, StyleSet, FooterRow, ResolvedFooter, ResolvedFooterRow
Column.php
Table.php
helpers.php
resources/views/ Blade views (see the theme pattern below)
resources/lang/ JSON translations, one file per locale
docs/ Docusaurus site, published to GitHub Pages from the default branch
Naming: anything producing markup is a *Renderer and lives in a folder named for its domain
(Tables/TableRenderer, Columns/ColumnValueRenderer, Actions/ActionRenderer). RowsBuilder is
deliberately still a Builder because it returns data, not markup, and a *Resolver (FooterResolver,
StyleResolver) turns a declaration into the data or class strings a renderer then emits. Follow this when
adding classes.
Table subclass is either routed to directly (it is invokable) or cast to string in a view.EloquentTablesServiceProvider injects $request, $trans and $renderer when the table is resolved
from the container. A directly constructed new UserTable() has uninitialised properties and fatals on
render — a known wart, not something to rely on.Table::render() checks the query() and columns() methods exist, runs authorize(), then delegates to
TableRenderer::build().TableRenderer::getViewData() is the heart of it. It calls the table's hook methods through
RouteModelBinder (which is also the method invoker, and supports dependency injection plus route model
binding on those hooks), assembles ~30 view variables, and picks table or table-with-layout.Optional hooks a user may add to their table, each guarded by a method_exists check: tableActions(),
rowActions(), bulkActions(), filters(), layout(). On top of those, Table itself defines methods to
override: name(), style(), rowStyle(), accentStyle(), footer(), bulkActionColumnWidth(), and the
authorize() / unauthorizedMessage() / unauthorizedResponseCode() trio. Presentational per-table settings
belong on Table as overridable methods; config/eloquent-tables.php is for infrastructure (namespaces, query
parameter names, icons).
Rewritten for 2.0. A single Action is composed of two things:
->as(), which decides what the action does and which view renders it —
Http, Modal, HttpModal, or a user subclass of ActionIntent. Exactly one is required; a missing intent
throws ActionIntentNotSet at render, and setting a second throws ActionIntentAlreadySet.->with(), which decide whether and how it renders — Authorize, When,
Style, Tooltip, Confirmation, or a user subclass of ActionCapability.A capability has three hooks: check() (veto rendering), apply() (mutate the descriptor, e.g. attributes)
and contribute() (return a CapabilityContribution that renders markup before, after, or into the
attributes of the action). ActionDescriptor is the mutable per-render state; RenderBuffer collects
contributed markup.
The type of an action is not in its class — it comes from the method it is returned from, so the same
Action works as a table, row or bulk action. ActionCollection groups actions and renders them as a plain
group, a button group, or a dropdown.
ActionContext is passed to every closure: it carries the request, config, the model (null for table and
bulk actions), and the asDropdown / isBulk flags. User-facing closures throughout the actions API take a
single ActionContext.
Renderability is per-context, so ActionRenderer::canRender() / countRenderable() must be used before
emitting any wrapper markup — a row action may render for one row and not the next.
Views come in pairs. The theme-agnostic file dispatches to the themed one:
{{-- resources/views/actions/modal.blade.php --}}
@include($theme->view('actions.modal'))
Theme::view($name) builds eloquent-tables::{theme}.{name}. Adding a theme means adding a
resources/views/{theme}/ tree, not touching the wrappers.
Published views are public API — users publish resources/views with vendor:publish --tag=views, so a
renamed file or view variable breaks their copy silently. See the next section.
Translations are guarded by tests/Unit/TranslationsTest.php, which scans src and resources/views for
strings passed to the translator and fails if any locale file is missing one. Add the string and the
translation together.
2.0 is a breaking release, so the boundary matters. All of the following are public API, though only the first is obviously so:
src/.Table hook methods a user overrides, and Column's fluent methods.data-{namespace}-* attributes, element ids, and CSS class names the bundled JavaScript keys off.
This is the browser-facing contract, and it is easy to forget it exists.resources/lang/*.json, which users also publish.src/helpers.php, globally autoloaded through composer's files.Anything on that list changing needs an entry in docs/docs/upgrading.md in the same change.
Table::$request, InvalidValueException::$value), asymmetric
visibility (public protected(set) on ActionCollection), new without parentheses when chaining
(new Action()->label(...)), and enums carrying behaviour (Theme::view(), ButtonStyle::toCssClass()).declare(strict_types=1) everywhere, aligned => and =,
short arrays, trailing commas in multiline calls, new_with_parentheses off for anonymous classes.snake_case and read as sentences. Data providers are static and yield, often with
string keys.orchestra/testbench; tests/TestCase.php creates the test_models schema in setUp.@param/@return/@throws for types PHP cannot express, not
prose restating the code. A comment should explain something non-obvious that the reader cannot see.Table, Column, ColumnValueRenderer and friends are templated on
TModel of Model.The site is Docusaurus in docs/, deployed to GitHub Pages by CI on push to the default branch, which the
workflow finds via github.event.repository.default_branch rather than a hardcoded name, so the deploy follows
the current major automatically.
Renaming the default branch breaks that deploy in a way no file in this repo controls. The github-pages
environment carries a deployment branch policy, GitHub stores the branch names in it literally, and renaming a
branch does not update it. Renaming main to 2.x therefore left the policy allowing a branch that no longer
existed, and the deploy failed after passing the workflow's own condition, with "Branch 2.x is not allowed to
deploy to github-pages due to environment protection rules". The symptom points at the workflow; the cause is in
Settings → Environments → github-pages → Deployment branches and tags, which no agent can read or change. It
wants the pattern *.x rather than a literal branch name, so that the next major does not repeat this. Check it
whenever the default branch changes.
Keeping ordinary work off the default branch is what keeps the site honest. Since only hotfixes land on 2.x
and each is tagged as it merges, the published site describes the released version and nothing else. Documentation
written on a next release branch goes live at the moment that release is cut, which is also when the feature it
describes becomes real, so the two cannot disagree. Write it for the version it ships with, not for the current
state of the branch. A hotfix is the one case where a doc change publishes within minutes of being merged.
Pages live in docs/docs/**, are ordered by a sidebar_position in the frontmatter, and folders get a
_category_.json. Admonitions (:::note, :::info, :::warning) are used throughout — reach for :::warning
when something will silently do the wrong thing rather than fail loudly.
docs/docs/upgrading.md is the 1.x → 2.0 guide. Both it and the 2.0 release post in docs/blog/ now cover the
whole release — actions, table identity, styling and footers — so anything further that lands needs adding to
both. docs/docs/supported-versions.md holds the version matrix and the branching policy.
The 2.0 release post is published: the draft: true flag is gone and the file is dated 2026-08-17. Worth
remembering for the next release post, since both bit here: Docusaurus takes the post date from the filename,
not from any frontmatter field, and the slug is what fixes the permalink, so renaming the file for the real
release date does not move the published URL.
Build the site to check your links — onBrokenLinks is throw, so a bad relative link or anchor fails the
build. A post with draft: true is excluded from a production build, which means its links are not validated,
and its tags and authors are not resolved either. Un-drafting is therefore the first real check of a post, so
build straight after flipping the flag rather than assuming a green build beforehand covered it.
docs/plans/ is a second Docusaurus docs instance, served at /plans under the Agent Plans navbar item. It
is where ce-brainstorm and ce-plan already write, so a plan is published simply by being created there — the
sidebar is autogenerated from the folder and needs no config change. Plans are public on purpose: users of the
package get the reasoning behind a change, not just the change.
Three things follow from that:
.md is parsed as CommonMark, not MDX (markdown.format: 'detect'), so angle brackets and braces outside
code fences are safe. Use .mdx only when you actually want JSX.```mermaid fenced blocks render as diagrams. They are drawn in the browser,
so they do not appear in the built HTML — that is expected, not a broken build.Since the site deploys from the default branch, a plan written on a next release branch goes live when that release is cut, along with the documentation for the work it planned.
Five skills from the compound-engineering-plugin
(MIT, © Every) are vendored into .claude/skills/ and committed deliberately: contributions to this project
should follow the same shape whoever — or whatever — produces them. Use them rather than improvising an
equivalent workflow.
| Skill | Use it when |
|---|---|
ce-brainstorm |
The scope is vague. Exploring what to build before there is anything to plan. |
ce-plan |
The work is understood but multi-step. Produces the plan ce-work then executes. |
ce-work |
Executing a plan or a concrete build request end-to-end. |
ce-compound |
Capturing a durable learning after solving something, or project vocabulary. |
ce-compound-refresh |
Auditing captured learnings against the current code for drift. |
A rough progression: ce-brainstorm → ce-plan → ce-work → ce-compound. Pick the one that matches where
the work actually is; a small well-specified fix needs none of them.
Two things to know about the vendored copies:
ce-work's shipping tail is disabled here by the hard rule above. Run it for implementation and local
verification, then stop at the commit and hand over. Do not let it commit, push, or open a PR.lfg, ce-pov,
ce-prototype, ce-debug and similar are unavailable — treat those as "not an option here" rather than
something to go and fetch. ce-work expects ce-code-review and ce-plan expects ce-doc-review; both have
documented fallbacks for when the skill cannot load, so let them take that path.Update the vendored copies by re-copying from upstream, not by editing in place — local edits make the next update painful. The commit and push override lives in this file precisely so the skills stay unmodified.
feature/* or bugfix/* branch that
merges back into it via a PR. Only a hotfix/* or a task/* targets the major branch. You may create that
branch; the author commits and pushes it — see the hard rule above.docs/docs/** in the same change as the behaviour, and docs/docs/upgrading.md for anything that
breaks 1.x usage. Build the docs to validate links.True at the time of writing — confirm before relying on any of it.
ActionIntent::after() is inconsistent: writes to the render buffers reach the output, but writes to
$descriptor->attributes do not, because attributes are copied into the view data before it runs. Use
before() for anything that must affect the markup.of and to are registered as global JSON translation keys, so a host application's own __('of') can
collide with the package's in either direction.Table constructed directly (new UserTable()) instead of resolved from the container fatals on render,
and docs/docs/simple-table.md shows new UserTable() being handed to a view.tests/coverage/ is regenerated by composer test and ignored by its own .gitignore. Never commit it.How can I help you explore Laravel packages today?