Server-side data with Laravel
This tutorial shows how to connect Handsontable’s dataProvider plugin to a Laravel backend. You will build a product inventory grid that loads data from a REST API with server-side pagination, sorting, and filtering, and that persists row create, update, and delete operations to a Laravel database.
Difficulty: Intermediate
Time: ~30 minutes
Stack: Laravel 11 (PHP 8.2+), Eloquent ORM, Handsontable dataProvider
What You’ll Build
A product inventory data grid that:
- Fetches paginated rows from
GET /api/productson every page change - Sorts and filters rows on the server — the browser never loads the full dataset
- Creates, updates, and deletes rows via
POST,PATCH, andDELETEendpoints - Sends CSRF tokens for Blade-rendered pages or uses Sanctum for SPA auth
- Shows a loading overlay while data loads and an error toast when a request fails
Before you begin
- Docker and Docker Compose installed
- Node.js 18 or later and npm 9 or later installed
No local PHP or Composer installation is required — the Laravel backend and MySQL 8 database run inside Docker.
Scaffold the backend
Run these Artisan commands in your Laravel project root:
Terminal window php artisan make:model Product --migrationphp artisan make:controller ProductController --model=Productphp artisan make:seeder ProductSeederWhat’s happening:
make:model Product --migrationcreatesapp/Models/Product.phpand a timestamped migration file indatabase/migrations/.make:controller ProductController --model=ProductcreatesProductController.phppre-bound to theProductmodel.make:seeder ProductSeedercreatesdatabase/seeders/ProductSeeder.phpfor sample data.
Define the migration
Replace the generated migration’s
up()method with the products schema:PHP <?phpuse Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema;// Generated by: php artisan make:migration create_products_tablereturn new class extends Migration{public function up(): void{Schema::create('products', function (Blueprint $table) {$table->id(); // Auto-increment primary key$table->string('name'); // Product name (filterable via LIKE)$table->string('sku')->unique(); // Stock-keeping unit (read-only in UI)$table->string('category'); // Dropdown value (filterable via =)$table->decimal('price', 10, 2); // Numeric — supports gt/lt/between$table->unsignedInteger('stock'); // Numeric — supports gt/lt/between$table->unsignedInteger('sort_order')->default(0); // Preserves insertion order when no sort is applied$table->timestamps();});}public function down(): void{Schema::dropIfExists('products');}};What’s happening:
id()creates an auto-increment primary key. This is the value Handsontable uses asrowId.string('sku')->unique()is a server-generated field, so the grid marks itreadOnly: true.decimal('price', 10, 2)stores two decimal places, matching thenumericcell type in the frontend column definition.unsignedInteger('stock')enforces a non-negative integer at the database level.
Run the migration:
Terminal window php artisan migrateCreate the Eloquent model
Open
app/Models/Product.phpand set$fillableand$casts:PHP <?phpnamespace App\Models;use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;class Product extends Model{use HasFactory;// Mass-assignable columns. 'id' is intentionally excluded (auto-increment).protected $fillable = ['name', 'sku', 'category', 'price', 'stock', 'sort_order'];// Cast numeric columns so Laravel returns real numbers, not strings.protected $casts = ['price' => 'float','stock' => 'integer',];}What’s happening:
$fillablelists the columns thatProduct::create()andupdate()may write to, protecting theidfrom mass-assignment.$castsmapspricetofloatandstocktointeger. Without this, Eloquent returns all values as strings and Handsontable’s numeric cell type receives"1299.99"instead of1299.99.
Seed the database
Open
database/seeders/ProductSeeder.phpand add at least 50 rows so that pagination spans multiple pages:PHP <?phpnamespace Database\Seeders;use App\Models\Product;use Illuminate\Database\Seeder;// Generated by: php artisan make:seeder ProductSeeder// Run with: php artisan db:seed --class=ProductSeederclass ProductSeeder extends Seeder{public function run(): void{$categories = ['Electronics', 'Accessories', 'Storage', 'Networking', 'Peripherals'];$products = [['name' => 'Laptop Pro 15', 'sku' => 'LAP-001', 'category' => 'Electronics', 'price' => 1299.99, 'stock' => 42],['name' => 'Wireless Mouse', 'sku' => 'MOU-002', 'category' => 'Accessories', 'price' => 29.99, 'stock' => 315],['name' => 'USB-C Hub 7-in-1', 'sku' => 'HUB-003', 'category' => 'Accessories', 'price' => 49.99, 'stock' => 178],['name' => 'NVMe SSD 1 TB', 'sku' => 'SSD-004', 'category' => 'Storage', 'price' => 109.99, 'stock' => 260],['name' => 'Mechanical Keyboard', 'sku' => 'KEY-005', 'category' => 'Peripherals', 'price' => 139.99, 'stock' => 95],['name' => 'Gigabit Switch 8-Port', 'sku' => 'SW8-006', 'category' => 'Networking', 'price' => 59.99, 'stock' => 74],['name' => 'Portable Monitor 15"', 'sku' => 'MON-007', 'category' => 'Electronics', 'price' => 249.99, 'stock' => 38],['name' => 'Webcam 4K', 'sku' => 'CAM-008', 'category' => 'Peripherals', 'price' => 89.99, 'stock' => 112],['name' => 'External HDD 2 TB', 'sku' => 'HDD-009', 'category' => 'Storage', 'price' => 74.99, 'stock' => 193],['name' => 'Wi-Fi 6 Router', 'sku' => 'RTR-010', 'category' => 'Networking', 'price' => 179.99, 'stock' => 55],['name' => 'Noise-Cancelling Headphones','sku' => 'HP-011', 'category' => 'Accessories', 'price' => 199.99, 'stock' => 67],['name' => 'Thunderbolt Dock', 'sku' => 'DOC-012', 'category' => 'Accessories', 'price' => 219.99, 'stock' => 31],['name' => 'Laptop Stand', 'sku' => 'STD-013', 'category' => 'Accessories', 'price' => 39.99, 'stock' => 220],['name' => 'Gaming Controller', 'sku' => 'CTR-014', 'category' => 'Peripherals', 'price' => 69.99, 'stock' => 148],['name' => 'HDMI Cable 2 m', 'sku' => 'CBL-015', 'category' => 'Accessories', 'price' => 12.99, 'stock' => 500],['name' => 'MicroSD 256 GB', 'sku' => 'MSD-016', 'category' => 'Storage', 'price' => 29.99, 'stock' => 387],['name' => 'PoE Switch 24-Port', 'sku' => 'POE-017', 'category' => 'Networking', 'price' => 299.99, 'stock' => 18],['name' => 'Smart Plug 4-Pack', 'sku' => 'PLG-018', 'category' => 'Electronics', 'price' => 34.99, 'stock' => 142],['name' => 'USB Microphone', 'sku' => 'MIC-019', 'category' => 'Peripherals', 'price' => 79.99, 'stock' => 89],['name' => 'Ergonomic Chair Mat', 'sku' => 'MAT-020', 'category' => 'Accessories', 'price' => 44.99, 'stock' => 61],['name' => 'Tablet 10" Wi-Fi', 'sku' => 'TAB-021', 'category' => 'Electronics', 'price' => 349.99, 'stock' => 27],['name' => 'Power Bank 20000 mAh', 'sku' => 'PBK-022', 'category' => 'Accessories', 'price' => 49.99, 'stock' => 203],['name' => 'OLED Monitor 27"', 'sku' => 'MON-023', 'category' => 'Electronics', 'price' => 699.99, 'stock' => 14],['name' => 'USB-A 4-Port Hub', 'sku' => 'HUB-024', 'category' => 'Accessories', 'price' => 19.99, 'stock' => 341],['name' => 'SSD Enclosure', 'sku' => 'ENC-025', 'category' => 'Storage', 'price' => 24.99, 'stock' => 115],['name' => 'Network Cable Cat 6 10 m', 'sku' => 'CBL-026', 'category' => 'Networking', 'price' => 9.99, 'stock' => 620],['name' => 'Drawing Tablet', 'sku' => 'TAB-027', 'category' => 'Peripherals', 'price' => 189.99, 'stock' => 43],['name' => 'Smart Switch', 'sku' => 'SW2-028', 'category' => 'Electronics', 'price' => 44.99, 'stock' => 78],['name' => 'Desk Lamp LED', 'sku' => 'LMP-029', 'category' => 'Accessories', 'price' => 34.99, 'stock' => 99],['name' => 'RAID Controller', 'sku' => 'RAD-030', 'category' => 'Storage', 'price' => 149.99, 'stock' => 22],['name' => 'VPN Router', 'sku' => 'VPN-031', 'category' => 'Networking', 'price' => 219.99, 'stock' => 30],['name' => 'Foot Pedal USB', 'sku' => 'FTP-032', 'category' => 'Peripherals', 'price' => 59.99, 'stock' => 47],['name' => 'Mini PC', 'sku' => 'MPC-033', 'category' => 'Electronics', 'price' => 449.99, 'stock' => 19],['name' => 'Cable Management Kit', 'sku' => 'KIT-034', 'category' => 'Accessories', 'price' => 14.99, 'stock' => 265],['name' => 'M.2 SSD 500 GB', 'sku' => 'SSD-035', 'category' => 'Storage', 'price' => 59.99, 'stock' => 183],['name' => 'Managed Switch 48-Port', 'sku' => 'MSW-036', 'category' => 'Networking', 'price' => 599.99, 'stock' => 8],['name' => 'RGB LED Strip', 'sku' => 'LED-037', 'category' => 'Electronics', 'price' => 24.99, 'stock' => 320],['name' => 'Monitor Arm Single', 'sku' => 'ARM-038', 'category' => 'Accessories', 'price' => 54.99, 'stock' => 73],['name' => 'Barcode Scanner USB', 'sku' => 'SCN-039', 'category' => 'Peripherals', 'price' => 119.99, 'stock' => 56],['name' => 'NAS Enclosure 4-Bay', 'sku' => 'NAS-040', 'category' => 'Storage', 'price' => 329.99, 'stock' => 11],['name' => 'Fiber Converter', 'sku' => 'FBC-041', 'category' => 'Networking', 'price' => 89.99, 'stock' => 25],['name' => 'Wireless Presenter', 'sku' => 'PRE-042', 'category' => 'Peripherals', 'price' => 39.99, 'stock' => 67],['name' => 'UPS 1500 VA', 'sku' => 'UPS-043', 'category' => 'Electronics', 'price' => 259.99, 'stock' => 16],['name' => 'Zip Ties 100-Pack', 'sku' => 'ZTP-044', 'category' => 'Accessories', 'price' => 7.99, 'stock' => 950],['name' => 'HDD 4 TB Desktop', 'sku' => 'HDD-045', 'category' => 'Storage', 'price' => 89.99, 'stock' => 94],['name' => 'Patch Panel 24-Port', 'sku' => 'PNL-046', 'category' => 'Networking', 'price' => 49.99, 'stock' => 35],['name' => 'Numeric Keypad', 'sku' => 'KPD-047', 'category' => 'Peripherals', 'price' => 29.99, 'stock' => 130],['name' => 'Server Rack 12U', 'sku' => 'RCK-048', 'category' => 'Networking', 'price' => 349.99, 'stock' => 7],['name' => 'Thermal Paste 5 g', 'sku' => 'TPT-049', 'category' => 'Accessories', 'price' => 8.99, 'stock' => 480],['name' => 'Flash Drive 128 GB', 'sku' => 'USB-050', 'category' => 'Storage', 'price' => 17.99, 'stock' => 740],['name' => 'HDMI Splitter 4-Port', 'sku' => 'SPL-051', 'category' => 'Electronics', 'price' => 29.99, 'stock' => 88],['name' => 'Smart Card Reader', 'sku' => 'SCR-052', 'category' => 'Peripherals', 'price' => 19.99, 'stock' => 210],];foreach ($products as $i => $data) {Product::create(array_merge($data, ['sort_order' => $i + 1]));}}}What’s happening:
Product::create($data)inserts each row through Eloquent so the$fillableguard and timestamps apply.- The 52 rows create six pages at the default
pageSize: 10, making pagination controls visible from the first load.
Run the seeder:
Terminal window php artisan db:seed --class=ProductSeederBuild the ProductController
ProductControllerhandles all four HTTP verbs. Each method maps to one HandsontabledataProvidercallback:PHP <?phpnamespace App\Http\Controllers;use App\Models\Product;use Illuminate\Http\JsonResponse;use Illuminate\Http\Request;use Illuminate\Support\Facades\DB;// Generated by: php artisan make:controller ProductController --model=Productclass ProductController extends Controller{private const ALLOWED_COLUMNS = ['name', 'sku', 'category', 'price', 'stock'];// GET /api/products// Query string — what Handsontable sends via buildUrl():// page, pageSize, sort[prop], sort[order],// filters[0][prop], filters[0][condition], filters[0][value], filters[0][value2]public function index(Request $request): JsonResponse{$page = (int) $request->input('page', 1);$pageSize = (int) $request->input('pageSize', 10);$sort = $request->input('sort'); // array ['prop' => ..., 'order' => ...]$filters = $request->input('filters'); // array of filter objects$query = Product::query();// --- Filtering ---------------------------------------------------------// Handsontable condition names map directly to SQL clauses.// Add more cases here as you enable additional condition types in the UI.if (is_array($filters)) {foreach ($filters as $filter) {$prop = $filter['prop'] ?? null;$condition = $filter['condition'] ?? null;$value = $filter['value'] ?? null;$value2 = $filter['value2'] ?? null;// Validate $prop against an allowlist of filterable columns before// interpolating it into any raw SQL to prevent SQL injection.if (!$prop || !$condition || !in_array($prop, self::ALLOWED_COLUMNS, true)) {continue;}// Text conditions — use LOWER() for case-insensitive matching.// escapeLike() escapes LIKE metacharacters in user input.switch ($condition) {case 'contains':$safe = $this->escapeLike(strtolower((string)$value));$query->whereRaw("LOWER({$prop}) LIKE ?", ['%' . $safe . '%']);break;case 'not_contains':$safe = $this->escapeLike(strtolower((string)$value));$query->whereRaw("LOWER({$prop}) NOT LIKE ?", ['%' . $safe . '%']);break;case 'begins_with':$safe = $this->escapeLike(strtolower((string)$value));$query->whereRaw("LOWER({$prop}) LIKE ?", [$safe . '%']);break;case 'ends_with':$safe = $this->escapeLike(strtolower((string)$value));$query->whereRaw("LOWER({$prop}) LIKE ?", ['%' . $safe]);break;// Numeric / exact conditionscase 'eq': $query->where($prop, '=', $value); break;case 'neq': $query->where($prop, '!=', $value); break;case 'gt': $query->where($prop, '>', $value); break;case 'gte': $query->where($prop, '>=', $value); break;case 'lt': $query->where($prop, '<', $value); break;case 'lte': $query->where($prop, '<=', $value); break;case 'between':$query->whereBetween($prop, [$value, $value2]);break;case 'not_between':$query->whereNotBetween($prop, [$value, $value2]);break;case 'empty':$isString = in_array($prop, ['name', 'sku', 'category'], true);$query->where(function ($q) use ($prop, $isString) {$q->whereNull($prop);if ($isString) {$q->orWhere($prop, '');}});break;case 'not_empty':$isString = in_array($prop, ['name', 'sku', 'category'], true);$query->where(function ($q) use ($prop, $isString) {$q->whereNotNull($prop);if ($isString) {$q->where($prop, '!=', '');}});break;}}}// --- Sorting -----------------------------------------------------------// Handsontable sends sort[prop] and sort[order] ('asc'|'desc').// Use manual skip/take rather than Laravel's paginate() because// Handsontable sends 1-based page + pageSize directly.if (is_array($sort) && isset($sort['prop'], $sort['order'])&& in_array($sort['prop'], self::ALLOWED_COLUMNS, true)) {$direction = in_array(strtolower($sort['order']), ['asc', 'desc'])? strtolower($sort['order']): 'asc';$query->orderBy($sort['prop'], $direction);} else {// Default: preserve insertion order$query->orderBy('sort_order');}// --- Pagination --------------------------------------------------------// Count filtered rows first so Handsontable knows the total row count.$total = $query->count();$data = $query->skip(($page - 1) * $pageSize)->take($pageSize)->get();// Return the shape that fetchRows expects: { data: [...], total: n }return response()->json(['data' => $data, 'total' => $total]);}// POST /api/products// Body (JSON): { position, referenceRowId, rowsAmount }// Matches the onRowsCreate payload shape. Returns the created rows so the// frontend can show a success notification with the generated SKU.public function store(Request $request): JsonResponse{$rowsAmount = max(1, (int) $request->input('rowsAmount', 1));$position = $request->input('position', 'below');$referenceRowId = $request->input('referenceRowId');$created = [];DB::transaction(function () use ($rowsAmount, $position, $referenceRowId, &$created) {$insertAt = $this->resolveInsertOrder($referenceRowId, $position, $rowsAmount);for ($i = 0; $i < $rowsAmount; $i++) {$created[] = Product::create(['name' => '','sku' => 'NEW-' . strtoupper(bin2hex(random_bytes(3))),'category' => 'Electronics','price' => 0,'stock' => 0,'sort_order' => $insertAt + $i,]);}});return response()->json($created, 201);}// PATCH /api/products// Body (JSON): [{ id, changes: { name?, price?, ... }, rowData? }, ...]// Matches the onRowsUpdate payload shape.public function batchUpdate(Request $request): JsonResponse{$rows = $request->json()->all();foreach ($rows as $row) {$product = Product::find($row['id'] ?? null);if ($product) {// Only update the changed columns — never overwrite the primary key or sort order.$changes = $row['changes'] ?? [];unset($changes['id'], $changes['sort_order']);$product->update($changes);}}return response()->json(null, 200);}// DELETE /api/products// Body (JSON): [1, 4, 7] — array of product IDs// Matches the onRowsRemove payload shape.public function batchDestroy(Request $request): JsonResponse{$ids = $request->json()->all();Product::whereIn('id', $ids)->delete();return response()->json(null, 204);}// Determines the sort_order for the new row(s) and shifts existing rows to make room.private function resolveInsertOrder(?int $referenceRowId, string $position, int $rowsAmount): int{if ($referenceRowId !== null) {$ref = Product::find($referenceRowId);if ($ref) {$insertAt = $position === 'above'? $ref->sort_order: $ref->sort_order + 1;Product::where('sort_order', '>=', $insertAt)->increment('sort_order', $rowsAmount);return $insertAt;}}// No reference row — append after the current maximumreturn (int) (Product::max('sort_order') ?? 0) + 1;}// Escapes LIKE metacharacters so literal % and _ in user input don't act as// wildcards. MySQL's default escape char is \, so we prefix \, %, and _ with \.private function escapeLike(string $value): string{return addcslashes($value, '\\%_');}}What’s happening:
index()— paginate, sort, and filterHandsontable sends query parameters through the
buildUrl()frontend helper:Query parameter Example value PHP access page,pageSize1,10$request->input('page')sort[prop],sort[order]'name','asc'$request->input('sort')filters[0][prop],filters[0][condition],filters[0][value]'price','gt','100'$request->input('filters')The filter loop maps Handsontable condition names to SQL clauses. Text conditions use
LOWER()for case-insensitive matching:Handsontable condition SQL equivalent containsLIKE '%value%'begins_withLIKE 'value%'gt> valuebetweenBETWEEN value AND value2emptyIS NULL OR = ''Both
$propvalues (for filters and for sort) are validated against an allowlist of column names before being used in any query, preventing SQL injection through unsanitized user input.Why
skip()/take()instead ofpaginate()?Laravel’s
paginate(n)manages its own?page=parameter and returns aLengthAwarePaginatorJSON shape. Handsontable already sendspageandpageSizedirectly, so manualskip(($page - 1) * $pageSize)->take($pageSize)returns the{ data, total }shape thatfetchRowsexpects without any adapter code.store()— create rowsWhen the user inserts rows from the context menu,
onRowsCreatecallsPOST /api/productswith:{ "position": "above", "referenceRowId": 5, "rowsAmount": 1 }store()readsposition,referenceRowId, androwsAmount. It runs aDB::transaction()that callsresolveInsertOrder()to find the correctsort_ordervalue, shifts existing rows to make room, and inserts the new blank rows. It returns the created rows as JSON with HTTP 201 so the frontend can show a success notification with the generated SKU.batchUpdate()— update changed cellsAfter a cell edit,
onRowsUpdatecallsPATCH /api/productswith:[{ "id": 4, "changes": { "price": 149.99 }, "rowData": { "..." } }]batchUpdate()finds each product byidand callsupdate()with only thechangesobject, so unchanged fields are not overwritten.batchDestroy()— delete rowsonRowsRemovecallsDELETE /api/productswith a plain array of row IDs:[4, 7, 12]batchDestroy()deletes them in onewhereIn()->delete()query and returns HTTP 204.Register API routes
Open
routes/api.phpand add the four product routes:PHP <?phpuse App\Http\Controllers\ProductController;use Illuminate\Support\Facades\Route;// Add these routes to routes/api.php.// All four verbs share the same base URL. Laravel matches them by HTTP method.Route::get('/products', [ProductController::class, 'index']); // Paginate, sort, filterRoute::post('/products', [ProductController::class, 'store']); // Insert new blank rowsRoute::patch('/products', [ProductController::class, 'batchUpdate']); // Batch-update changed cellsRoute::delete('/products', [ProductController::class, 'batchDestroy']); // Delete rows by ID// If you use Sanctum for SPA authentication, protect these routes by wrapping// them in the 'auth:sanctum' middleware://// Route::middleware('auth:sanctum')->group(function () {// Route::get('/products', [ProductController::class, 'index']);// Route::post('/products', [ProductController::class, 'store']);// Route::patch('/products', [ProductController::class, 'batchUpdate']);// Route::delete('/products', [ProductController::class, 'batchDestroy']);// });What’s happening:
- All four routes share the same
/api/productspath. Laravel matches them by HTTP method. - The Sanctum middleware group is shown commented out. Uncomment it when you add authentication to your API.
Verify the routes are registered:
Terminal window php artisan route:list --path=api/products- All four routes share the same
Set up the Vite dev server
Create a
vite.config.jsat the root of your frontend project and configure a proxy so requests go to Vite (:5173) and are forwarded to Laravel without triggering CORS:import { defineConfig } from 'vite';export default defineConfig({server: {port: 5173,proxy: {'/api': {target: 'http://localhost:8000',changeOrigin: true,},},},});What’s happening:
- The Vite dev server forwards every
/api/*request to the Laravel server running on port 8000. Because both the HTML page and the API requests share the same origin (localhost:5173), the browser never sees a cross-origin request — no CORS headers are needed on the backend. - In production, deploy the frontend and backend behind the same reverse proxy (nginx or Apache), or configure CORS headers in
config/cors.phpfor your production origin.
- The Vite dev server forwards every
Wire up Handsontable
Start the backend and the Vite dev server with
bash setup.sh(ormake setup), then openhttp://localhost:5173. The Laravel API runs onhttp://localhost:8000inside Docker; Vite proxies all/api/*requests to it. The complete frontend code is in the files below.TypeScript /* file: app.component.ts */import {Component,ViewChild,ViewEncapsulation,CUSTOM_ELEMENTS_SCHEMA,} from '@angular/core';import { HotTableModule, HotTableComponent } from '@handsontable/angular-wrapper';import type {DataProviderQueryParameters,RowsCreatePayload,RowUpdatePayload,RowMutationPayload,RowMutationRemovePayload,} from 'handsontable/plugins/dataProvider';// Serializes fetchRows query parameters into a URL query string that Laravel// reads via request()->input().//// Handsontable sends:// sort: { prop: 'name', order: 'asc' } or null// filters: [{ prop: 'price', conditions: [{ name: 'gt', args: [100] }] }] or null//// Laravel reads:// sort[prop], sort[order]// filters[0][prop], filters[0][condition], filters[0][value], filters[0][value2]function buildUrl(base: string, { page, pageSize, sort, filters }: DataProviderQueryParameters): string {const params = new URLSearchParams({page: String(page),pageSize: String(pageSize),});if (sort) {params.set('sort[prop]', sort.prop);params.set('sort[order]', sort.order);}if (filters?.length) {let idx = 0;filters.forEach(({ prop, conditions }) => {(conditions || []).forEach(({ name, args }) => {if (!name) return;params.set(`filters[${idx}][prop]`, prop);params.set(`filters[${idx}][condition]`, name);const a = args ?? [];if (a[0] != null) params.set(`filters[${idx}][value]`, String(a[0]));if (a[1] != null) params.set(`filters[${idx}][value2]`, String(a[1]));idx++;});});}return `${base}?${params}`;}// For Blade-rendered pages, inject via <meta name="csrf-token" content="{{ csrf_token() }}">.// For this standalone SPA the API routes are stateless so the header is a no-op,// but it's kept here so the code mirrors the recipe exactly.function csrfToken(): string {return (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '';}@Component({selector: 'example1-server-side-laravel',standalone: true,encapsulation: ViewEncapsulation.None,imports: [HotTableModule],schemas: [CUSTOM_ELEMENTS_SCHEMA],template: `<hot-table [settings]="settings"></hot-table>`,})export class AppComponent {@ViewChild(HotTableComponent) hotRef!: HotTableComponent;private removeConfirmed = false;settings = {dataProvider: {// rowId must match the primary key returned by the Laravel model.rowId: 'id',// Called on every page change, sort, and filter.fetchRows: async (queryParameters: DataProviderQueryParameters, { signal }: { signal: AbortSignal }) => {const url = buildUrl('/api/products', queryParameters);const res = await fetch(url, { signal });if (!res.ok) throw new Error(`HTTP ${res.status}`);const json = await res.json();return { rows: json.data, totalRows: json.total };},// Fires when the user inserts rows via the context menu.// payload: { position: 'above'|'below', referenceRowId, rowsAmount }onRowsCreate: async (payload: RowsCreatePayload) => {const res = await fetch('/api/products', {method: 'POST',headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken() },body: JSON.stringify(payload),});if (!res.ok) throw new Error(`HTTP ${res.status}`);const data = await res.json();const row = data[0];// eslint-disable-next-line @typescript-eslint/no-explicit-any(this.hotRef.hotInstance!.getPlugin('notification') as any).showMessage({variant: 'success',title: 'Row added',message: `Created: ${row.sku} (id: ${row.id})`,duration: 3000,});return data;},// Fires after a cell edit, paste, or autofill batch.// rows: [{ id, changes: { price: 149.99 }, rowData: {...} }, ...]onRowsUpdate: async (rows: RowUpdatePayload[]) => {const res = await fetch('/api/products', {method: 'PATCH',headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken() },body: JSON.stringify(rows),});if (!res.ok) throw new Error(`HTTP ${res.status}`);},// Fires after the user confirms deletion.// rowIds: [4, 7, 12]onRowsRemove: async (rowIds: unknown[]) => {const res = await fetch('/api/products', {method: 'DELETE',headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken() },body: JSON.stringify(rowIds),});if (!res.ok) throw new Error(`HTTP ${res.status}`);// eslint-disable-next-line @typescript-eslint/no-explicit-any(this.hotRef.hotInstance!.getPlugin('notification') as any).showMessage({variant: 'success',title: 'Rows deleted',message: `Deleted ${rowIds.length} row${rowIds.length !== 1 ? 's' : ''}`,duration: 3000,});},},// beforeRowsMutation is sync (checks for a strict `=== false` return), so// we can't await an async prompt inline. Instead: cancel the original// attempt, show a notification with Delete/Cancel actions, and on Delete// re-issue the remove via the DataProvider API. The flag lets the second// pass through without re-prompting.beforeRowsMutation: (operation: 'create' | 'update' | 'remove', payload: RowMutationPayload): false | void => {if (operation === 'remove' && !this.removeConfirmed) {const { rowsRemove } = payload as RowMutationRemovePayload;const hot = this.hotRef.hotInstance!;const count = rowsRemove.length;// eslint-disable-next-line @typescript-eslint/no-explicit-anyconst notification = (hot.getPlugin('notification') as any);const id = notification.showMessage({variant: 'warning',title: 'Delete rows',message: `Delete ${count} row${count !== 1 ? 's' : ''}? This cannot be undone.`,duration: 0,actions: [{label: 'Delete',type: 'primary',callback: () => {notification.hide(id);this.removeConfirmed = true;// eslint-disable-next-line @typescript-eslint/no-explicit-any(hot.getPlugin('dataProvider') as any).removeRows(rowsRemove).finally(() => {this.removeConfirmed = false;});},},{label: 'Cancel',type: 'secondary',callback: () => notification.hide(id),},],});return false;}},pagination: { pageSize: 10 },columnSorting: true,filters: true,dropdownMenu: true,contextMenu: true,emptyDataState: true,notification: true,width: '100%',height: 'auto',rowHeaders: true,colHeaders: ['Name', 'SKU', 'Category', 'Price', 'Stock'],columns: [{ data: 'name', type: 'text' },{ data: 'sku', type: 'text', readOnly: true },{data: 'category',type: 'dropdown',source: ['Electronics', 'Accessories', 'Storage', 'Networking', 'Peripherals'],},{ data: 'price', type: 'numeric', numericFormat: { pattern: '$0,0.00' } },{ data: 'stock', type: 'numeric' },],licenseKey: 'non-commercial-and-evaluation',};}/* end-file *//* file: app.config.ts */import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';import { registerAllModules } from 'handsontable/registry';import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper';registerAllModules();export const appConfig: ApplicationConfig = {providers: [provideZoneChangeDetection({ eventCoalescing: true }),{provide: HOT_GLOBAL_CONFIG,useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig,},],};/* end-file */What’s happening:
buildUrlhelperbuildUrlserializes thequeryParametersobject thatfetchRowsreceives into a URL query string that Laravel reads withrequest()->input(). It converts the Handsontable filter shape —{ prop, conditions: [{ name, args }] }(each filter can carry multiple conditions) — into the flat bracket-notation parameters Laravel parses automatically.csrfTokenhelperLaravel requires a CSRF token on
POST,PATCH, andDELETErequests. For Blade-rendered pages, inject the token via<meta name="csrf-token" content="{{ csrf_token() }}">in your layout. For a Sanctum SPA, callGET /sanctum/csrf-cookieonce on startup and send theX-XSRF-TOKENcookie value instead.fetchRowsfetchRowsis called on every page change, sort, and filter. Passingsignaltofetch()lets the browser cancel stale in-flight requests when the user sorts or pages quickly. Throwing on a non-ok response letsnotification: truedisplay an error toast automatically.onRowsCreate,onRowsUpdate,onRowsRemoveonRowsCreatemust return the array of rows created by the server (including server-assignedidvalues). Handsontable uses the returned rows to update its internal row map so that subsequent updates and deletes reference the correct primary keys.onRowsCreateandonRowsRemovecallnotification.showMessage()with asuccessvariant to confirm the operation to the user.Cell edits via
onRowsUpdateappear in the grid immediately (optimistic update). If the server returns a non-2xx response or any callback throws, Handsontable rolls back the values and firesafterRowsMutationError.beforeRowsMutationbeforeRowsMutationfires before any create, update, or remove operation. Returningfalsecancels the operation —onRowsRemoveis not called and no rows are deleted on the server.Because
beforeRowsMutationis synchronous and checks for a strict=== falsereturn, you cannot usewindow.confirm()or any async dialog. Instead, usenotification.showMessage()withvariant: 'warning'and two action buttons. Cancel the first attempt by returningfalse, then on Delete re-issue the remove viahot.getPlugin('dataProvider').removeRows(rowsRemove). TheremoveConfirmedflag lets the second pass through without re-prompting.notification: trueandemptyDataState: truenotification: trueenables the built-in error toast. WhenfetchRowsor a mutation callback throws, Handsontable shows a translated error message. Fetch failures also add a Refetch action that retries the last request.emptyDataState: trueshows a loading overlay whilefetchRowsis in flight and an empty-state message when the server returns zero rows.contextMenu: trueenables the right-click context menu with “Insert row above / below” and “Remove row” items.
How It Works — Complete Flow
- Initial load:
fetchRowsfires with{ page: 1, pageSize: 10, sort: null, filters: null }. Laravel returns{ data: [...10 rows...], total: 52 }. The grid renders the first page with a pagination bar. - Sort: The user clicks the Price header.
fetchRowsfires withsort: { prop: 'price', order: 'asc' }. Laravel appliesorderBy('price', 'asc')and returns the first page sorted by price. - Filter: The user opens the Category filter and types “Electronics”.
fetchRowsfires with the filter condition. Laravel appliesWHERE LOWER(category) LIKE '%electronics%'and returns the matching rows. - Edit: The user changes a price cell. The new value appears immediately.
onRowsUpdatefires with[{ id: 4, changes: { price: 149.99 } }]. Laravel updates the row. On success, Handsontable silently refetches the current page. - Insert: The user right-clicks and selects Insert row below.
onRowsCreatefires with{ position: 'below', referenceRowId: 4, rowsAmount: 1 }. Laravel creates a blank row and returns it. Handsontable refetches and shows a “Row added” success notification. - Delete: The user selects two rows and chooses Remove rows.
beforeRowsMutationintercepts the operation, returnsfalse, and shows a warning notification with Delete and Cancel action buttons. On Delete,onRowsRemovefires with[4, 7]. Laravel deletes both rows and a “Rows deleted” success notification appears. - Error: The server returns 500.
fetchRowsthrows. Handsontable shows an error toast with a Refetch button.
What you learned
- How to map Handsontable’s
queryParametersto Laravelrequest()->input()with thebuildUrl()helper. - How to apply Handsontable filter condition names as Eloquent
where()clauses. - Why
skip()/take()is simpler thanpaginate()when Handsontable sendspageandpageSizedirectly. - How to validate column names against an allowlist before using them in
whereRaw()andorderBy()to prevent SQL injection. - How to send CSRF tokens for Blade-rendered pages and for SPA Sanctum apps.
- How
notification: trueprovides error toasts and a Refetch action with no extra code. - How
beforeRowsMutationintercepts operations before they reach the server.
Next steps
- Server-side data overview — DataProvider plugin reference
- Configuration and query parameters — all
fetchRowsquery fields - Server-side CRUD — mutation lifecycle and hooks
- Fetching, hooks, and examples — error handling and loading UI
- Server-side data with Spring Boot — the same Handsontable frontend wired to a Java backend
- Server-side data with Symfony — the same Handsontable frontend wired to a Symfony backend