Display-Only Grid — Client-Side Sorting, What-If Totals & Custom Cells
Both grids on this page are painted from in-memory rows on a plain
Blade page: no Livewire component, no query(), no editable() —
just <x-laragrid :grid :rows>. This is the mode for computed report
grids whose rows do not exist as table rows: trial balances, ageings, roll-ups. They
still get the full keyboard model, the selection engine, the column chooser, resizable
and persisted widths, and server-computed footer totals.
Try it: click any header to sort — client-side, stable and type-aware, cycling asc → desc → the original order · select a range and read Count / Sum / Average off the status bar · press F9 on a row to temporarily hide it and watch the footer totals recompute over what is left (the what-if view), then Shift+F9 to bring everything back · Ctrl+C copies the selection as TSV · ▦ hides and restores columns.
City roll-up — a database aggregate, painted as rows
The Tier column is an application-defined column type
(App\Grid\Columns\RatingColumn) drawn by an application-registered painter,
and the money columns use an application-registered 'inr' format with
Indian digit grouping. Neither the package nor the renderer knows either of them exists.
Receivables ageing — hand-built rows, no toolbar, fixed height
Six literal rows in a PHP constant. toolbar(false) strips the chrome,
height('260px') fixes the box, and the footer still totals authoritatively.
Press F9 on the “Over 180 days” row to see the provision total drop.
What this page demonstrates
- Display mode — rows passed to the tag, painted as-is, on a page with no Livewire component at all.
- Client-side sorting —
sortable()without a DB target, plusdefaultSort()applied at load. - What-if totals — F9 / Shift+F9 row hiding with live footer recomputation.
- The extension seams — a custom column type, a custom cell painter, a custom display format and a custom parse kind, each with a PHP half and a JavaScript twin registered under the same name.
- Mode-appropriate refusals —
exportable(),savedViews(),call()actions and bulk actions all fail loudly at build time on a display grid, because none of them can be honoured without a server-side row source.
The source
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Grid\Columns\RatingColumn;
use App\Models\Resort;
use Illuminate\View\View;
use LaraGrid\Actions\Action;
use LaraGrid\Aggregate;
use LaraGrid\ColumnGroup;
use LaraGrid\Columns\ComputedColumn;
use LaraGrid\Columns\DecimalColumn;
use LaraGrid\Columns\IntegerColumn;
use LaraGrid\Columns\SerialColumn;
use LaraGrid\Columns\TextColumn;
use LaraGrid\Grid;
use LaraGrid\GridDensity;
use LaraGrid\Support\CellHtml;
/**
* The DISPLAY-MODE showcase — LaraGrid on a plain Blade page with no Livewire component
* anywhere in sight.
*
* A display grid declares neither ->query() nor ->editable(): rows are handed to the tag
* (<x-laragrid :grid :rows>) and painted as-is, with the same keyboard model, selection
* engine, column chooser, resizing and footer totals as the other two modes. It is the mode
* for computed report grids — trial balances, ageings, roll-ups — that can never be
* query()-backed because their rows do not exist as table rows.
*
* Unique to this mode:
* · ->sortable() sorts CLIENT-SIDE — stable, type-aware, empties last, click cycling
* asc → desc → original order, with ->defaultSort() applied at load
* · F9 / Shift+F9 temporarily hide and restore rows, and the footer aggregates recompute
* over what is left — the what-if view an accountant actually wants
* · only url() row actions are allowed: an in-memory grid has no authoritative server-side
* row source, so call() and bulk actions are refused at build time
*
* This page also carries the EXTENSION demo: a custom column type (RatingColumn), a custom
* painter, a custom display format ('inr') and a custom parse kind ('stars') — PHP halves in
* App\Grid\* and App\Providers\AppServiceProvider, JavaScript twins in the view.
*/
class ReportsController extends Controller
{
/** A small in-memory table — rows that exist nowhere in the database. */
private const AGEING = [
['bucket' => 'Not due', 'invoices' => 128, 'amount' => 4128500, 'weight' => 0],
['bucket' => '1 – 30 days', 'invoices' => 74, 'amount' => 2260750, 'weight' => 15],
['bucket' => '31 – 60 days', 'invoices' => 41, 'amount' => 1189300, 'weight' => 35],
['bucket' => '61 – 90 days', 'invoices' => 22, 'amount' => 742900, 'weight' => 60],
['bucket' => '91 – 180 days', 'invoices' => 13, 'amount' => 415600, 'weight' => 80],
['bucket' => 'Over 180 days', 'invoices' => 7, 'amount' => 233450, 'weight' => 100],
];
public function index(): View
{
$cityRows = $this->cityRows();
return view('reports.index', [
'cityGrid' => $this->cityGrid(),
'cityRows' => $cityRows,
'ageingGrid' => $this->ageingGrid(),
'ageingRows' => $this->ageingRows(),
]);
}
/**
* The roll-up: one row per city, computed by the database but shaped here — exactly the
* kind of result set display mode exists for.
*
* @return list<array<string, mixed>>
*/
private function cityRows(): array
{
$rows = Resort::query()
->selectRaw('city')
->selectRaw('COUNT(*) as properties')
->selectRaw('COALESCE(SUM(rooms), 0) as rooms')
->selectRaw('COALESCE(AVG(comparison_tariff), 0) as avg_tariff')
->selectRaw('COALESCE(SUM(comparison_tariff), 0) as tariff_total')
->selectRaw('COALESCE(SUM(hits), 0) as hits')
->whereNotNull('city')
->groupBy('city')
->orderBy('city')
->get()
->map(fn ($row): array => [
'city' => (string) $row->city,
'properties' => (int) $row->properties,
'rooms' => (int) $row->rooms,
'avg_tariff' => number_format((float) $row->avg_tariff, 2, '.', ''),
'tariff_total' => (int) $row->tariff_total,
'hits' => (int) $row->hits,
// Feeds the custom RatingColumn: a 0–5 score off the average tariff.
'rating' => max(0, min(5, (int) round(((float) $row->avg_tariff) / 2500))),
])
->all();
$totalHits = max(1, array_sum(array_column($rows, 'hits')));
return array_map(fn (array $row): array => $row + [
'share' => round($row['hits'] * 100 / $totalHits, 2),
], $rows);
}
/**
* The main report grid. No ->query(), no ->editable() — display mode.
*/
private function cityGrid(): Grid
{
return Grid::make('city-report')
// No authorize() is required in display mode: there is no server data surface to
// gate — the host already decided what rows to hand over. (For the same reason
// ->exportable() and ->savedViews() are refused here: both need a query() grid.)
->defaultSort('city')
->columnGroups([
ColumnGroup::make('Inventory', ['properties', 'rooms']),
ColumnGroup::make('Tariff', ['avg_tariff', 'tariff_total', 'rating']),
ColumnGroup::make('Demand', ['hits', 'share']),
])
->columns([
SerialColumn::make(),
// Client-side sorting: no argument is allowed (a DB sort target would be
// meaningless here and fails loudly at build time).
TextColumn::make('city')->label('City')->sortable()->minWidth(150)->grow(),
IntegerColumn::make('properties')->label('Properties')->sortable()
->width(110)->align('right')->format('number'),
IntegerColumn::make('rooms')->label('Rooms')->sortable()
->width(100)->align('right')->format('number'),
// The app-registered 'inr' format — Indian digit grouping, PHP + JS twins.
DecimalColumn::make('avg_tariff')->label('Avg tariff')->scale(2)->sortable()
->width(130)->align('right')->format('inr', ['scale' => 2]),
IntegerColumn::make('tariff_total')->label('Tariff total')->sortable()
->width(140)->align('right')->format('inr'),
// The app-defined column type, drawn by the app-registered 'rating' painter.
RatingColumn::make('rating')->label('Tier')->sortable(),
IntegerColumn::make('hits')->label('Page views')->sortable()
->width(120)->align('right')->format('number'),
ComputedColumn::make('share')->label('Share')->html()->width(150)
->state(fn (array $row): string => sprintf(
'<span class="bar"><span class="bar-fill" style="width:%s%%"></span></span><span class="bar-num">%s%%</span>',
min(100, (float) ($row['share'] ?? 0) * 4),
number_format((float) ($row['share'] ?? 0), 2),
)),
])
->footer([
Aggregate::sum('properties')->format('number'),
Aggregate::sum('rooms')->format('number'),
Aggregate::sum('tariff_total')->format('inr'),
Aggregate::sum('hits')->format('number'),
])
// Display grids may declare url() row actions only: with no server-side row
// source there is nothing for a call() action to re-resolve and re-authorize.
->actions([
Action::make('browse')->label('Browse in the register')->icon('→')
->url(fn (): string => route('resorts.index')),
])
->stickyHeader()
->freezeColumns(2)
->striped()
->density(GridDensity::Normal)
->theme('zinc')
->statusBar()
->persistWidths()
->maxHeight('60vh')
->emptyState('No cities to report on.');
}
/**
* @return list<array<string, mixed>>
*/
private function ageingRows(): array
{
return array_map(fn (array $row): array => $row + [
'provision' => (int) round($row['amount'] * $row['weight'] / 100),
], self::AGEING);
}
/**
* The what-if grid: hand-built rows, no toolbar, a fixed height — and F9 as the point.
*/
private function ageingGrid(): Grid
{
return Grid::make('ageing')
->toolbar(false) // no search, no filters, no chooser — bare chrome
->height('260px') // a fixed box rather than a content-sized one
->columns([
SerialColumn::make(),
TextColumn::make('bucket')->label('Ageing bucket')->sortable()->minWidth(150)->grow(),
IntegerColumn::make('invoices')->label('Invoices')->sortable()
->width(100)->align('right')->format('number'),
IntegerColumn::make('amount')->label('Outstanding')->sortable()
->width(150)->align('right')->format('inr'),
IntegerColumn::make('weight')->label('Provision %')->sortable()
->width(110)->align('right'),
IntegerColumn::make('provision')->label('Provision')->sortable()
->width(140)->align('right')->format('inr'),
ComputedColumn::make('health')->label('Health')->html()->width(100)->align('center')
->state(fn (array $row): string => match (true) {
($row['weight'] ?? 0) >= 60 => CellHtml::badge('red', 'At risk'),
($row['weight'] ?? 0) >= 15 => CellHtml::badge('amber', 'Watch'),
default => CellHtml::badge('green', 'Current'),
}),
])
->footer([
Aggregate::sum('invoices')->format('number'),
Aggregate::sum('amount')->format('inr'),
Aggregate::sum('provision')->format('inr'),
])
->density(GridDensity::Comfortable)
->theme('amber')
->striped()
->statusBar()
->emptyState('Nothing outstanding.');
}
}
<?php
declare(strict_types=1);
namespace App\Grid\Columns;
use LaraGrid\Columns\Column;
/**
* An app-defined column type — the whole extension seam in one small class.
*
* A custom column names three things and nothing else:
* · painterId() — which client paint routine draws its cells (registered in JS as 'rating')
* · editorId() — which floating editor opens on it, or null for display-only
* · parseSpec() — how typed text becomes the model value, as a {kind} tag whose PHP cast
* ('stars', registered on the CastRegistry) and JS twin must agree
*
* The renderer never learns the type: it asks the registry "which painter?" and calls it.
* See App\Providers\AppServiceProvider for the PHP registrations and
* resources/views/reports/index.blade.php for their JavaScript twins.
*/
final class RatingColumn extends Column
{
protected function configureDefaults(): void
{
$this->defaultAlign('center');
if ($this->width === null) {
$this->width(110);
}
}
public function painterId(): string
{
return 'rating';
}
/** Reuses the built-in number editor — a custom type need not ship a custom editor. */
public function editorId(): ?string
{
return 'number';
}
/**
* @return array<string, mixed>
*/
public function parseSpec(): array
{
return ['kind' => 'stars'];
}
/**
* Stars are numbers, so a selection of them gets a status-bar Sum / Average like any
* other numeric column.
*/
public function isSelectableNumeric(): bool
{
return true;
}
/**
* Server rules the TYPE contributes on top of whatever the author declared.
*
* @return list<mixed>
*/
public function implicitRules(): array
{
return ['integer', 'min:0', 'max:5'];
}
}
<?php
declare(strict_types=1);
namespace App\Grid\Formatting;
use LaraGrid\Formatting\Formatter;
/**
* An app-registered display format — Indian digit grouping (12,34,56,789) with a ₹ prefix.
*
* Formatting runs in BOTH runtimes: the client for instant paint, the server for authority
* (tests, pre-computed footer totals, PDF exports). Every PHP formatter therefore needs a
* behaviourally identical JS twin registered under the same name — ours lives in
* resources/views/reports/index.blade.php. The package pins its own pairs with shared
* vectors; an app should test its own the same way.
*
* Registered as 'inr' in App\Providers\AppServiceProvider; used as
* ->format('inr', ['scale' => 2]) on any column or footer aggregate.
*/
final class InrFormatter implements Formatter
{
/**
* @param array<string, scalar> $args Supports {scale: int (default 0), symbol: bool (default true)}.
*/
public function format(mixed $value, array $args = []): string
{
if ($value === null || $value === '') {
return '';
}
$scale = max(0, (int) ($args['scale'] ?? 0));
$symbol = (bool) ($args['symbol'] ?? true);
$number = (float) $value;
$negative = $number < 0;
// Ungrouped fixed-scale text first, then regroup — so rounding happens exactly once.
[$whole, $fraction] = array_pad(explode('.', number_format(abs($number), $scale, '.', '')), 2, '');
if (strlen($whole) > 3) {
// Indian grouping: the last three digits, then pairs all the way up.
$whole = preg_replace('/\B(?=(\d{2})+(?!\d))/', ',', substr($whole, 0, -3))
.','.substr($whole, -3);
}
return ($negative ? '-' : '')
.($symbol ? '₹' : '')
.$whole
.($fraction !== '' ? '.'.$fraction : '');
}
}
<?php
declare(strict_types=1);
namespace App\Grid\Casting;
use LaraGrid\Casting\Cast;
use LaraGrid\Columns\Column;
/**
* The parse "kind" behind App\Grid\Columns\RatingColumn — turns whatever the operator typed
* into an integer 0–5.
*
* Casting, like formatting, runs in both runtimes: the client casts optimistically so the
* cell paints instantly, the server casts authoritatively so the stored value is the truth.
* The two must agree by construction, which is why every registered cast needs a JS twin
* under the same kind name (ours is in resources/views/reports/index.blade.php).
*
* Registered as 'stars' in App\Providers\AppServiceProvider.
*/
final class StarsCast implements Cast
{
/**
* @param array<string, mixed> $spec The column's full parseSpec.
*/
public function cast(mixed $value, array $spec, Column $column): mixed
{
if ($value === null || $value === '') {
return null;
}
return max(0, min(5, (int) round((float) $value)));
}
}
<?php
namespace App\Providers;
use App\Grid\Casting\StarsCast;
use App\Grid\Formatting\InrFormatter;
use Illuminate\Support\ServiceProvider;
use LaraGrid\Casting\CastRegistry;
use LaraGrid\Formatting\FormatRegistry;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// LaraGrid's PHP-side extension seams. Both registries are singletons the package
// resolves whenever it needs to render or cast a value server-side; each entry here
// has a behaviourally identical JavaScript twin registered through
// window.LaraGrid.pending (see resources/views/reports/index.blade.php).
$this->app->make(FormatRegistry::class)->register('inr', new InrFormatter);
$this->app->make(CastRegistry::class)->register('stars', new StarsCast);
}
}
{{-- The JavaScript half of this app's LaraGrid extensions — the twins of the PHP registrations
in App\Providers\AppServiceProvider. Included once from the layout, so every page can use
the 'inr' format, the 'rating' painter and the 'stars' cast.
Registration goes through the ORDER-INDEPENDENT `pending` queue rather than a direct
LaraGrid.registerX() call: with auto-injection the grid bundle is a DEFERRED script at the
end of <head>, so this inline script runs first, when window.LaraGrid does not exist yet.
The bundle merges onto whatever is already on window, drains the queue, and only then runs
its first scan — so everything seeded here wins the FIRST PAINT rather than reconciling
after it. After boot the queue is replaced by a live sink whose push() runs callbacks
immediately, which is why this same idiom is correct from any script position. --}}
<script>
(window.LaraGrid = window.LaraGrid || {}).pending = [
(LG) => {
// A custom PAINTER, for App\Grid\Columns\RatingColumn::painterId() === 'rating'.
// Built with LG.el() — the same XSS-safe element factory the built-in renderers
// use — so a custom cell never touches innerHTML.
LG.registerPainter('rating', (cellEl, ctx) => {
const stars = Math.max(0, Math.min(5, Math.round(Number(ctx.value) || 0)));
cellEl.textContent = '';
cellEl.appendChild(
LG.el('span', 'demo-rating', '★'.repeat(stars) + '☆'.repeat(5 - stars))
);
cellEl.setAttribute('aria-label', stars + ' of 5');
});
// The twin of App\Grid\Formatting\InrFormatter: same name, same output, both
// runtimes. Indian digit grouping — the last three digits, then pairs.
LG.registerFormatter('inr', (value, args) => {
args = args || {};
if (value === null || value === undefined || value === '') return '';
const scale = Math.max(0, parseInt(args.scale || 0, 10) || 0);
const symbol = args.symbol === undefined ? true : Boolean(args.symbol);
const number = Number(value);
if (!Number.isFinite(number)) return '';
const parts = Math.abs(number).toFixed(scale).split('.');
const whole = parts[0];
const fraction = parts[1] || '';
const grouped = whole.length > 3
? whole.slice(0, -3).replace(/\B(?=(\d{2})+(?!\d))/g, ',') + ',' + whole.slice(-3)
: whole;
return (number < 0 ? '-' : '') + (symbol ? '₹' : '') + grouped
+ (fraction ? '.' + fraction : '');
});
// The twin of App\Grid\Casting\StarsCast — used when a RatingColumn sits on an
// EDITABLE grid: `parse` produces the model value the client paints optimistically,
// `editText` seeds the editor when it opens.
LG.registerCast('stars', {
parse: (text) => {
if (text === null || text === undefined || String(text).trim() === '') return null;
const n = Math.round(Number(text));
return Number.isFinite(n) ? Math.max(0, Math.min(5, n)) : null;
},
editText: (value) => (value === null || value === undefined ? '' : String(value)),
});
},
];
</script>
<style>
.demo-rating { letter-spacing: .08em; color: #d97706; }
html.dark .demo-rating { color: #fbbf24; }
</style>
<x-layouts.app :wide="true">
<h1>Display-Only Grid — Client-Side Sorting, What-If Totals & Custom Cells</h1>
<p class="lede">
Both grids on this page are painted from <strong>in-memory rows</strong> on a plain
Blade page: no Livewire component, no <code>query()</code>, no <code>editable()</code> —
just <code><x-laragrid :grid :rows></code>. This is the mode for computed report
grids whose rows do not exist as table rows: trial balances, ageings, roll-ups. They
still get the full keyboard model, the selection engine, the column chooser, resizable
and persisted widths, and server-computed footer totals.
</p>
<p class="keys">
<strong>Try it:</strong>
click any header to sort — client-side, stable and type-aware, cycling
asc → desc → the original order ·
select a range and read Count / Sum / Average off the status bar ·
press <kbd>F9</kbd> on a row to <strong>temporarily hide it</strong> and watch the
footer totals recompute over what is left (the what-if view), then
<kbd>Shift</kbd>+<kbd>F9</kbd> to bring everything back ·
<kbd>Ctrl</kbd>+<kbd>C</kbd> copies the selection as TSV ·
<kbd>▦</kbd> hides and restores columns.
</p>
<h2>City roll-up <span class="muted">— a database aggregate, painted as rows</span></h2>
<x-laragrid :grid="$cityGrid" :rows="$cityRows" />
<p class="muted" style="margin-top:.6rem">
The <strong>Tier</strong> column is an application-defined column type
(<code>App\Grid\Columns\RatingColumn</code>) drawn by an application-registered painter,
and the money columns use an application-registered <code>'inr'</code> format with
Indian digit grouping. Neither the package nor the renderer knows either of them exists.
</p>
<h2>Receivables ageing <span class="muted">— hand-built rows, no toolbar, fixed height</span></h2>
<p class="lede">
Six literal rows in a PHP constant. <code>toolbar(false)</code> strips the chrome,
<code>height('260px')</code> fixes the box, and the footer still totals authoritatively.
Press <kbd>F9</kbd> on the “Over 180 days” row to see the provision total drop.
</p>
<x-laragrid :grid="$ageingGrid" :rows="$ageingRows" />
<h2>What this page demonstrates</h2>
<ul class="lede">
<li><strong>Display mode</strong> — rows passed to the tag, painted as-is, on a page
with no Livewire component at all.</li>
<li><strong>Client-side sorting</strong> — <code>sortable()</code> without a DB target,
plus <code>defaultSort()</code> applied at load.</li>
<li><strong>What-if totals</strong> — <kbd>F9</kbd> / <kbd>Shift</kbd>+<kbd>F9</kbd> row
hiding with live footer recomputation.</li>
<li><strong>The extension seams</strong> — a custom column type, a custom cell painter,
a custom display format and a custom parse kind, each with a PHP half and a
JavaScript twin registered under the same name.</li>
<li><strong>Mode-appropriate refusals</strong> — <code>exportable()</code>,
<code>savedViews()</code>, <code>call()</code> actions and bulk actions all fail
loudly at build time on a display grid, because none of them can be honoured
without a server-side row source.</li>
</ul>
<h2 id="source">The source</h2>
<x-source-code title="Reports source" :files="[
'app/Http/Controllers/ReportsController.php',
'app/Grid/Columns/RatingColumn.php',
'app/Grid/Formatting/InrFormatter.php',
'app/Grid/Casting/StarsCast.php',
'app/Providers/AppServiceProvider.php',
'resources/views/partials/laragrid-extensions.blade.php',
'resources/views/reports/index.blade.php',
]" />
@push('styles')
<style>
.bar { display: inline-block; width: 60px; height: .5rem; margin-right: .4rem; border-radius: 9999px; background: color-mix(in oklab, currentColor 15%, transparent); overflow: hidden; vertical-align: middle; }
.bar-fill { display: block; height: 100%; background: currentColor; opacity: .55; }
.bar-num { font-variant-numeric: tabular-nums; }
</style>
@endpush
</x-layouts.app>