Search by

alemian95 / laraveldatatable

alemian95

Server-side datatables for Laravel: search, sort, filter and paginate any Eloquent or Query Builder query from HTTP parameters, with a React companion package.

Package info

github.com/alemian95/LaravelDatatable

Homepage

pkg:composer/alemian95/laraveldatatable

Fund package maintenance!

alemian95

Statistics

Installs: 8

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

v1.1.0 2026-10-12 10:59 UTC

README

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

A lightweight, server-side datatable query layer for Laravel. Wrap any Eloquent or Query Builder instance and get standardized JSON pagination, search, sorting and filtering driven by HTTP request parameters — with hooks to override each step.

Requirements

  • PHP ^8.3
  • Laravel ^12.0 || ^13.0

Installation

Install via Composer:

composer require alemian95/laraveldatatable

The service provider is auto-discovered (AleMian95\Datatable\DatatableServiceProvider) — no manual registration required.

Frontend (React)

If you want the ready-made React datatable, run the install command:

php artisan datatable:install

It installs the @alemian95/laraveldatatable-react npm package (detecting npm / pnpm / yarn / bun from your lockfile), publishes the config file, offers the Agent Skill, warns about missing peer dependencies (react, react-dom, tailwindcss), and prints the remaining setup steps. The frontend is optional — the backend works on its own. See the React package README for the component API.

AI coding agents

The package ships an Agent Skill: instructions that teach coding agents (Claude Code, Codex, Cursor, Copilot, Gemini, Junie) its public API, the HTTP contract and the React component. Agents load it only when a task touches the package.

  • With Laravel Boost: run php artisan boost:install (or boost:update) and select alemian95/laraveldatatable. Boost copies the skill into each agent's folder and refreshes it on boost:update.

  • Without Boost: datatable:install offers to copy it into .claude/skills/laraveldatatable/ and .agents/skills/laraveldatatable/. After updating the package, refresh the copies with:

    php artisan vendor:publish --tag=laraveldatatable-skill --force

    Local edits to the copies are overwritten by the refresh.

Optionally, publish the config file to override defaults:

php artisan vendor:publish --tag="laraveldatatable-config"

This is the contents of the published config/laraveldatatable.php:

return [
    'default' => [
        // Default page size used when the request omits "per_page".
        'per_page' => 15,

        // Hard upper bound: a larger "per_page" is clamped down to this cap.
        'max_per_page' => 100,
    ],

    'debug' => [
        // When true, the interpolated SQL of each query is logged at "info".
        // Off by default: the SQL can contain the raw search term (PII).
        'log_sql' => false,
    ],

    'search' => [
        // When true: fall back to Schema introspection if no whitelist is declared.
        // When false: declaring HasSearchableColumns or withSearchableColumns() is mandatory.
        'auto_discover_columns' => false,

        // Column names / wildcard patterns always excluded from auto-discovery.
        'auto_discovery_blacklist' => [
            'password', 'remember_token', 'api_token',
            '*_token', '*_secret', '*_hash', '*_key',
        ],
    ],
];

Usage

The main entry point is AleMian95\Datatable\DatatableApi. Create it with DatatableApi::for($query) and return it from a controller: it is Responsable, so Laravel turns it into a paginated JSON response. Call toPaginator() to get the paginator (or resource collection) directly, e.g. as an Inertia prop. Each call runs on a clone of your query, so the same instance can be executed more than once.

Quick start

use AleMian95\Datatable\DatatableApi;
use App\Models\User;

public function index()
{
    return DatatableApi::for(User::query());
}

That call already paginates. Search and sort are opt-in: declare what the client may use with withSearchableColumns() (or the HasSearchableColumns contract on the model) and withSortableColumns(). Anything undeclared is ignored with a log warning.

HTTP request contract

DatatableRequest parses the following parameters from the incoming request:

Parameter Type Default Purpose
search string null Free-text term applied with case-insensitive LIKE %term%.
search_columns csv string auto-resolved (see Advanced) Columns to search in. Supports dot-notation relation.column.
sort_by string null Column to sort by. Supports dot-notation for BelongsTo relations.
sort_order asc | desc asc Sort direction.
per_page int config('laraveldatatable.default.per_page', 15) Results per page.
page int 1 Page number.
filter[<key>] string | [from]/[to] none Client filter; applied only when <key> is declared via withFilters().

Example request:

GET /api/users?search=jane&search_columns=first_name,last_name,email&sort_by=created_at&sort_order=desc&per_page=25

Response shape

Without a resource, the response is a standard Laravel length-aware paginator:

{
  "current_page": 1,
  "data": [ { "id": 1, "name": "Jane Doe", "...": "..." } ],
  "first_page_url": "...",
  "from": 1,
  "last_page": 5,
  "last_page_url": "...",
  "links": [ /* ... */ ],
  "next_page_url": "...",
  "path": "...",
  "per_page": 15,
  "prev_page_url": null,
  "to": 15,
  "total": 75
}

