brackets/admin-listing
Laravel package for building admin listing screens with a fluent API: searchable and filterable tables, sorting, pagination, customizable columns and row actions. Helps you ship consistent CRUD index views faster.
brackets/admin-listing is a small support package from the BRACKETS admin suite. It does not ship routes, views or a UI — it wraps an Eloquent query with search, ordering, pagination and a response transformer so an admin index action can be a one-liner. Most people get it transitively via brackets/admin-ui or brackets/admin-generator; installing it alone is only worth it if you want the query helper without the rest of the suite.
composer require brackets/admin-listing
php artisan vendor:publish --provider="Brackets\AdminListing\AdminListingServiceProvider" --tag=config
That publishes config/admin-listing.php (default limit, default order.column / order.direction, search behaviour, and — since v3.5.0 — the name of the request attribute used for the current page). Read that file first, then read the source — the package is only a handful of files: the AdminListing class, the AdminListing facade, and the HasSearchableAttributes trait. Ignore the repo README if it lags; the generated code from admin-generator is the best documentation.
First real use case — replace a hand-written index query:
// before
$posts = Post::query()->latest()->paginate(15);
return view('admin.posts.index', compact('posts'));
// after
return AdminListing::create(Post::class)->processRequestAndReturn($request, 'admin.posts.index');
To make search work, add the trait and declare the columns on the model:
use Brackets\AdminListing\Traits\HasSearchableAttributes;
class Post extends Model
{
use HasSearchableAttributes;
public $searchable = ['id', 'title', 'slug'];
}
Then hit the page with ?search=foo&orderBy=title&orderDirection=desc&page=2&perPage=25 and confirm the result.
One-liner vs. explicit chain. processRequestAndReturn($request, $view) reads search, orderBy, orderDirection, the page attribute and perPage from the request and returns either a paginator or, for AJAX requests, the JSON envelope the admin Vue tables expect. That convenience is exactly why you should not use it in a non-BRACKETS frontend — it will shape the response for you. For custom consumers, build explicitly:
$listing = AdminListing::create(Post::class, 25)
->attachSearchQuery($request->query('search'))
->attachOrdering($request->query('orderBy'), $request->query('orderDirection'))
->attachMeta();
return $listing->get(); // paginator / collection
(Method names have shifted between releases — confirm attachOrdering vs attachOrderBy against the installed source before copying this.)
Custom limit per controller. AdminListing::create(Post::class, 50) overrides the config default without touching admin-listing.php globally. Useful when one table is much smaller than the rest.
Custom page parameter (v3.5.0). The request attribute used for the page number is now configurable instead of being hard-coded to page. This matters when a listing is embedded next to another paginated component, or when your frontend already uses a different name (p, seite, page[number]). Set it once in config/admin-listing.php and — because the paginator must agree — pass the same name to the underlying LengthAwarePaginator (->paginate($perPage, ['*'], $pageName)), otherwise the links the paginator renders will point at a parameter nobody reads. Confirm the exact config key in the published file rather than guessing; it is new and the README has not caught up.
Transformers. The third argument to processRequestAndReturn (or a ->transform(...) call) lets you reshape rows after pagination — attach computed attributes, flatten relations, or hide columns. This runs on the result set, not the query, so it is cheap and safe, but it cannot fix N+1s. For eager loading, constrain the base query (global scope or a subclass that overrides the built query) rather than transforming afterwards.
Filters map to model scopes. attachFilters($request->all()) is the pattern for "status = published" style toggles; the convention is that each filter key resolves to a scope on the model. Write the scopes as normal Eloquent local scopes and the listing does the wiring. Verify the exact key/scope naming convention in your installed version — this is the part of the API that has churned most.
Use it as a base class. For anything non-trivial (multi-tenant scoping, joins for search across relations), subclass AdminListing, override the query build step, and keep the controllers thin. That is the intended extension point and it keeps you off the package's internals in your day-to-day code.
processRequestAndReturn returns a data + meta envelope for XHR requests (current_page, last_page, per_page, total, search, orderBy, orderDirection). If your frontend isn't the BRACKETS admin UI, call ->get() and shape the response yourself.orderBy yourself. Never pass $request->query('orderBy') straight through if you are on an older release; SQL injection via ORDER BY is a classic and the package's validation is version-dependent. Keep an allow-list in the controller or in the config.LIKE %term% across $searchable. No relevance, no full-text, no relation traversal. On tables past a few hundred thousand rows this becomes the slowest query in the app — add composite indexes or move to Scout/Meilisearch instead of fighting the helper.$searchable fails at request time, not at boot. Add a test that hits the index route with ?search=x for every listing you add; it catches missing columns, missing scopes and broken transformers in one shot.LIKE is case-sensitive; the config exposes a switch for this. Set it once per project rather than per query.orderBy is absent the config's order.column is used — which can be a column that doesn't exist on a newer table. Set sensible defaults per model by overriding in a subclass.perPage from the request is bounded by the limit config in most releases; confirm the behaviour, because an unbounded perPage=10000 will happily serialise your whole table.?page= by hand will silently reset to page 1. Grep for page= when you flip the setting, and keep the frontend and the paginator's pageName in sync.illuminate/* constraint in composer.json and run the index route as a smoke test before trusting it in production. There are no documented breaking changes in 3.5.0, so the upgrade from 3.4.x should be a drop-in.$searchable is a public property on the model. Harmless on its own, but it collides conceptually with Laravel Scout and with any of your own search abstractions — be consistent about which one owns the property in a given model.How can I help you explore Laravel packages today?