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

For production, publish the prebuilt assets once per upgrade — php artisan vendor:publish --tag=laragrid-assets --force — and the tags point at static files instead of a Laravel boot; a copy left by an older release is ignored, so old JavaScript is never paired with new PHP.

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.

Saved

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. A Livewire host may add trusted CSV/XLSX/PDF downloads with exportRows() without changing the mode. 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, per-user saved views, and session-sticky filters via persistQuery(). 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. In-memory reports export too, through a trusted exportRows() resolver. Register your own writer 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.

Sticky filters and draft recovery

persistQuery() keeps the current search, filters, sort and per-page for the session — read on the server before page 1 is built, so there is no unfiltered first paint. persistDraft() keeps an editable grid's rows, queued ops and errors in IndexedDB until the save is confirmed; a reload offers Restore / Discard.

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. optionsDependOn() lets the option list depend on sibling cells without the client ever reusing one row's list for another.

Column focus modes, the ERP way

focusMode(FocusMode::Always | Manual | Never, default: …) — the Variable, Semi-Variable and Fixed fields of voucher entry. Manual columns are skipped by Enter and Tab but edit on click; Never columns are skipped and locked; a static or closure default() seeds every fresh row.

A Save button that cannot race the grid

grid.commitAndSave('save') commits the open editor, flushes, waits for every op to be acknowledged, then calls your Livewire method; the bubbling lgrid:statechange event carries canSave for a declarative button, and the grid's own sync bar reports Syncing, Offline and Retry states.

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; after Ctrl+A the copy carries the column headers, and a pasted header row is skipped
Type · double-clickoverwrite or edit a cell — FocusMode::Manual columns take the click but are skipped by Enter and Tab
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; on a readonly grid it activates the row
Deleteclear the selected cells
F9 · Shift+Deleteeditable grids: delete the row (guarded by minRows)
F9 · Shift+F9readonly and display grids: temporarily hide a row / restore all — footers recompute
Insert · F7 · Ctrl+Dinsert a row · repeat the row above · fill down
Ctrl+Z · Ctrl+Y / Ctrl+Shift+Zundo · redo (editable grids)
Ctrl+Efocus a validation error — the footer's Review errors control lists every message
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, sticky filters, export, saved views

Editable Grid

Editable datagrid: inline editing, focus modes, formulas, async pickers, draft recovery

Voucher Grid

Double-entry voucher grid: balanced completion, Dr/Cr locking, row-dependent pickers

Display Grid

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

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.

Can LaraGrid skip fields during keyboard entry, the way Busy or Tally do?

Yes. Every column takes a focus mode: FocusMode::Always is the ordinary stop-here field, FocusMode::Manual is skipped by Enter and Tab but still edits on a mouse click (the semi-variable field), and FocusMode::Never is skipped and locked (the fixed field). Each can carry a static or closure default that seeds every fresh row, so a voucher date or a default quantity is filled in without a keystroke.

Does LaraGrid remember filters when the operator leaves a list and comes back?

Yes, opt-in per grid with persistQuery(). The current search, filters, sort and per-page live in the Laravel session for the rest of it, are read on the server before page one is built (so the first paint is already narrowed), and values a filter no longer accepts are dropped rather than replayed. The page number is deliberately not kept. Column widths and hidden columns persist separately in localStorage with persistWidths(), and savedViews() stores named snapshots per user.

Can a display-only, in-memory report grid be exported?

Yes, when it is hosted by a Livewire component that declares exportRows() and authorize(). LaraGrid never exports the rows a page painted; the resolver rebuilds the rows on the server from the host's own data, receiving only the normalized current sort, search and filters, and the download goes through the same fail-closed export pipeline as a query() grid.

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