When returnResource(ResourceClass::class) is used, the paginator is wrapped in ResourceClass::collection($paginator), producing the conventional { "data": [...], "links": {...}, "meta": {...} } envelope.

Customizing the pipeline

Each builder method below returns $this, so they can be chained freely.

  • DatatableApi::for(Builder $query, ?Request $request = null): self — accepts an Eloquent builder, a Relation, or a base QueryBuilder. Without $request the current request is used; pass one explicitly in jobs, commands and tests.
  • withCustomSearch(Closure $search): self — overrides the default LIKE/auto-column search. The closure receives ($builder, string $term) and is responsible for the full search clause.
  • withCustomSorts(array $sorts): self — map of sort_by value → Closure($builder, string $direction). Triggered only when the incoming sort_by matches a key; otherwise the default sort logic runs.
  • withSortableColumns(array $columns): self — authoritative whitelist for the sort_by parameter (dot-notation entries included, e.g. author.name). A sort_by outside the list is dropped with a warning instead of reaching the database; keys declared via withCustomSorts() are always allowed. Without it, only withCustomSorts() keys are sortable.
  • withFilters(array $filters): self — map of filter[<key>] name → Closure($builder, string|array $value). A closure runs only when its key is present; range filters receive ['from' => ?string, 'to' => ?string]. Type the value as string or array to accept only that shape: a value of the other shape is ignored like a malformed one, instead of reaching the closure. Malformed values and undeclared keys are ignored with a log warning. Fixed constraints (tenant, active scope) belong on the query you pass to for().
  • withSearchableColumns(array $columns): self — declares the authoritative whitelist of columns the search can target for this instance. Wins over the HasSearchableColumns contract on the model and is the only way to enable search on a raw QueryBuilder when auto_discover_columns is false. When set, search_columns from the request is intersected against this whitelist.
  • returnResource(string $resourceClass): self — fully-qualified API Resource class name. Output is wrapped via Resource::collection($paginator).

Every with* method replaces the previous declaration; the last call wins.

Full chained example:

use AleMian95\Datatable\DatatableApi;
use App\Http\Resources\UserResource;
use App\Models\User;

public function index()
{
    return DatatableApi::for(
            User::query()->where('active', true)->with('profile', 'role')
        )
        ->withCustomSorts([
            'full_name' => fn ($builder, $direction) =>
                $builder->orderByRaw("CONCAT(first_name, ' ', last_name) {$direction}"),
        ])
        ->withSortableColumns(['created_at', 'email'])
        ->withFilters([
            'status' => fn ($builder, string $value) => $builder->where('status', $value),
            'created_at' => fn ($builder, array $range) => $builder
                ->when($range['from'], fn ($q, $from) => $q->whereDate('created_at', '>=', $from))
                ->when($range['to'], fn ($q, $to) => $q->whereDate('created_at', '<=', $to)),
        ])
        ->returnResource(UserResource::class);
}

Searchable columns

The set of columns that can be searched is resolved in this order:

  1. DatatableApi::withSearchableColumns(['col_a', 'col_b']) — wins over everything.
  2. Model implements HasSearchableColumns — the contract returns the whitelist (the trait Concerns\HasSearchableColumns reads a protected array $searchable = [...] property by default).
  3. Auto-discovery via Schema::getColumns() — fallback only when config('laraveldatatable.search.auto_discover_columns') is true (off by default since 0.9). Filters out non-string columns and applies the auto_discovery_blacklist. When the request supplies search_columns in this branch, they are intersected against the auto-discovery result — so the type filter and the blacklist also protect against client-supplied column names.

When a whitelist is declared, search_columns from the HTTP request is intersected against it: the client can never broaden it. An empty whitelist (withSearchableColumns([]) or protected array $searchable = []) is treated as an authoritative signal to omit the search clause entirely — no LIKE is applied, the dataset is returned unfiltered by the search term (pagination, sorting and other filters still apply), and there is no fallback to the next source. When no source can satisfy the request and auto-discovery is off, the search is ignored with a log warning: the request still succeeds, unfiltered by the term.

Example with the trait:

use AleMian95\Datatable\Contracts\HasSearchableColumns;
use AleMian95\Datatable\Concerns\HasSearchableColumns as HasSearchableColumnsTrait;

class User extends Model implements HasSearchableColumns
{
    use HasSearchableColumnsTrait;

    protected array $searchable = ['first_name', 'last_name', 'email', 'profile.bio'];
}

Example with the per-request override (works for both Eloquent and raw QueryBuilder):

return DatatableApi::for(DB::table('users'))
    ->withSearchableColumns(['name', 'email']);

To make declaration mandatory project-wide, set in config/laraveldatatable.php:

'search' => [
    'auto_discover_columns' => false,
],

Resolver lifecycle. The SearchColumnResolver is bound to the container as a scoped instance, so each HTTP request / queue job receives a fresh resolver built from the current laraveldatatable.search.* values. This means multi-tenant setups that swap the config per request get the expected per-tenant behavior with no extra work. For the rare case of changing the config mid-request (e.g. inside tests), call app()->forgetInstance(\AleMian95\Datatable\Contracts\SearchColumnResolver::class) after the change to force re-resolution.

