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
- PHP 8.2+ and Composer installed
- A Laravel 11 project created (
composer create-project laravel/laravel inventory) - A configured database (SQLite works for local development)
- Node.js 22 and Handsontable installed (
npm install handsontable)
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: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: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: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: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()readsrowsAmountand creates that many blank rows. It returns HTTP 201.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: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
Configure CORS
Browsers block cross-origin requests unless the server sends the correct headers.
Open
config/cors.phpand allow your frontend origin:'allowed_origins' => ['http://localhost:5173'], // Vite dev serverWhat’s happening:
- The CORS middleware registers automatically in Laravel 11 via
bootstrap/app.php. No extra configuration is needed beyond theallowed_originslist. - In production replace
'http://localhost:5173'with the exact frontend origin. Using['*']is acceptable during local development but exposes your API to any origin.
- The CORS middleware registers automatically in Laravel 11 via
Wire up Handsontable
With the server running (
php artisan serve), configure Handsontable to use thedataProviderplugin. The complete frontend code is in the files below.TypeScript /* file: app.component.ts */import { Component, ViewChild } from '@angular/core';import { GridSettings, HotTableComponent, HotTableModule } from '@handsontable/angular-wrapper';import type { DataProviderQueryParameters, DataProviderFetchOptions, RowsCreatePayload, RowUpdatePayload } from 'handsontable/plugins/dataProvider';import type { SourceRowData } from 'handsontable/common';function buildUrl(base: string, params: DataProviderQueryParameters): string {const query = new URLSearchParams({page: String(params.page),pageSize: String(params.pageSize),});if (params.sort) {query.set('sort[prop]', params.sort.prop);query.set('sort[order]', params.sort.order);}if (params.filters?.length) {params.filters.forEach(({ prop, conditions }, i) => {const cond = conditions[0];query.set(`filters[${i}][prop]`, prop);if (cond?.name) {query.set(`filters[${i}][condition]`, cond.name);}if (cond?.args[0] != null) {query.set(`filters[${i}][value]`, String(cond.args[0]));}if (cond?.args[1] != null) {query.set(`filters[${i}][value2]`, String(cond.args[1]));}});}return `${base}?${query}`;}function csrfToken(): string {return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';}@Component({standalone: true,imports: [HotTableModule],selector: 'example1-server-side-laravel',template: `<div><hot-table [settings]="gridSettings"></hot-table></div>`,})export class AppComponent {@ViewChild(HotTableComponent, { static: false }) readonly hotTable!: HotTableComponent;readonly gridSettings: GridSettings = {dataProvider: {rowId: 'id',fetchRows: (params: DataProviderQueryParameters, options: DataProviderFetchOptions) =>this.fetchRows(params, options.signal),onRowsCreate: (payload: RowsCreatePayload) => this.onRowsCreate(payload),onRowsUpdate: (rows: RowUpdatePayload[]) => this.onRowsUpdate(rows),onRowsRemove: (rowIds: unknown[]) => this.onRowsRemove(rowIds),},pagination: { pageSize: 10 },columnSorting: true,filters: true,dropdownMenu: true,contextMenu: true,emptyDataState: true,notification: true,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' },],};async fetchRows(params: DataProviderQueryParameters, signal: AbortSignal): Promise<{ rows: SourceRowData[]; totalRows: number }> {const url = buildUrl('/api/products', params);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 };}async onRowsCreate(payload: RowsCreatePayload): Promise<void> {await fetch('/api/products', {method: 'POST',headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken() },body: JSON.stringify(payload),});}async onRowsUpdate(rows: RowUpdatePayload[]): Promise<void> {await fetch('/api/products', {method: 'PATCH',headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken() },body: JSON.stringify(rows),});}async onRowsRemove(rowIds: unknown[]): Promise<void> {await fetch('/api/products', {method: 'DELETE',headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken() },body: JSON.stringify(rowIds),});}}/* 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 */HTML <div><example1-server-side-laravel></example1-server-side-laravel></div>What’s happening:
buildUrlhelperfunction buildUrl(base, { page, pageSize, sort, filters }) {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) {filters.forEach((filter, i) => {params.set(`filters[${i}][prop]`, filter.prop);params.set(`filters[${i}][condition]`, filter.condition.name);const args = filter.condition.args ?? [];if (args[0] != null) params.set(`filters[${i}][value]`, String(args[0]));if (args[1] != null) params.set(`filters[${i}][value2]`, String(args[1]));});}return `${base}?${params}`;}buildUrlserializes thequeryParametersobject thatfetchRowsreceives into a URL query string that Laravel reads withrequest()->input(). It converts the Handsontable filter condition shape —{ prop, condition: { name, args } }— into the flat bracket-notation parameters Laravel parses automatically.csrfTokenhelperfunction csrfToken() {return document.querySelector('meta[name="csrf-token"]')?.content ?? '';}Laravel 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.fetchRowsfetchRows: async ({ page, pageSize, sort, filters }, { signal }) => {const url = buildUrl('/api/products', { page, pageSize, sort, filters });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 };},fetchRowsis 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,onRowsRemoveonRowsCreate: async (payload) => {// payload: { position: 'above'|'below', referenceRowId, rowsAmount }await fetch('/api/products', {method: 'POST',headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken() },body: JSON.stringify(payload),});},onRowsUpdate: async (rows) => {// rows: [{ id, changes: { price: 149.99 }, rowData: {...} }, ...]await fetch('/api/products', {method: 'PATCH',headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken() },body: JSON.stringify(rows),});},onRowsRemove: async (rowIds) => {// rowIds: [4, 7, 12]await fetch('/api/products', {method: 'DELETE',headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken() },body: JSON.stringify(rowIds),});},Cell edits appear in the grid immediately (optimistic update). If the server returns a non-2xx response or the callback throws, Handsontable rolls back the values and fires
afterRowsMutationError.beforeRowsMutationlet removeConfirmed = false;// ...beforeRowsMutation(operation, payload) {if (operation === 'remove' && !removeConfirmed) {const count = payload.rowsRemove.length;const notification = hot.getPlugin('notification');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);removeConfirmed = true;hot.getPlugin('dataProvider').removeRows(payload.rowsRemove).finally(() => {removeConfirmed = false;});},},{label: 'Cancel',type: 'secondary',callback: () => notification.hide(id),},],});return false;}},beforeRowsMutationfires 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 other async dialogs. Instead, cancel the first attempt by returningfalse, show a notification with Delete and Cancel actions, and on Delete re-issue the remove via the DataProvider API. TheremoveConfirmedflag lets the second pass through without re-prompting.notification: trueandemptyDataState: truenotification: true,emptyDataState: true,notification: 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.
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 Handsontable refetches. - Delete: The user selects two rows and chooses Remove rows.
beforeRowsMutationshows a confirm dialog. On confirmation,onRowsRemovefires with[4, 7]. Laravel deletes both rows. - 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