Skip to content

LaraGrid — an Excel-style, keyboard-first datagrid for Laravel + Livewire

LaraGrid is a Laravel datagrid package extracted from a production accounting system built for spreadsheet-trained operators, then made app-neutral. The engine is framework-free vanilla JavaScript that owns every cell it paints: the grid body lives inside a wire:ignore region, Livewire never morphs a row, and all server traffic runs over renderless RPCs. The result is spreadsheet-grade speed with Laravel-grade authority — every edit is validated, authorized and recomputed server-side.

Everything is configured in your component class with chained methods. No Blade wiring, no JavaScript to write, no npm step — composer require is the entire install.

composer require unnathianalytics/laragrid

PHP 8.1+ · Laravel 10 / 11 / 12 / 13 · Livewire 4.1+ (installed automatically) · MIT licensed · Packagist · GitHub

See it working

The eight most-viewed properties from this demo's 501-row table. Click a header to sort, drag a selection and read Count / Sum / Average off the status bar, then press Ctrl+C and paste straight into Excel.

The three modes

ModeDeclare withWhat you getDemo
Display rows passed to the tag Paints in-memory rows on a plain Blade page with no Livewire component. sortable() columns sort client-side — stable, type-aware, empties last. Built for computed report grids (trial balance, ageing) that can never be query()-backed. Display grid →
Readonly, server-side ->query(fn () => Model::query()) Sorting, global search, filters and pagination through a whitelisted fail-closed pipeline. Page 1 ships in the initial payload for a zero-round-trip first paint; later pages stream over an RPC with an LRU cache and idle prefetch. Opt-in CSV/XLSX/PDF export and per-user saved views. Readonly grid →
Editable ->editable()->rowsFrom('lines') The full spreadsheet: optimistic client, authoritative server, a typed op protocol, validation on both sides, formula columns, async pickers with row enrichment, auto-append, undo/redo and live footer totals. Editable grid → · Voucher grid →

What a Laravel datagrid should do out of the box

Server-side sorting, search & filters

Declare searchable(), filters() and sortable() and the toolbar renders itself. Every narrowing runs through a whitelisted, bound-parameter SQL pipeline that is fail-closed by construction — nothing the browser sends can widen the query.

Pagination that adapts

paginate() with a per-page picker, plus singlePageUpTo(N): whenever the filtered set fits, the grid serves it whole and drops the pager. Oversized first pages defer to a post-boot fetch, so mount HTML stays small at any table size.

CSV, Excel and PDF export

exportable() downloads the operator's current view — active sort, search and filters over the whole filtered set. All three writers are dependency-free: BOM-ed UTF-8 CSV, native SpreadsheetML XLSX with typed number cells, and a native A4 PDF writer. Register your own for anything else.

Per-user saved views

savedViews() persists named snapshots of search + filters + sort + per-page + column layout, server-side and scoped to the authenticated operator. Views are sanitized against the grid's declared surface, so one operator can never see, apply or delete another's.

Inline editing with a real contract

The client paints every keystroke and streams typed ops; the server authorizes, casts, validates, runs your hooks and recomputes formulas, then reconciles the authoritative values back. Rows are addressed by stable keys, never positions.

Formula columns in two runtimes

FormulaColumn::make('amount')->formula('round(qty * rate, 2)') — evaluated live in the browser for instant feedback and authoritatively in PHP by a twin evaluator, pinned to the same committed vectors.

Async pickers that enrich the row

SearchSelectColumn streams options over an RPC and onSelect() pre-fills dependent cells server-side; formula columns recompute after the hook, so one pick updates the whole row in a single round trip.

Undo, redo and bulk paste

100 steps of history where one gesture is one step — a 200-cell paste, a fill-down, a row delete. Undo replays through the same op protocol as typing, so it can never resurrect a value your rules would refuse.

Actions, fail-closed end to end

Row, bulk and toolbar actions. The client echoes only an action name; the server re-authorizes the grid gate and the action gate, re-resolves the row from its authoritative source, and re-checks visible() before your closure runs.

