milenmk/laravel-simple-datatables
A lightweight Laravel package for adding simple server-side data tables to your application. Quickly turn Eloquent models into searchable, sortable, and paginated tables with minimal setup and Blade-friendly views—no heavy JavaScript framework required.
Treat this as a source-readable dependency, not a documented framework. With one star and an empty 2.1.13 changelog, the README plus src/ is the real specification. Budget 30–45 minutes to read the package end-to-end before wiring it into a screen — the API surface is small enough that this is faster than guessing.
Install and pin.
composer require milenmk/laravel-simple-datatables
Pin the minor version in composer.json ("milenmk/laravel-simple-datatables": "~2.1.0"). The 2.1.13 release notes are a bare diff link with no described changes — that is not evidence of a bugfix-only patch, so don't let a composer update move you across minor versions unattended. Commit the lockfile.
Confirm your stack assumptions first. The package advertises Livewire components but its changelog URL points at a repo named laravel-simple-data-tables-and-forms, and the description also mentions Blade/Eloquent usage. Check the installed version's composer.json for its Livewire constraint (^2 vs ^3) and its CSS expectations (Tailwind vs Bootstrap markup in the published views). A Livewire 2 package dropped into a Livewire 3 app will fail in ways that look like your bug, not the package's.
Publish anything publishable, then read the service provider. php artisan vendor:publish --provider="Milenmk\..." (confirm the exact provider from vendor/milenmk/laravel-simple-datatables/src/). Published views/config are your override points — know where they land before you start, because you will almost certainly modify the row-action markup.
First use case: one internal index screen. Pick a model you already have a plain paginate() index for — User, Order, Invoice. Generate the Livewire component, define four or five columns, and compare it side by side with what you're replacing. The honest evaluation is "did this save me the sort/search/paginate boilerplate," not "does it look nice."
Skeleton of the shape you're aiming for (verify method names against the installed source — this is the pattern, not a verbatim API contract):
// app/Livewire/OrdersTable.php
class OrdersTable extends Component
{
public string $search = '';
public string $sortField = 'created_at';
public string $sortDirection = 'desc';
public function render()
{
return view('livewire.orders-table');
}
}
{{-- resources/views/livewire/orders-table.blade.php --}}
<livewire:simple-datatable
:query="$this->query()"
:columns="$columns"
:search="$search"
:sort-field="$sortField"
:sort-direction="$sortDirection"
/>
public function query()
{
return Order::query()->with('customer:id,name'); // eager-load what you render
}
If the component doesn't support the exact fluent chain you expect, stop and read src/ — do not start patching around it before you understand its column and query contracts, or you'll end up with a fork you didn't mean to maintain.
Keep the query in a method, not in the Blade view. The single most useful habit with this package is exposing query() as a dedicated method so you can unit-test the filtering logic without rendering anything, and so filters compose cleanly:
public function query()
{
return Order::query()
->with('customer:id,name')
->when($this->status, fn ($q) => $q->where('status', $this->status))
->when($this->dateFrom, fn ($q) => $q->whereDate('created_at', '>=', $this->dateFrom));
}
Filters as public Livewire properties bound to selects/date inputs is the pattern that scales; anything more opinionated (saved filter sets, multi-select facets) is out of scope for this package and you'll be writing it yourself.
Column definitions are where the real work lives. Two categories, and they behave differently:
sortable/searchable and let the package generate the ORDER BY and LIKE clauses. These are cheap and safe.[
['field' => 'id', 'label' => '#', 'sortable' => true],
['field' => 'customer.name', 'label' => 'Customer', 'searchable' => true],
['field' => 'total', 'label' => 'Total', 'render' => fn ($row) => number_format($row->total, 2)],
['field' => 'status', 'label' => 'Status', 'render' => fn ($row) => view('partials.status-badge', ['status' => $row->status])->render()],
]
Row actions belong in a render callback, wired back to the parent component. Don't build an edit form inside the table — emit an event or call a parent method with the row id and let a separate Livewire component own the detail screen. The package is a table, not a CRUD framework; fighting that boundary is where teams burn days.
<button wire:click="edit({{ $row->id }})">Edit</button>
Reuse one table component per model, parameterized by scope. Rather than three near-identical table components for "all orders / my orders / overdue orders," expose a scope property or pass a query from the parent. This keeps the column definitions in one place — which matters a lot when someone asks to add a column to "all the order tables."
Test at the Livewire layer, not the rendered HTML layer. Livewire::test(OrdersTable::class)->set('search', 'acme')->assertSee('Acme Corp') gives you search/sort/filter regression coverage cheaply. Assert on domain-visible strings, not on table markup or class names — the package's Blade can change under you and you don't want your suite coupled to it.
Keep the dependency at the edge. The table component is a view concern. Do not let it leak into services, policies, or domain models — no package-specific traits on your Eloquent models, no package calls from controllers that also do business logic. If you later swap to Filament or a hand-rolled table, the blast radius should be a handful of Blade + Livewire files.
Integration tip for larger admin surfaces: if you're already building forms by hand, use this package only for the list views and keep a separate form solution. Trying to force one library to do both halves is precisely the "bespoke admin panel" you were trying to avoid.
Server-side search + relations is the number-one source of "why is my table empty / why is it slow." If a column is customer.name, the package either joins for you or it doesn't. Verify by inspecting the generated SQL (DB::listen or Telescope) on your first screen. If it doesn't join, searching that column silently no-ops or errors — and the fix is a scoped query with whereHas/join, not a package patch. Similarly, sorting an aliased or computed column (total_with_tax, CONCAT(first, last)) works on some drivers and fails on others; prefer sorting on real columns.
Eager-load everything you render. Server-side tables love N+1 queries: 25 rows × a customer.name relation is 25 extra queries per keystroke, and a server-side table fires a request per search input (debounced). ->with() in the query method is mandatory, not optional.
Every keystroke hits your database. Confirm the search input is debounced (typically ~300ms) and that your table's searchable columns are indexed. If the package doesn't debounce by default, wrap the input yourself. This is also the argument against using it on high-volume tenant-scoped tables: the COUNT(*) for pagination alone can dominate the query cost.
Escape your render callbacks. If a callback returns HTML, any user-derived value in it (names, filenames, free-text notes) must go through e() / Blade's {{ }}. Custom render callbacks are the standard XSS hole in datatable packages, because the package can't tell your trusted markup from user data. Never interpolate raw model attributes into a rendered string.
Pagination and filters interact badly if you forget to reset. Changing a filter without resetting to page 1 leaves the user on page 7 of a 1-page result set, showing an empty table. Call the package's page-reset (or reset the underlying paginator) in the updated* hook for each filter property — this is a one-liner you will forget on the second filter you add.
Pin and audit before upgrading. The 2.1.13 notes carry zero signal about what changed. Before any bump: read the actual commits between tags, run your Livewire tests, and click through one real screen. There is no deprecation policy, no security-response commitment, and no SLA — treat upgrades like any other third-party change, and keep the version pinned in composer.json rather than floating on ^2.
Config quirks live in the published views, not the config file. Styling, empty-state text, and wrapper markup are usually in resources/views/vendor/.... When something looks wrong, check there before you assume the query is broken — and remember that published views are now your code; a package upgrade won't update them, so a future bugfix to markup may never reach you. Note in the README of your project that you've published and diverged.
Debug in this order: (1) dump the SQL from query(), (2) check count() vs the rows returned, (3) confirm the Livewire property names match what the component expects, (4) check the installed Livewire major version constraint. This resolves the large majority of "the table renders nothing" reports, and it's faster than reading the package source for the fifth time.
Have an exit plan. It's a single-maintainer dependency covering a low-differentiation need. Before adopting broadly, make sure the query method and column definitions are isolated enough that replacing the table with Filament's table builder, or with a plain Blade @foreach over a paginator, is a half-day job rather than a sprint. If it helps, note in a code comment — one line, in each table component — that this is the swap point. That note is worth more than the hours the package saves on the first screen.
How can I help you explore Laravel packages today?