ArticleLaravel7 min read

From Project-Based Datatable to Laravel Composer Package

How I turned my Laravel Inertia Datatable project implementation into a reusable Composer package for server-side search, filters, sorting, relations, and pagination.

raprmdn
Rafi Putra Ramadhan
Laravel
PHP
Composer
Eloquent
Datatable
From Project-Based Datatable to Laravel Composer Package

From Project-Based Datatable to Laravel Composer Package

I created a standalone Laravel Composer package called raprmdn/laravel-inertia-datatables.

The original version was built as a complete Laravel, Inertia, React, Tailwind, and shadcn/ui project. It worked as a full implementation, but the backend datatable logic was reusable enough that it should not stay locked inside one project.

So I extracted the core logic into a Composer package. The package has since reached v0.6.0, with a safer column definition API and broader support for Eloquent and Query Builder use cases.

Package: Packagist

Source Code: GitHub

Demo Application: Demo

The Problem

Datatable logic is repeated in many Laravel applications.

Most admin panels and internal tools need similar features:

  • Search
  • Filters
  • Date range filters
  • Sorting
  • Pagination
  • Relationship columns
  • Configurable pagination limits
  • JSON and custom filters
  • Eloquent and Query Builder queries

At first, this logic usually lives directly inside controllers. But after building multiple tables, the same patterns keep repeating.

That was the main reason I wanted to create a package.

Why I Extracted It

The original repository was useful as a project example, but it was not flexible enough to reuse in other applications.

If I wanted the same datatable behavior in another Laravel project, I would need to copy the code manually.

That creates a few problems:

  • Duplicate logic
  • Harder maintenance
  • Inconsistent implementation between projects
  • More controller code than necessary

By moving the datatable logic into a package, I can reuse the same backend behavior across different Laravel applications.

Why Backend First?

Even though the package name includes Inertia, it remains backend-first.

It can be used with:

  • Inertia
  • API resources
  • Blade
  • JSON responses
  • Custom Laravel responses

It does not require Inertia, React, Tailwind, Ziggy, shadcn/ui, or an npm package. Those choices stay in the consuming application.

The backend is where search, filters, sorting, relations, and pagination need to be handled safely and consistently.

Installation

The package requires PHP 8.2 or newer and supports Laravel 10 through 13. Install it using Composer:

Terminal
composer require raprmdn/laravel-inertia-datatables

Publishing the configuration file is optional:

Terminal
php artisan vendor:publish --tag=inertia-datatables-config

Laravel discovers the service provider and facade automatically.

Basic Usage

ColumnDefinition is now the primary public API. Each definition gives the frontend a public key and maps it to a trusted backend column or relation path.

Controller.php
use App\Models\User;
use Illuminate\Http\Request;
use Raprmdn\DataTables\Column;
use Raprmdn\DataTables\Facades\DataTable;
 
public function index(Request $request)
{
    $users = DataTable::query(User::query())
        ->columnDefinitions([
            Column::group(['name', 'email'])->searchable()->sortable(),
            Column::make('status')->filterable()->sortable(),
            Column::make('created_at')->dateRange()->sortable(),
        ])
        ->applyFilters($request->query('filters', []))
        ->applySort($request->string('col')->toString() ?: null)
        ->orderBy('created_at', 'desc')
        ->make();
 
    return response()->json($users);
}

Pagination is the default output. The package reads search, sort direction, and page size from the configured query parameters during make().

For example:

URL
?search=rafi
&filters[]=status:active
&filters[]=created_at_from:01-01-2026
&filters[]=created_at_to:31-01-2026
&col=name
&sort=asc
&limit=25

Unknown filter and sort keys are ignored because they do not have an enabled column definition.

Column Definitions

Column::make() creates one public column definition:

ColumnDefinition
Column::make('author', 'author.name')
    ->searchable()
    ->filterable()
    ->sortable();

Here, author is the request-facing key and author.name is the trusted backend source. Request input should never be used as a source.

Capabilities are opt-in:

  • searchable() enables global search
  • filterable() enables exact, JSON, or custom filtering
  • sortable() enables requested sorting
  • dateRange() enables _from and _to date filters

Use Column::group() when multiple columns share the same capabilities:

ColumnGroup
Column::group([
    'name',
    'email',
    'organization' => 'organization.name',
])->searchable()->sortable();

This allowlist keeps public request keys separate from database columns and relation paths.

Filters and Sorting