Theming with CSS tokens

Six shipped schemes with coordinated dark variants, three densities, and every visual exposed as a --lgrid-* custom property with a self-contained default. In a Tailwind v4 app it adopts your @theme palette automatically.

Accessible by construction

The grid is one tab stop with a roving active cell exposed through aria-activedescendant, a polite live-region announcer for selection and clipboard changes, and no per-cell tabindex to trap anyone.

Extensible without forking

Custom column types, painters, editors, formatters and parse kinds register through PHP registries and their window.LaraGrid twins. The renderer never learns your types — it asks the registry.

The keyboard, which is the point

Two presets, one switch: keymap('entry') — the serpentine data-entry rhythm Tally and Busy trained a generation of operators on — or keymap('excel'), where Enter moves down and Tab moves right and nothing ever blocks.

KeysAction
Arrows · Tab · Home · End · PageUp / PageDown · Ctrl+edgesnavigate
Shift + movement · Ctrl+Aextend the selection / select all
Ctrl+Ccopy the selection as TSV — pastes into Excel and round-trips back
Type · F2 · double-clickoverwrite or edit a cell
Spacetoggle a checkbox or Yes/No cell in place
Y / Nanswer a Yes/No cell and advance — one keystroke per row
Entercommit and advance — serpentine in entry, down in excel
Deleteclear the selected cells
Shift+Delete · F8delete the row (guarded by minRows)
F9 · Shift+F9display grids: temporarily hide a row / restore all — footers recompute
Insert · Ctrl+Dinsert a row · fill down
Ctrl+Z · Ctrl+Y / Ctrl+Shift+Zundo · redo (editable grids)
Ctrl+Ejump to the first error
ContextMenu · Shift+F10open the row's actions menu
Escapeclear the selection / cancel the edit

Every demo on this site

Readonly Grid

Server-side datagrid: sort, search, filter, export, saved views

Editable Grid

Editable datagrid: inline editing, formulas, async pickers

Voucher Grid

Double-entry voucher grid: balanced completion and Dr/Cr locking

Display Grid

Display-only grid: client-side sort, what-if totals, custom cells

Theming

Six shipped color schemes, dark mode and CSS tokens

LaraForm

The companion keyboard-first Laravel form package

Frequently asked questions

What is LaraGrid?

LaraGrid is an Excel-style, keyboard-first datagrid for Laravel and Livewire. It ships three modes — a display grid for in-memory rows, a readonly server-side register with sorting, search, filters, pagination and exports, and a fully editable entry grid with typed cell editors, formula columns and undo — all configured with chained methods in your component class.

How do I install LaraGrid in a Laravel project?

Run composer require unnathianalytics/laragrid. The service provider auto-discovers and the prebuilt script and stylesheet auto-inject into any page that renders a grid, so there is no layout directive, no npm step and no build. Run php artisan migrate once only if you use saved views.

Does LaraGrid require writing JavaScript?

No. The grid engine is framework-free vanilla JavaScript shipped inside the package. Every behaviour — columns, sorting, filters, validation, formulas, actions, exports, theming — is declared in PHP on the Grid definition. Custom painters, editors, formatters and casts can be registered through window.LaraGrid when an app wants to extend it.

Which Laravel and Livewire versions does LaraGrid support?

PHP 8.1 or newer, Laravel 10, 11, 12 or 13, and Livewire 4.1 or newer, which is installed automatically as a dependency.

Can LaraGrid export a table to CSV, Excel or PDF?

Yes. A readonly grid that declares exportable() gains a toolbar Export control that downloads the operator's current view — active sort, global search and filters applied over the whole filtered set. All three writers are dependency-free: CSV as BOM-ed UTF-8, XLSX as native SpreadsheetML with typed number cells, and a native A4 PDF writer.

How does inline editing stay safe if the client applies keystrokes optimistically?

The client paints every keystroke immediately and streams typed ops to the server, where each write is authorized, cast, validated, run through your hooks and recomputed for formula columns. The response reconciles the authoritative values back into the grid, so an optimistic value can never outlive a rule that refuses it.

Is LaraGrid keyboard accessible?

The keyboard is the primary interface. The grid is a single tab stop with a roving active cell exposed through aria-activedescendant, arrows, Tab, Home, End and Page keys navigate, Ctrl+C copies the selection as TSV, Enter drives a context-aware serpentine entry flow, and Ctrl+Z / Ctrl+Y undo and redo. Two presets ship: entry and excel.

How much does LaraGrid cost?

LaraGrid is free and open source under the MIT license.

The teaser grid's source

<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Resort;
use Illuminate\View\View;
use LaraGrid\Aggregate;
use LaraGrid\Columns\ComputedColumn;
use LaraGrid\Columns\DateColumn;
use LaraGrid\Columns\IntegerColumn;
use LaraGrid\Columns\SerialColumn;
use LaraGrid\Columns\TextColumn;
use LaraGrid\Grid;
use LaraGrid\GridDensity;
use LaraGrid\Support\CellHtml;

/**
 * The overview page — what LaraGrid is, what it costs to install, and a working grid above
 * the fold so the claim is checkable in one scroll.
 *
 * The teaser is a DISPLAY-mode grid (rows handed to the tag, no Livewire component), which
 * keeps the landing page a plain cached Blade render while still being the real component.
 */
class HomeController extends Controller
{
    public function index(): View
    {
        return view('home', [
            'grid' => $this->teaser(),
            'rows' => $this->rows(),
            'resortCount' => Resort::query()->count(),
        ]);
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function rows(): array
    {
        return Resort::query()
            ->whereNotNull('city')
            ->orderByDesc('hits')
            ->limit(8)
            ->get(['id', 'name', 'type', 'city', 'star_rating', 'comparison_tariff', 'rooms', 'hits', 'created_at', 'visibility'])
            ->map(fn (Resort $resort): array => [
                'id' => (int) $resort->id,
                'name' => (string) $resort->name,
                'type' => (string) $resort->type,
                'city' => (string) $resort->city,
                'tariff' => (int) $resort->comparison_tariff,
                'rooms' => (int) $resort->rooms,
                'hits' => (int) $resort->hits,
                'created_at' => optional($resort->created_at)->toDateString(),
                'visibility' => (string) ($resort->visibility ?? 'show'),
            ])
            ->all();
    }

    private function teaser(): Grid
    {
        return Grid::make('teaser')
            ->toolbar(false)
            ->defaultSort('hits', 'desc')
            ->columns([
                SerialColumn::make(),
                TextColumn::make('name')->label('Resort')->sortable()->minWidth(200)->grow(),
                TextColumn::make('type')->label('Type')->sortable()->width(120),
                TextColumn::make('city')->label('City')->sortable()->width(120),
                IntegerColumn::make('tariff')->label('Tariff')->sortable()->width(110)
                    ->align('right')->format('inr'),
                IntegerColumn::make('rooms')->label('Rooms')->sortable()->width(90)->align('right'),
                IntegerColumn::make('hits')->label('Views')->sortable()->width(100)
                    ->align('right')->format('number'),
                ComputedColumn::make('status')->label('Status')->html()->width(90)->align('center')
                    ->state(fn (array $row): string => ($row['visibility'] ?? 'show') === 'show'
                        ? CellHtml::badge('green', 'Live')
                        : CellHtml::badge('zinc', 'Hidden')),
                DateColumn::make('created_at')->label('Added')->sortable()->width(110),
            ])
            ->footer([
                Aggregate::sum('rooms')->format('number'),
                Aggregate::sum('hits')->format('number'),
            ])
            ->stickyHeader()
            ->striped()
            ->statusBar()
            ->density(GridDensity::Compact)
            ->theme('blue')
            ->maxHeight('none');
    }
}
app/Http/Controllers/HomeController.php