freshbitsweb/laratables
freshbitsweb/laratables simplifies Laravel DataTables server-side processing with Eloquent support, relationship search, eager loading, sorting, and pagination, helping you build fast, interactive data tables with minimal code and less boilerplate.
Laratables is a thin server-side adapter between DataTables' Ajax protocol and Eloquent. It does one job: take a request (with draw, start, length, search, order, columns) and return {draw, recordsTotal, recordsFiltered, data} — including search, ordering, and pagination — without you hand-writing that JSON.
Install and publish config:
composer require freshbitsweb/laratables
php artisan vendor:publish --tag=laratables
Auto-discovery handles the service provider on 5.5+. The published config/laratables.php is tiny — the key you actually care about is max_records, a hard cap on the result set used as a safety valve against a length: -1 (or maliciously large) request.
First use case — a paginated admin index. One route, one line in the controller:
use Freshbitsweb\Laratables\Laratables;
Route::get('/admin/users', fn () => Laratables::records(User::class));
Then in Blade, point DataTables at it. The columns array is the contract — each data key must exist in the rows the endpoint returns:
$('#users').DataTable({
processing: true,
serverSide: true,
ajax: '/admin/users',
columns: [
{ data: 'id' },
{ data: 'name', name: 'name' },
{ data: 'email' },
],
});
Where to look first: the model class you passed in. Laratables is convention-driven — it reflects on the model for laratables* static methods to customize the query, add computed columns, and eager-load relations. Reading those conventions (in the model or the package's Laratables class) tells you more than the readme does. The second place to look is the actual JSON response: curl 'http://app.test/admin/users?draw=1&start=0&length=10' before you ever open the browser, so you can separate "backend is wrong" from "DataTables config is wrong".
If you outgrow the class-driven style, there's a fluent form for ad-hoc tables:
return Laratables::of(User::query())
->addColumn('action', fn ($user) => view('users.actions', compact('user'))->render())
->toJson();
Model hooks are the primary extension point. Instead of configuring everything at the call site, you teach the model how it should be tabulated:
class User extends Model
{
// Scope every table request. This is the single most important hook.
public static function laratablesQueryConditions($query)
{
return $query->where('tenant_id', auth()->user()->tenant_id);
}
// Relation loading — kill the N+1 before it happens.
public static function laratablesEagerLoads()
{
return ['roles', 'company'];
}
// Convention: laratablesCustom{StudlyColumn} overrides the column value.
public static function laratablesCustomName($user)
{
return e("{$user->first_name} {$user->last_name}");
}
public static function laratablesCustomRoles($user)
{
return $user->roles->pluck('name')->implode(', ');
}
}
Actions column. The standard pattern is a custom column that renders a Blade partial and marks it unsortable/unesearchable on the client:
public static function laratablesCustomAction($user)
{
return view('admin.users.actions', ['user' => $user])->render();
}
{ data: 'action', name: 'action', orderable: false, searchable: false }
Whitelist searchable and orderable columns. DataTables sends columns[i][searchable] and order[0][column], and Laratables maps those indexes back to column names. On a table with joins or aliased columns, an unguarded order clause is a SQLSTATE[42S22] unknown column waiting to happen. Constrain it explicitly if your version exposes the searchable/orderable hooks; otherwise validate with a laratablesQueryConditions that inspects the request and falls back to id.
Relation columns. For a company.name column you generally need three things together: a join (or with()) in the query conditions, a qualified column in the searchable/orderable list, and a custom column that renders the value. Do all three or you get partial behavior — sorts that silently do nothing, or searches that match the wrong column.
Authorization is your job, not the package's. Laratables does not apply policies or global scopes differently from any other query. Treat the endpoint as a raw query surface: apply tenant scoping, soft-delete awareness, and role checks inside laratablesQueryConditions, and never accept a model class from user input.
Testing. Assert the envelope, not the whole payload:
$this->actingAs($admin)
->getJson('/admin/users?draw=1&start=0&length=10&search[value]=alice')
->assertJsonStructure(['draw', 'recordsTotal', 'recordsFiltered', 'data'])
->assertJsonPath('draw', 1);
Also assert scoping: log in as a second tenant and confirm recordsTotal drops. That's the test that catches the regression everyone ships.
recordsTotal vs recordsFiltered diverge for a reason. recordsTotal is the unfiltered count, recordsFiltered is after search. If they're equal when you searched, your search isn't hitting the columns you think it is. If they're both wrong, you have a groupBy/having in the query — Laravel's count() on a grouped query returns the wrong number, and Laratables will happily serialize it.max_records cap is silent. Exceed it and you get a truncated data array with no error. Admins who sort by "newest" and scroll are the ones who notice.laratablesCustomName doesn't augment name; it overwrites it in the payload. That's usually what you want, but it means the raw value is no longer available to the front end — expose it under a second key if you need both.$appends and accessors leak into the payload. Every attribute on the model gets serialized. A model with an expensive accessor appended will run that accessor N times per page. select() a narrower column set in the query conditions and prune $appends for table-shaped models.Laratables::records(...) is already a JSON response. Wrapping it in response()->json(...) or an API Resource breaks the draw counter and DataTables will warn about a malformed response.type: 'POST', either add the route to a CSRF-exempt group or send X-CSRF-TOKEN — otherwise you'll get a 419 and DataTables will show "processing" forever with no clue why.created_at arrives as an ISO timestamp (or whatever your cast produces), not a formatted string. Format it with a custom column server-side, or use DataTables' render client-side — but not both, or you'll double-format.data key mismatch on the client or an N+1 you forgot to eager-load.records / recordsOf / recordsOfClass, and various relation-loading hook names). Blog snippets from older majors will 500 on a fresh install. Grep the installed Laratables class for the actual method names before copying anything, and pin the major in composer.json — the package tracks Laravel 5.5 through 11.x, so a Laravel upgrade can move you a major without you noticing.Laratables::of($query) path is where people accidentally pass an unscoped User::query() and expose the whole table. Any table endpoint needs a feature test asserting tenant isolation, because nothing in the package enforces it for you.How can I help you explore Laravel packages today?