Filters use column:value strings. Multiple values for one key are grouped with OR, while different keys are combined with AND.

Controller.php
$users = DataTable::query(User::query())
    ->columnDefinitions([
        Column::make('status', 'status_code')->filterable(),
        Column::make('organization', 'organization.name')
            ->filterable()
            ->sortable(),
        Column::make('created_at')->dateRange()->sortable(),
    ])
    ->applyFilters($request->query('filters', []))
    ->applySort($request->string('col')->toString() ?: null)
    ->orderBy('created_at', 'desc')
    ->make();

Friendly filter values can be mapped to backend values:

FilterAliases
Column::make('verification', 'email_verified_at')
    ->filterable()
    ->filterAliases([
        'verified' => 'NOT NULL',
        'unverified' => 'NULL',
    ]);

Date ranges use the configured format, d-m-Y by default. Either boundary can be omitted. The to boundary includes the complete selected day.

If no valid requested sort exists, orderBy() provides the fallback order.

JSON and Custom Filters

JSON scalar paths work like normal filter sources:

JSONFilter
Column::make(
    'email_notifications',
    'settings->notifications->email',
)->filterable();

JSON arrays or containment queries use jsonContains():

JSONFilter
Column::make('delivery_channel', 'channels')
    ->filterable()
    ->jsonContains();

Application-specific behavior can use a trusted filterUsing() callback. Custom calculated ordering can use sortUsing(). Both remain behind explicitly enabled filterable() or sortable() definitions.

Relationship Support

Eloquent columns support relationship paths using dot notation.

Controller.php
DataTable::query($query)
    ->with(['contact.channel', 'priority'])
    ->withCount('comments')
    ->columnDefinitions([
        Column::group(['number', 'contact.name', 'contact.email'])
            ->searchable(),
        Column::make('priority', 'priority.name')
            ->filterable()
            ->sortable(),
    ])
    ->make();

For example:

SourceColumn
'contact.name'

means:

  • contact is the relationship method on the model
  • name is the column on the related table

Search, filters, and date ranges support nested Eloquent relation paths. Relation sorting supports BelongsTo and HasOne, including nested and self-referencing relations.

Sorting a HasMany or BelongsToMany relation is ambiguous, so the package throws an InvalidArgumentException. Use sortUsing() when an application needs an explicit aggregation.

Query Builder Support

The same column API works with Laravel Query Builder:

Controller.php
use Illuminate\Support\Facades\DB;
 
$query = DB::table('users')
    ->select('users.*')
    ->leftJoin('organizations as organization', function ($join) {
        $join->on('organization.id', '=', 'users.organization_id');
    });
 
$users = DataTable::query($query)
    ->columnDefinitions([
        Column::make('name', 'users.name')->searchable()->sortable(),
        Column::make('organization', 'organization.name')
            ->searchable()
            ->sortable(),
    ])
    ->make();

Query Builder does not resolve Eloquent relations. The caller supplies joins and aliases, and dotted sources are treated as SQL table or alias references.

Pagination and Collections

Pagination is the default. The requested limit is clamped between 1 and the configured maximum, which defaults to 100.

Use collection output when pagination is not needed:

Controller.php
DataTable::query(User::query())
    ->type('collection')
    ->make();

Collection output is unpaginated and ignores page-size settings.

Legacy API

The original parser-based API remains supported for backward compatibility. This includes parseFilters(), parseSort(), searchable(), allowedFilters(), and allowedSorts().

New code should use column definitions. They keep public request keys, trusted backend sources, capabilities, aliases, and callbacks together instead of coordinating separate maps and allowlists.

Current Limitations

The package remains in beta, so the public API may still change before v1.0.0.

Current limitations:

  • Frontend components are not included or required
  • Relation sorting supports BelongsTo and HasOne, not HasMany or BelongsToMany
  • Query Builder joins and aliases must be supplied by the caller
  • Generic frontend filter operators are not included; application-specific behavior uses filterUsing()
  • String column and relation names may not receive complete IDE autocomplete

Final Thoughts

At v0.6.0, the package solves more than the first extracted implementation. It provides one allowlisted API for search, filters, date ranges, sorting, relations, and result output while keeping request-facing keys separate from trusted query sources.

The original project remains available as the complete Laravel and Inertia example application. The package remains backend-first so it can work with Inertia, API resources, Blade, JSON responses, or custom Laravel responses.

Building the feature inside a real project first helped me understand the problem better. After that, extracting it into a package felt much clearer.

Links: