tbtop / admin
Inertia admin builder - PHP DSL pages rendered by the @tbtop React client
Requires
- php: ^8.4
- enshrined/svg-sanitize: ^0.22.0
- illuminate/contracts: ^11.0||^12.0||^13.0
- inertiajs/inertia-laravel: ^3.1
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.5.6
- v0.5.5
- v0.5.4
- v0.5.3
- v0.5.2
- v0.5.1
- v0.5.0
- v0.4.21
- v0.4.20
- v0.4.19
- v0.4.18
- v0.4.17
- v0.4.16
- v0.4.15
- v0.4.14
- v0.4.13
- v0.4.12
- v0.4.11
- v0.4.10
- v0.4.9
- v0.4.8
- v0.4.7
- v0.4.6
- v0.4.5
- v0.4.4
- v0.4.3
- v0.4.2
- v0.4.1
- v0.4.0
- v0.3.0
- v0.2.41
- v0.2.40
- v0.2.39
- v0.2.38
- v0.2.37
- v0.2.36
- v0.2.35
- v0.2.34
- v0.2.33
- v0.2.32
- v0.2.31
- v0.2.30
- v0.2.29
- v0.2.28
- v0.2.27
- v0.2.26
- v0.2.25
- v0.2.24
- v0.2.23
- v0.2.22
- v0.2.21
- v0.2.20
- v0.2.19
- v0.2.18
- v0.2.17
- v0.2.16
- v0.2.15
- v0.2.14
- v0.2.13
- v0.2.12
- v0.2.11
- v0.2.10
- v0.2.9
- v0.2.8
- v0.2.7
- v0.2.6
- v0.2.5
- v0.2.4
- v0.2.3
- v0.2.2
- v0.2.1
- v0.2.0
- dev-feat/mcp-server
- dev-fix/290-chart-query-identity
- dev-fix/orb-portal-https
- dev-cursor/critical-bug-management-c33b
- dev-clawpatch/medium-fnd_sig-feat-cli-command-6d9aeca51b-_81e4cfccca
- dev-clawpatch/medium-fnd_sig-feat-cli-command-5d3f5e7c22-_27286b98b2
- dev-clawpatch/medium-fnd_sig-feat-cli-command-5d3f5e7c22-_155e17764d
- dev-clawpatch/medium-fnd_sig-feat-cli-command-5d3f5e7c22-_1108521d3e
- dev-clawpatch/medium-fnd_sig-feat-cli-command-52d4ffa6ab-_87fd1eacdf
- dev-clawpatch/medium-fnd_sig-feat-cli-command-3c5a3eda70-_b12705828a
- dev-clawpatch/medium-fnd_sig-feat-cli-command-3c5a3eda70-_7dfc3545c4
- dev-clawpatch/medium-fnd_sig-feat-cli-command-3c5a3eda70-_5411599277
- dev-clawpatch/medium-fnd_sig-feat-cli-command-3a2307c7c4-_f25d4b32e0
- dev-clawpatch/medium-fnd_sig-feat-cli-command-3a2307c7c4-_3d192b70ab
- dev-clawpatch/medium-fnd_sig-feat-cli-command-3689187dcd-_650b093d92
- dev-clawpatch/medium-fnd_sig-feat-cli-command-225464b40b-_7be09e2e24
- dev-clawpatch/medium-fnd_sig-feat-cli-command-225464b40b-_66f22f8008
- dev-clawpatch/medium-fnd_sig-feat-cli-command-225464b40b-_297f5169f0
- dev-clawpatch/high-fnd_sig-feat-ui-flow-ccd5fa85fc-79fd_aa15a091e4
- dev-clawpatch/high-fnd_sig-feat-ui-flow-86b9fb88de-b25c_1eeb7afbc7
- dev-clawpatch/high-fnd_sig-feat-ui-flow-4bf0ecdb2c-2aae_59d072c0b8
- dev-clawpatch/high-fnd_sig-feat-ui-flow-2bd919f06c-37a0_47bb63829c
- dev-clawpatch/high-fnd_sig-feat-ui-flow-17bba9f197-ea58_4aa8fa31f1
- dev-clawpatch/high-fnd_sig-feat-route-0fab88a748-41802d_0391a88735
- dev-clawpatch/high-fnd_sig-feat-library-ad59e8263b-6f20_8097474b79
- dev-clawpatch/high-fnd_sig-feat-library-271171d0a2-6a46_a22751e8cb
- dev-fix/richtext-lexical-init-flake
- dev-refactor/staleness-guard-module
- dev-fix/php-client-locale-parity
- dev-clawpatch/pat_fnd-sig-feat-infra-cf39b63118-03_0454e1d81d
- dev-clawpatch/pat_fnd-sig-feat-cli-command-74dc538_cd79d2366c
- dev-clawpatch/pat_fnd-sig-feat-route-13a59bdd2b-0d_f06679dee2
- dev-spec/173-confirmed-defects
- dev-feat/dsl-server-closures
- dev-feat/upload-inline-config
This package is auto-updated.
Last update: 2026-10-01 14:50:24 UTC
README
An admin builder: pages are authored in a PHP DSL, serialized to a JSON structure in Inertia props, and rendered by a React interpreter. Filament's authoring model, without Livewire.
Layout
| Package | What it is |
|---|---|
packages/php |
composer tbtop/admin — DSL, controllers, Effects, nav, uploads |
packages/client |
npm @tbtop/inertia-admin — render layer + Inertia integration |
packages/contracts |
JSON Schema grammar + kitchen-sink fixture (the contract shared by both sides) |
apps/demo |
Laravel 12 + Inertia v3 reference app (acceptance) |
Run the demo
# the demo aliases @tbtop/inertia-admin to packages/client/src, whose Tailwind # source pulls its own deps — install them first (cd packages/client && bun install) cd apps/demo composer install && npm install cp .env.example .env && php artisan key:generate touch database/database.sqlite php artisan migrate --seed && php artisan storage:link npm run dev # keep running: without a Vite dev server or a # prior `npm run build`, admin pages 500 on the # missing manifest php artisan serve --port=8090 # in a second terminal # http://127.0.0.1:8090/admin/posts — admin@admin.com / password
A page in 30 seconds
class PostsIndexPage extends Page { public static function path(): string { return 'posts'; } public static function nav(): ?array { return ['group' => 'Content', 'label' => 'Posts', 'order' => 1]; } public function view(S $s): Node { return $s->stack([ $s->table('posts') ->columns(['title' => 'Title', 'views' => 'Views']) ->searchable(['title']) ->defaultSort('created_at', 'desc') ->query(fn () => Post::query()) ->rowActions([ $s->action('edit')->label('Edit') ->url('/admin/posts/{row.id}/edit'), // row template $s->action('delete')->label('Delete')->color('danger') ->confirm('Delete this post?') ->handle(function (ActionCtx $ctx): Effects { Post::whereKey($ctx->row['id'])->delete(); return Effects::make()->notify('Deleted')->refreshTable(); }, needs: ['row']), ]) ->toNode(), ]); } }
Registration: nothing per page. A panel (listed once in
config/tbtop-admin.php → 'panels') declares a discovery root in its
configure() —
->discoverPages(in: app_path('Admin/Pages'), for: 'App\\Admin\\Pages') — and
every page class under it is found and routed automatically. Adding a screen is
php artisan make:tbtop-page Posts and nothing else. Pages outside that root
(package-owned ones such as the media library) and any page that must come
first are listed explicitly with ->pages([...]); discovery merges them and
drops duplicates. Routes and the table/data/form/action endpoints are wired
under the panel's prefix + middleware.
Discovery results are cached: run php artisan tbtop:cache-pages in
deployment, and php artisan tbtop:clear-cached-pages after adding a page
locally if the cache is warm.
Forms
$s->form('post', [ $s->text('title')->label('Title')->required()->rules('max:200'), $s->text('intro')->label('Intro')->translatable(), // per content locale $s->repeater('sections')->rules('array|max:10')->set('fields', [ $s->text('heading')->required(), ]), $s->actionsRow([ $s->action('save')->label('Save')->keybinding('mod+s')->submit(), ]), ]) ->record($post->toArray()) // initial data → props ->onSubmit(function (ActionCtx $ctx): Effects { // $ctx->form = validated $post->update($ctx->form); return Effects::make()->notify('Saved'); });
- Laravel owns validation: rules are collected from the fields (repeater →
parent.*.child),validate()→ 422 → errors land on the fields. Regex rules must use the array form. - The declarative subset of rules ships to the client as
constraintsfor on-blur validation. - Submit goes through Inertia
router.post(errors bag, history); success effects arrive via flash. - A field with no rules gets a baseline
nullable(otherwise Laravel drops it from the validated payload).
Actions — five kinds
| Spec | What it does |
|---|---|
->url(url) |
Inertia visit; supports {row.id} templates |
->submit() |
submit the nearest (or a named) form |
->handle(fn, needs: [...]) |
POST to a server closure; payload by needs: form/row/selection |
->modal(title, $node) |
client modal with a StructureNode body |
->custom('name', params) |
client registry via defineCustomAction() |
->confirm(title) wraps a server action in a confirm modal. Server closures
resolve by name per-request — they never travel over the wire.
Effects (a closed set): notify | redirect | refreshTable | resetForm | closeModal | haltModal | copyToClipboard | setFormData.
The set is closed: anything non-standard goes through custom or a server
redirect. Adding an effect means changing the schema, the PHP builder and the
client interpreter together, so it is a contract change, not an extension point.
Uploads
An upload field carries its own storage config — there is no global profile registry, and the client cannot override any of it:
$s->upload('doc')->label('Document') ->disk('public')->directory('docs')->visibility('public') ->accept('image/*')->maxSize(5 * 1024 * 1024) ->convertTo('webp')->quality(80), // optional GD conversion $s->upload('gallery')->multiple()->maxFiles(8)->reorderable(),
The endpoint is page-scoped — POST {page-path}/uploads/{field} — and inherits
the page's gate. It answers {data: {path, url}}, and the form value is the
path string (a list of them when multiple()), so $ctx->form['doc'] is
what you persist. A private field's url comes back signed and short-lived,
so the preview renders without exposing the file publicly. saveUsing()
replaces the storage step when you need your own.
The media library is a separate feature with its own manager, tables and
config ('media' in config/tbtop-admin.php) — use $s->media() for it.
The contract
packages/contracts/structure.schema.json is the wire grammar. Gates:
- PHP: the kitchen-sink page validates against the schema + a snapshot
(
UPDATE_FIXTURES=1 vendor/bin/pestto regenerate); - client: the same fixture passes the zod mirror and a render smoke test.
A new block = update the schema + the zod mirror + the fixture in one PR.
Quality gates
cd packages/php && vendor/bin/pest && vendor/bin/phpstan analyse && vendor/bin/pint --test cd packages/client && bun test && bunx tsc --noEmit cd apps/demo && php artisan test
phpstan runs at level 5 (the skeleton default; raise it in phpstan.neon.dist).
Status
See docs/backlog.md for the current gap list (a package-side auth backend is
the known blocker). Per-package contributor notes live in the root CLAUDE.md.