mercurioplatform / tables
Reusable list/table engine for Laravel admin (Bootstrap 5 + jQuery + Blade).
Requires
- php: ^8.3
- laravel/framework: ^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.27
- openspout/openspout: ^4.26
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^11.0|^12.0|^13.0
Suggests
- openspout/openspout: Required for XLSX export (^4.26). Install in the host project to enable `?format=xlsx`.
README
mercurioplatform/tables
Reusable list/table engine для админок на Laravel 13 + Bootstrap 5 + jQuery + Blade. Декларативные Resource-классы превращаются в полноценные admin-страницы (поиск, сортировка, фильтры, saved views, bulk/row actions, экспорт, prefs, history+undo) одной строкой роута.
Why
- 1 Resource-класс = 1 admin-страница.
Route::tablesPage('admin/products', ProductResource::class)— контроллер не нужен, страница, JSON-эндпоинты для bulk/row/export/prefs/log регистрируются автоматически, ресурс попадает вResourceRegistryбез дублирования в конфиге. - 5 путей расширения, не больше: подкласс
Field→cellView()на ячейке → слоты<x-tables::page>→ SCSS theme tokens → ключиconfig/tables.php.vendor:publish --tag=tables-views— эскейп-хатч, когда ничего из перечисленного не подошло. - Без vendor lock-in. Bootstrap 5 + jQuery + Blade-компоненты, без виртуального DOM и SPA-оверхеда. Страница рендерится сервером, AJAX подгружает только
<x-tables::table-root>.
Install
composer require mercurioplatform/tables
Опубликовать ресурсы:
php artisan vendor:publish --tag=tables-config # config/tables.php php artisan vendor:publish --tag=tables-migrations # saved_views / user_table_prefs / action_log / action_progress php artisan vendor:publish --tag=tables-assets # resources/{js,scss}/vendor/tables/ php artisan vendor:publish --tag=tables-views # resources/views/vendor/tables/ — только при необходимости
php artisan migrate
JS- и SCSS-бандлы ассетов подключаются в Vite-конфиге хост-приложения (импорт из resources/{js,scss}/vendor/tables/).
Quickstart (30 секунд)
// app/Tables/Catalog/ProductResource.php namespace App\Tables\Catalog; use App\Models\Product; use Illuminate\Database\Eloquent\Builder; use Mercurio\Tables\Field\StatusField; use Mercurio\Tables\Field\TextField; use Mercurio\Tables\ListResource; class ProductResource extends ListResource { public function key(): string { return 'catalog.products'; } public function query(): Builder { return Product::query(); } public function fields(): array { return [ TextField::make('id', '#')->sortable()->mono()->align('left'), TextField::make('title', 'Название')->sortable(), StatusField::make('status', 'Статус') ->kinds(['published' => 'success', 'draft' => 'secondary']) ->labels(['published' => 'Опубликован', 'draft' => 'Черновик']), ]; } public function searchable(): array { return ['id', 'title']; } }
// routes/web.php use App\Tables\Catalog\ProductResource; use Illuminate\Support\Facades\Route; Route::middleware(['auth'])->group(function () { Route::tablesPage('admin/products', ProductResource::class); });
Namespace hint. Cell-fields (
Mercurio\Tables\Field\*) и form-fields для bulk/row schemas (Mercurio\Tables\Form\Field\*) делят именаTextFieldиNumberField. В Resource'е, использующем оба слоя, удобнее импортировать form-fields под алиасом:use Mercurio\Tables\Field\TextField; use Mercurio\Tables\Field\NumberField; use Mercurio\Tables\Form\Field\TextField as TextInput; use Mercurio\Tables\Form\Field\NumberField as NumberInput;
Откройте /admin/products — рабочая страница со списком, поиском, сортировкой и пагинацией. Bulk/row actions, saved views, фильтры, экспорт включаются добавлением соответствующих методов в ProductResource (см. Features).
Examples
В репозитории есть examples/ — готовые demo-ресурсы на все пять Source-адаптеров (EloquentSource, ArraySource, SqlSource, HttpSource, FileSource CSV/JSONL). Это лучший способ увидеть, как поднимается каждый Source: copy-paste файла в app/Tables/Demo/, миграция + сидер, route-snippet — и demo-страницы работают.
После copy-paste откроется шесть URL'ов:
/admin/tables-demo/eloquent—App\Models\UserчерезEloquentSource/admin/tables-demo/array— справочник из 32 строк черезArraySource/admin/tables-demo/sql— таблицаtables_demo_ordersчерезSqlSource/admin/tables-demo/http— JSONPlaceholder черезHttpSource/admin/tables-demo/file-csv—currencies.csvчерезFileSource::csv()/admin/tables-demo/file-jsonl—instruments.jsonlчерезFileSource::jsonl()
examples/не попадает вvendor/послеcomposer require— он исключён через.gitattributes/composer.json#archive.exclude. Это сознательное архитектурное решение: вендор остаётся с одним только runtime-кодом. Examples живут на GitHub.
Quick-start (copy-paste): examples/README.md. Подробный разбор каждого demo и decision-tree «какой Source выбрать»: docs/examples.md.
JSON API
Любой ListResource может опционально отдавать JSON параллельно Blade-рендеру через Route::tablesApi(...). Macro независим от Route::tablesPage(...) — middleware наследуется от файла маршрутов (routes/api.php → api stack, routes/web.php → web stack), implicit-публикации нет.
// routes/api.php Route::tablesApi('orders', OrdersResource::class)->name('orders');
curl "https://example.test/api/orders?per_page=10&include=summary,capabilities"
Доступно из коробки:
?fields=id,number,total— sparse-fieldsets (whitelist черезApiConfig::allowFields).?format=raw|formatted|both— три режима сериализации значений; per-field overrides?format[total]=both&format[status]=raw(детали вdocs/json-api.md).?filter[status]=paid/?filter[total][gte]=1000— плоский AND-sugar, whitelist operator'ов через существующийField::filterable([Operator::...]).- POST с JSON-body или
?qb=<base64>для произвольного QB-дерева (OR-группы, NOT, вложенность) — см.docs/json-api.md. ?sort=-created_at,?q=…,?per_page=N,?page=N.?savedView=key— мерджится с пришедшимиfilter[..].?include=summary,savedViews,capabilities— опциональные блоки envelope'а.- self-описание ресурса через
?include=schemaили discovery-endpointGET /{uri}/schema— см.docs/json-api.md. - mutations через
POST /{uri}/mutate(cell / row / bulk, JSON body сop-дискриминатором) приapi()->allowMutations(true)— undo-probe через?include=undoToken, queued-bulk через 202 +progress_id; см.docs/json-api.md.
Read-only из коробки. Write активируется явным ApiConfig::allowMutations(true). Для публичного API под Sanctum/токены — Route::middleware('auth:sanctum')->group(...) в routes/api.php.
Полный контракт и error envelope: docs/json-api.md.
Registering resources
Route::tablesPage('admin/products', ProductResource::class) — основной путь. Макрос регистрирует 17 named routes для страницы (index, options, bulk/row actions, export, prefs, saved views, action log), и одновременно записывает FQN ресурса в Route::defaults('resource', ...) на каждый из них. При boot'е сервис-провайдер обходит все маршруты, собирает уникальные defaults['resource'] и вызывает ResourceRegistry::register(). Это даёт два важных свойства:
- Один источник правды.
Route::tablesPage(...)автоматически добавляет ресурс в реестр —config('tables.resources')для него заполнять не нужно. - Совместимо с
php artisan route:cache. Defaults сериализуются вместе сRouteCollection, поэтому enumeration работает и в проде, и в dev'е.
config('tables.resources') остаётся как опциональный override и нужен только в исключениях:
- Ресурс зарегистрирован через
Route::tablesResource(...)(controller-based, безdefaults('resource', ...)на роутах). - Ресурс используется без HTTP-маршрутов: CLI-команды, внешние интеграции, prerender'инг saved views в фоне.
- Хочется явно сузить набор ресурсов, по которым
SystemViewSyncerобходит БД.
SystemViewSyncer запускается в boot() на каждом non-console request'е и синхронизирует savedViews() ресурса с таблицей saved_views (типа system). Защищён fingerprint cache tables.sysviews.fp:{resourceKey} — повторных SQL-вызовов на неизменных объявлениях нет. Полностью выключается через config('tables.sync_system_views', false).
Features
- Saved views — system + user, синхронизируются из Resource-объявления через
php artisan tables:sync-saved-views. - Filter chips + Query Builder — chip / select / range / date / boolean + AST-фильтр через
?qb=base64(json). - Bulk actions — instant / confirm / form / queue (background).
- Row actions — link / inline / quick-edit form / popover-confirm.
- Action log + undo — окно отката для идемпотентных bulk.
- Prefs popover — видимые столбцы / density / page size, persisted на пользователя.
- Summary slot — KPI и funnel-cards над таблицей.
- Streaming export — CSV (default) + JSON + XLSX (opt-in), single endpoint, async fallback через
ExportJobDispatcher. - Localization — ru/en из коробки, namespace
tables::*, расширяется черезvendor:publish --tag=tables-lang. - Inline cell edit — декларативный
editableUsing(...)с policy / rules / transform / options и опциональной регистрацией route'а.
Saved views
SavedView::all() (вся выборка), SavedView::scope('name', label, scopeOrFilter). После изменения объявления — php artisan tables:sync-saved-views синхронизирует system-views в БД (для каждого key()).
public function savedViews(): array { return [ SavedView::all('Все')->default()->position(0), SavedView::scope('published', 'Опубликованные', 'published')->color('green')->position(1), SavedView::scope('draft', 'Черновики', 'draft')->color('amber')->position(2), ]; }
Filters
Любой Field объявляется фильтруемым через ->filterable([Operator::In, Operator::Between, ...]). Для relation-полей (BelongsToField, BelongsToManyField) автокомплит подгружает опции через /<base>/options. Расширенный фильтр (Query Builder) включается автоматически — кнопка в filter-bar:right.
TextField::make('id', '#')->filterable([Operator::In]), MoneyField::make('cached_price_cents', 'Цена') ->filterable([Operator::Between, Operator::Gt, Operator::Lt]),
Bulk actions
Четыре kind: instant() (одним POST), confirm() (с превью), schema(...) (form в offcanvas), ->queue() (фоном через очередь Laravel).
public function bulkActions(): array { return [ BulkAction::make('publish', 'Опубликовать') ->instant() ->handler(BulkUpdateStatusProductsAction::class) ->payload(['status' => 'published']) ->policy(ProductPolicy::class, 'update') ->undoable( capture: fn (array $ids) => Product::whereIn('id', $ids)->pluck('status', 'id')->all(), reverse: fn (array $ids, array $snapshot) => $this->reverseStatus($snapshot), ), ]; }
Row actions
RowAction::link() — ссылка с иконкой; RowAction::make()->using(fn) — inline-handler; ->schema([...]) — quick-edit form в offcanvas; ->confirm()->preview(fn) — popover-подтверждение.
RowAction::make('quick-edit', 'Быстрое редактирование') ->schema([ SelectField::enum('status', 'Статус', ProductStatus::class)->required(), ]) ->using(fn ($row, $payload, $actor) => /* ... */),
Action log
Append-only лог успешных bulk/row-действий пишется автоматически в tables_action_log. Двух-уровневое управление:
- Глобальный kill-switch —
config('tables.action_log.enabled', true).false→ writer не пишет ни одной записи; UI на странице тоже ничего не показывает. - Per-resource UI toggle —
actionHistoryEnabled(): trueна конкретном Resource. По умолчаниюfalse: записи в БД пишутся (если глобальный flagtrue), но HeaderAction «История» в page-head не появляется и offcanvas с журналом не доступен.true→ в header добавляется кнопка «История», offcanvas + undo (дляundoable()-actions в окнеundo_window_minutes) активны.
Сознательный design: писать заранее, чтобы при включении UI на ресурсе позже история была сохранена с момента deploy'а, а не с момента переключения flag'а.
Prefs
Per-user настройки видимых столбцов / density / page size — popover «Настроить таблицу» в filter-bar:right. Хранятся в tables_user_table_prefs (user_id, resource_key). URL > DB > defaults — приоритет разрешения.
Summary
Слот над таблицей для KPI/funnel-карточек. Объявляется на ресурсе:
public function summary(): ?Summary { return KpiSummary::make([ KpiCard::make('total', 'Всего товаров', fn () => Product::count()), KpiCard::make('published', 'Опубликовано', fn () => Product::where('status', 'published')->count()), ]); }
Host может добавить собственные карточки — наследник SummaryCard + host-namespaced Blade-template (Blade::anonymousComponentNamespace(...) в AppServiceProvider). Опционально регистрируется по slug'у в config('tables.summary_cards'). Подробности и обработка ошибок рендера — docs/summary-cards.md.
Export
Стрим в нескольких форматах на текущее состояние списка (search + view + chips + qb + sort + visible columns) через единый endpoint ?format=csv|json|xlsx. CSV/JSON встроены, XLSX — opt-in (composer require openspout/openspout). Лимит — config('tables.export.sync_limit'); выше — ExportJobDispatcher (опционально) или HTTP 413. UI рендерит dropdown форматов автоматически, если зарегистрировано больше одного writer'а. Подробности и кастомный writer — docs/export.md.
Inline cell edit
Поле объявляется редактируемым через editableUsing(...) — один primary-метод, мерджащий policy / validation rules / опциональный transform / целевую колонку БД / опции для select-инпута. Старые shortcut-методы (editable, editColumn, editPolicy, editRules, editOptions) остаются и совместимы.
TextField::make('title') ->editableUsing( policy: [ProductPolicy::class, 'edit'], rules: ['required', 'string', 'max:255'], transform: fn (string $value) => trim($value), ), BelongsToField::make('category') ->editableUsing( policy: [ProductPolicy::class, 'updateCategory'], options: fn () => Category::orderBy('title')->pluck('title', 'id')->all(), ),
Pipeline на PATCH {base}/cells/{id}/{field}: policy → rules → transform → update() внутри транзакции. Любой Throwable из transform логируется как tables.cell_edit.transform_failed и возвращает 422 с translation-ключом tables::cell_edit.transform_failed.
Если ресурс read-only — переопределите ListResource::cellEditEnabled(): false, и route не будет регистрироваться вовсе (404).
Sticky layout
Для любой Route::tablesPage-страницы пакет автоматически включает поведение:
- Sticky checkbox-колонка (
.ap-table__chk) — фиксируется слева при горизонтальной прокрутке широкой таблицы. - Sticky actions-колонка (
.ap-table__more) — фиксируется справа при горизонтальной прокрутке. - Внутренний горизонтальный скролл — если полей много и контент не влазит в ширину карточки, появляется scrollbar внутри
.card, страница по горизонтали не разъезжается.
Поведение включено по умолчанию, без UI-тогглера. Реализация — чистый CSS position: sticky + .tables-scroll { overflow-x: auto } на внутреннем wrapper'е. Никакого JS, никакого custom-API.
Если host опубликовал views через vendor:publish --tag=tables-views ранее — повторите publish (новая разметка <div class="tables-scroll"> появилась в table-root.blade.php). Старые опубликованные views продолжат работать, но sticky-фичу не получат.
Sticky <thead> не реализован. Чистый CSS не может пиннить thead к верху viewport'а, когда у table-обёртки есть overflow-x: auto — .tables-scroll сама становится ближайшим scroll-контейнером для sticky-позиционирования. Решения с max-height (внутренний y-scroll, thead к верху обёртки) и JS-cloning (DataTables-подход) рассмотрены как follow-up; в текущем релизе sticky-thead отсутствует.
Чтобы выключить sticky-поведение колонок в host-приложении (host-side override после tables-стилей):
[data-tables-root] { .tables-scroll { overflow: visible; } table > * > tr > .ap-table__chk, table > * > tr > .ap-table__more { position: static; } }
Localization
Пакет поставляется с двумя локалями — ru и en — namespace tables::* (12 групп: action_log, bulk, cell, confirm, export, filters, prefs, qb, row_actions, saved_views, shell, summary). Переключение через App::setLocale(). Runtime JS обращается к строкам через window.TablesI18n + helper tablesT(key, params).
Дефолты в config/tables.php для UI-меток (qb_button_label, export.button_label, user_prefs.popover_button_label, action_log.header_action_label) указаны как translation keys пакета — переопределение в хосте может передать как ключ ('app::custom.label'), так и готовую строку.
php artisan vendor:publish --tag=tables-lang
Подробности в docs/i18n.md.
Configuration
Все ключи — в config/tables.php (опубликуйте через vendor:publish --tag=tables-config). Ключевые:
guard— guard для policy/Gate-проверок (defaultadmin).default_per_page— fallback дляperPage()в Resource (default 25).partial_header— заголовок AJAX-частичного рендера (defaultX-Tables-Partial).js_event_prefix— префикс DOM-событий (tables:rendered,tables:loading,tables:total-changed).resources— массив FQN ResourceClass дляResourceRegistry. Опциональный — ресурсы, зарегистрированные черезRoute::tablesPage(...), попадают в реестр автоматически (см. Registering resources). Заполняйте только для CLI-only ресурсов,Route::tablesResource(...)и других исключений.sync_system_views— авто-вызовSystemViewSyncerвboot()(defaulttrue). Поставьтеfalse, чтобы полностью выключить синхронизацию system saved views (например, в read-replica окружении).action_log.enabled,action_log.undo_window_minutes,action_log.undo_snapshot_max_bytes— журнал и окно отката.bulk_progress.*— настройки фоновых bulk-actions (poll-интервал, job-class, chunk-size).export.sync_limit,export.chunk_size,export.csv_*,export.async_dispatcher— CSV-экспорт.shell.layout,shell.page_head_component,shell.flash_keys,shell.title_suffix— обёртка страницы.tables.*— имена БД-таблиц движка (для overrides на стороне хоста).
Extending
Пять путей, в порядке возрастания инвазивности:
-
Подкласс
Field— собственный тип ячейки. Достаточноprotected string $cellView = 'admin.tables._cell-foo'и Blade-шаблон с$value/$rowв скоупе.class ColorSwatchField extends Field { protected string $cellView = 'admin.tables._cell-color-swatch'; }
-
->cellView('partial.name')на ячейке существующего поля — без подкласса, переопределить только рендер ячейки. -
Слоты
<x-tables::page>—header,summary,empty-stateпринимают произвольный Blade. -
SCSS theme tokens — переопределить переменные пакета в собственном бандле (импорт
resources/scss/vendor/tables/_tokens.scssпосле собственных). -
Config keys — поведение, не вёрстку (
tables.bulk_progress.poll_interval_ms,tables.export.sync_limit, …).
Эскейп-хатч: vendor:publish --tag=tables-views копирует blade-компоненты в resources/views/vendor/tables/ — приоритет публикации над пакетом.
Development
cd packages/tables composer install composer ci # pint --test + phpstan analyse composer pint:fix # автофикс стиля composer phpstan # только статика
Тесты в пакет не вкладываются; регрессии проверяются на host-приложении.
JS dev-tooling (ESLint)
cd packages/tables npm install # Node ≥ 18.18 npm run lint # eslint resources/js/tables npm run lint:fix # eslint resources/js/tables --fix
tables/package.json существует только для dev-инструментов: runtime-зависимости (jQuery, Bootstrap) поставляет host-приложение через Vite. package-lock.json не коммитится — по аналогии с composer.lock (конвенция library-package'а), чтобы host-приложение свободно резолвило версии своих зависимостей.
Upgrade from v1.1 to v1.2
Изменения, требующие действий на host-приложении:
-
Anonymous components namespace. Internal Blade refs пакета теперь используют namespaced синтаксис
<x-tables::page>,<x-tables::table-root>, и т.д. Если в host-шаблонах есть ссылки в старом формате<x-tables.page>, замените их:grep -rn '<x-tables\.' resources/views/ # ручная замена x-tables.foo → x-tables::foo
-
Host-override
Blade::anonymousComponentPathбольше не нужен. Если вAppServiceProvider::boot()(или другом сервис-провайдере) был добавлен workaround видаBlade::anonymousComponentPath(base_path('vendor/mercurioplatform/tables/resources/views/components'), 'tables')— удалите его. Пакет регистрирует namespace'tables'сам. -
Опубликованные views — переопубликовать. Структура
resources/views/vendor/tables/упрощена: подпапкаcomponents/tables/убрана, файлы переехали вcomponents/. Если делалиvendor:publish --tag=tables-views:php artisan vendor:publish --tag=tables-views --force php artisan view:clear php artisan optimize:clear
Альтернатива — вручную: переместить
resources/views/vendor/tables/components/tables/*вresources/views/vendor/tables/components/, удалить пустуюtables/. -
config('tables.resources')стал опциональным. Ресурсы черезRoute::tablesPage(...)теперь регистрируются автоматически. Можно очистить массив, оставив в нём только CLI-only ресурсы и ресурсы подRoute::tablesResource(...). Подробности — Registering resources. -
php artisan route:cacheв production. Подтверждённо совместим с auto-registry: defaults сериализуются вRouteCollection. Если ещё не используется в деплое — добавьте, это снимет boot-стоимость регистрации ~17 маршрутов на каждый ресурс.
License
MIT © Timur Turdyev