awes-io/table-builder
awes-io/table-builder is a Laravel package for creating dynamic data tables with a fluent PHP API. Define columns, filters, sorting, pagination, and row actions, then render responsive, server-side tables from Eloquent queries with minimal code. It simplifies admin dashboards and CRUD interfaces.
Install via Composer and publish the config:
composer require awes-io/table-builder
php artisan vendor:publish --tag=table-builder-config
The package is a server-side table definition layer – it doesn't render HTML itself. You define a Table class, and the frontend (typically the companion awes-io/table Vue component, but any HTTP client works) consumes a JSON payload describing columns, rows, pagination, and sort state.
First use case: a paginated, sortable users list.
AwesIO\TableBuilder\Table:namespace App\Tables;
use AwesIO\TableBuilder\Table;
class UsersTable extends Table
{
public function query()
{
return User::query()->with('roles');
}
public function columns(): array
{
return [
['key' => 'id', 'label' => 'ID', 'sortable' => true],
['key' => 'name', 'label' => 'Name', 'sortable' => true],
['key' => 'email', 'label' => 'Email', 'sortable' => true],
['key' => 'roles.name', 'label' => 'Roles'],
['key' => 'created_at', 'label' => 'Created', 'sortable' => true],
];
}
}
public function index(UsersTable $table)
{
return $table->json(); // applies pagination, sorting, returns columns + rows
}
<aw-table endpoint="/api/users" />
If you're not using the Awes frontend stack, hit GET /api/users?page=1&sort=name&direction=asc&per_page=15 and consume the JSON shape ({ data, meta, columns }) yourself.
The first thing to check when something breaks is the JSON response – inspect columns and the raw rows before touching the Vue component. That isolates backend vs frontend issues immediately.
One table class per resource. Treat the table class like a FormRequest: it owns the query, columns, and transforms. Keep controllers thin – inject the table and return ->json().
Sorting via query-string contract. Standard params are sort, direction, and page / per_page. The table intercepts these and appends orderBy to your query. To customise, override the sorting hook rather than post-processing results:
public function applySorting($query)
{
if (request('sort') === 'full_name') {
return $query->orderByRaw("CONCAT(first_name,' ',last_name) " . $this->direction());
}
return parent::applySorting($query);
}
Custom cell rendering. Column values are resolved from the row by key. For computed values, add a format/render closure or a presenter that receives the model:
['key' => 'status', 'render' => fn ($user) => $user->active ? 'Active' : 'Inactive'],
For Vue-side rendering, return a component name and pass the row through – this is the pattern the Awes UI relies on for badges, buttons and action menus.
Eager-loading is a table concern. Put ->with() in query(). Nested keys like roles.name require the relation to be loaded, otherwise you get N+1s on every page.
Filters. Wire filter inputs into the table by narrowing query() based on request params, e.g. ->when(request('role'), fn($q,$r) => $q->whereHas('roles', fn($q) => $q->where('name',$r))). This keeps filtering in one place and testable independent of the HTTP layer.
Integration with API resources. If you're building an SPA, return a lightweight shape per row using ->through(fn($m) => new UserRowResource($m)) style mapping so the table payload stays small and your API contract stays explicit.
Reusable base table. For multi-tenant or admin-heavy apps, extract a BaseTable with sensible defaults (per-page bounds, default sort, timestamp formatting) and extend it.
applySorting. Sorting on roles.name will throw a SQL error because that's a relation, not a column – alias it or handle it manually.with() alone doesn't make nested keys sortable. Loaded relations are for display; sorting requires a join or a subquery. If you need sortable relations, join them explicitly and qualify column names to avoid ambiguous-column errors.per_page is user-controllable; clamp it server-side (e.g. min(max($n, 10), 100)) or you'll get request-a-million-rows incidents.count(). Eloquent's paginator runs a COUNT(*) on the underlying query, which can be slow with heavy joins. Consider simplePaginate semantics, or override the paginate call with a cached count for very large tables.key is not just a label. The frontend uses it to access row properties and, when sortable, to build the sort param. Mismatches between key and the actual JSON attribute are the most common "row shows empty cell" bug.config/table-builder.php exists before editing. Defaults (per-page, sort direction) live there, not in the class, so overriding in the class wins but config is the global lever.$table->query()->toSql() and bindings if results look wrong – the table mutates the query, so seeing the final SQL is faster than reasoning through layers.Table class is a plain object; you can subclass and override columns(), query(), applySorting(), and the serialization method. Prefer overrides over service-container hacks – the class is designed to be extended, not decorated.composer.json and write at least one integration test that hits the table endpoint, because breaking changes between minor versions are plausible and there's little Stack Overflow coverage to fall back on.How can I help you explore Laravel packages today?