Relational search

When search_columns contains a dot — author.name, tags.label — the package needs to know how to resolve the relation segment (author, tags) into SQL.

On Eloquent builders the relation is auto-discovered from the model. No extra configuration is needed:

return DatatableApi::for(Book::query())
    ->withSearchableColumns(['title', 'author.name', 'tags.label']);

Supported relation types via auto-discovery: BelongsTo, HasOne, HasMany, BelongsToMany. Other relation types (MorphTo, HasManyThrough, …) need an explicit declaration via withRelationSearch() using RelationSearch::custom().

On a raw QueryBuilder there is no model to introspect, so the relation must be declared explicitly:

use AleMian95\Datatable\Search\RelationSearch;

return DatatableApi::for(DB::table('books'))
    ->withSearchableColumns(['title', 'author.name'])
    ->withRelationSearch([
        'author' => RelationSearch::belongsTo('authors'),
    ]);

Smart defaults follow the Laravel conventions: belongsTo('authors') assumes author_id and id. Override only when the schema diverges:

->withRelationSearch([
    'author'    => RelationSearch::belongsTo('writers', localKey: 'written_by', remoteKey: 'uuid'),
    'publisher' => RelationSearch::hasOne('publishers', foreignKey: 'book_isbn', localKey: 'isbn'),
    'tags'      => RelationSearch::belongsToMany('tags', pivot: 'book_tag'),
])

A declared spec wins over Eloquent auto-discovery for the same relation key, which is the right tool to inject custom scopes (soft-delete filtering, tenant constraints) without rewriting the whole search:

->withRelationSearch([
    'author' => RelationSearch::custom(function ($query, $remoteColumn, $term) {
        $query->orWhereExists(fn ($sub) =>
            $sub->from('authors')
                ->whereColumn('authors.id', 'books.author_id')
                ->whereNull('authors.deleted_at')
                ->whereLike("authors.{$remoteColumn}", "%{$term}%")
        );
    }),
])

Multi-hop dotted paths (book.author.country.name) are resolved automatically on Eloquent via the existing orWhereHas chain. On raw QueryBuilder multi-hop is unsupported — the column is dropped with a Log::warning.

Generated SQL uses orWhereExists with explicit key joins (and an inner join for belongsToMany). Columns are always qualified table.column to avoid ambiguity with the base table.

Migrating from older versions that already used Eloquent dot-notation search: the row set returned by the package is unchanged. The SQL emitted internally changes from orWhereHas(...) to orWhereExists(...); this is visible in query logs and any test that asserts on the raw SQL text, but is invisible at the result-set level.

Known limits

  1. Multi-hop dot-notation on raw QueryBuilder. Single-hop paths (author.name) work on both Eloquent and raw queries — see Relational search. Multi-hop paths (author.country.name) are supported only on Eloquent (resolved via orWhereHas); on a raw QueryBuilder they are dropped with a Log::warning.

  2. Relational sorting supports BelongsTo only. For sort_by=author.name, SortApplier performs a leftJoin on each BelongsTo segment and then orders by the joined column. A path whose segments are not all BelongsTo is dropped with a log warning. Expose such sorts via withCustomSorts(...).

  3. Joined columns on a raw QueryBuilder need a custom search. Plain search columns are qualified with the base table (order_items.name), and a dotted entry always means a relation, never table.column. To search a joined table, use withCustomSearch():

    DatatableApi::for(DB::table('order_items')->join('products', 'products.id', '=', 'order_items.product_id'))
        ->withCustomSearch(fn ($q, string $term) => $q->whereLike('products.name', "%{$term}%"));
  4. SQL logging is opt-in. With laraveldatatable.debug.log_sql set to true, each assembled query is written to the log at info level. It is off by default because the interpolated SQL contains the raw search term.

Versioning

The package follows semantic versioning. The public API, the one a minor or patch release never breaks, is listed in ADR 0002:

  • DatatableApi (final, created with DatatableApi::for()), Search\RelationSearch, the HasSearchableColumns contract and trait, SearchColumnsNotConfiguredException, the config keys and the datatable:install command;
  • the HTTP contract: the query parameters and the two response envelopes above;
  • every export of the React package.

Everything marked @internal (appliers, resolvers, DatatableRequest, …) can change in any release. The Composer package and the npm package are released together with the same version number.

Testing

composer test

The suite runs on in-memory SQLite by default. To run it against MySQL or Postgres, point it at a server with DB_CONNECTION (mysql | pgsql), plus DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD as needed. The defaults are 127.0.0.1, testing, root/postgres and secret:

DB_CONNECTION=pgsql DB_PORT=5432 composer test

CI runs it on SQLite, MySQL 8.4 and Postgres 17.

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security Vulnerabilities

Please review our security policy on how to report security vulnerabilities.

Credits

License

The MIT License (MIT). Please see License File for more information.