Skip to content

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.

View full example on GitHub

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/products on 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, and DELETE endpoints
  • 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.

  1. Scaffold the backend

    Run these Artisan commands in your Laravel project root:

    Terminal window
    php artisan make:model Product --migration
    php artisan make:controller ProductController --model=Product
    php artisan make:seeder ProductSeeder

    What’s happening:

    • make:model Product --migration creates app/Models/Product.php and a timestamped migration file in database/migrations/.
    • make:controller ProductController --model=Product creates ProductController.php pre-bound to the Product model.
    • make:seeder ProductSeeder creates database/seeders/ProductSeeder.php for sample data.
  2. Define the migration

    Replace the generated migration’s up() method with the products schema:

    PHP
    <?php
    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\Schema;
    // Generated by: php artisan make:migration create_products_table
    return 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 as rowId.
    • string('sku')->unique() is a server-generated field, so the grid marks it readOnly: true.
    • decimal('price', 10, 2) stores two decimal places, matching the numeric cell type in the frontend column definition.
    • unsignedInteger('stock') enforces a non-negative integer at the database level.

    Run the migration:

    Terminal window
    php artisan migrate
  3. Create the Eloquent model

    Open app/Models/Product.php and set $fillable and $casts:

    PHP
    <?php
    namespace 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:

    • $fillable lists the columns that Product::create() and update() may write to, protecting the id from mass-assignment.
    • $casts maps price to float and stock to integer. Without this, Eloquent returns all values as strings and Handsontable’s numeric cell type receives "1299.99" instead of 1299.99.
  4. Seed the database

    Open database/seeders/ProductSeeder.php and add at least 50 rows so that pagination spans multiple pages:

    PHP
    <?php
    namespace Database\Seeders;
    use App\Models\Product;
    use Illuminate\Database\Seeder;
    // Generated by: php artisan make:seeder ProductSeeder
    // Run with: php artisan db:seed --class=ProductSeeder
    class 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 $fillable guard 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=ProductSeeder
  5. Build the ProductController

    ProductController handles all four HTTP verbs. Each method maps to one Handsontable dataProvider callback:

    PHP
    <?php
    namespace 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=Product
    class 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 conditions
    case '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 maximum
    return (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 filter

    Handsontable sends query parameters through the buildUrl() frontend helper:

    Query parameterExample valuePHP 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 conditionSQL equivalent
    containsLIKE '%value%'
    begins_withLIKE 'value%'
    gt> value
    betweenBETWEEN value AND value2
    emptyIS NULL OR = ''

    Both $prop values (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 of paginate()?

    Laravel’s paginate(n) manages its own ?page= parameter and returns a LengthAwarePaginator JSON shape. Handsontable already sends page and pageSize directly, so manual skip(($page - 1) * $pageSize)->take($pageSize) returns the { data, total } shape that fetchRows expects without any adapter code.

    store() — create rows

    When the user inserts rows from the context menu, onRowsCreate calls POST /api/products with:

    { "position": "above", "referenceRowId": 5, "rowsAmount": 1 }

    store() reads position, referenceRowId, and rowsAmount. It runs a DB::transaction() that calls resolveInsertOrder() to find the correct sort_order value, 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 cells

    After a cell edit, onRowsUpdate calls PATCH /api/products with:

    [{ "id": 4, "changes": { "price": 149.99 }, "rowData": { "..." } }]

    batchUpdate() finds each product by id and calls update() with only the changes object, so unchanged fields are not overwritten.

    batchDestroy() — delete rows

    onRowsRemove calls DELETE /api/products with a plain array of row IDs:

    [4, 7, 12]

    batchDestroy() deletes them in one whereIn()->delete() query and returns HTTP 204.

  6. Register API routes

    Open routes/api.php and add the four product routes:

    PHP
    <?php
    use 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, filter
    Route::post('/products', [ProductController::class, 'store']); // Insert new blank rows
    Route::patch('/products', [ProductController::class, 'batchUpdate']); // Batch-update changed cells
    Route::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/products path. 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
  7. Set up the Vite dev server

    Create a vite.config.js at 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.php for your production origin.
  8. Wire up Handsontable

    Start the backend and the Vite dev server with bash setup.sh (or make setup), then open http://localhost:5173. The Laravel API runs on http://localhost:8000 inside Docker; Vite proxies all /api/* requests to it. The complete frontend code is in the files below.

    JavaScript
    import Handsontable from 'handsontable/base';
    import { registerAllModules } from 'handsontable/registry';
    registerAllModules();
    // 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, { 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?.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 ?? [];
    // Single-value conditions: contains, gt, eq, begins_with …
    if (a[0] != null) params.set(`filters[${idx}][value]`, String(a[0]));
    // Range conditions: between, not_between
    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() {
    return document.querySelector('meta[name="csrf-token"]')?.content ?? '';
    }
    const container = document.querySelector('#example1');
    let removeConfirmed = false;
    // eslint-disable-next-line no-unused-vars
    const hot = new Handsontable(container, {
    dataProvider: {
    // rowId must match the primary key field in your Laravel model.
    // Handsontable uses this value in every update and remove callback.
    rowId: 'id',
    // fetchRows is called on every page change, sort, and filter.
    // queryParameters: { page, pageSize, sort, filters }
    // signal: AbortSignal — pass it to fetch() so stale requests cancel
    // when the user sorts, filters, or changes pages quickly.
    fetchRows: 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}`);
    // Laravel returns: { data: [...], total: n }
    const json = await res.json();
    return { rows: json.data, totalRows: json.total };
    },
    // onRowsCreate fires when the user inserts rows from the context menu.
    // payload: { position: 'above'|'below', referenceRowId, rowsAmount }
    onRowsCreate: async (payload) => {
    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];
    hot.getPlugin('notification').showMessage({
    variant: 'success',
    title: 'Row added',
    message: `Created: ${row.sku} (id: ${row.id})`,
    duration: 3000,
    });
    return data;
    },
    // onRowsUpdate fires after a cell edit, paste, or autofill batch.
    // rows is an array of { id, changes, rowData } objects.
    // Changes appear in the grid immediately (optimistic update) and roll back
    // if this callback throws or rejects.
    onRowsUpdate: async (rows) => {
    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}`);
    },
    // onRowsRemove fires after the user confirms deletion.
    // rowIds is an array of stable row IDs (values of the rowId field).
    onRowsRemove: async (rowIds) => {
    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}`);
    hot.getPlugin('notification').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 cannot 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, 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;
    }
    },
    // pagination: pageSize controls how many rows fetchRows requests per call.
    pagination: { pageSize: 10 },
    // columnSorting sends { prop, order } in queryParameters.sort.
    // Keep multiColumnSorting off — it conflicts with dataProvider.
    columnSorting: true,
    // filters sends condition objects in queryParameters.filters.
    // The grid resets to page 1 automatically on each filter change.
    filters: true,
    // dropdownMenu shows the column header filter button.
    dropdownMenu: true,
    // contextMenu exposes "Insert row above / below" and "Remove row".
    contextMenu: true,
    // emptyDataState shows a loading overlay while fetchRows is in flight
    // and an empty-state overlay when the result set is empty.
    emptyDataState: true,
    // notification shows an error toast when fetchRows or any CRUD callback
    // rejects. Fetch failures include a Refetch button.
    notification: true,
    rowHeaders: true,
    colHeaders: ['Name', 'SKU', 'Category', 'Price', 'Stock'],
    columns: [
    { data: 'name', type: 'text' },
    // SKU is generated server-side, so it is read-only in the grid.
    { 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' },
    ],
    width: '100%',
    height: 'auto',
    licenseKey: 'non-commercial-and-evaluation',
    });
    export default hot;

    What’s happening:

    buildUrl helper

    buildUrl serializes the queryParameters object that fetchRows receives into a URL query string that Laravel reads with request()->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.

    csrfToken helper

    Laravel requires a CSRF token on POST, PATCH, and DELETE requests. For Blade-rendered pages, inject the token via <meta name="csrf-token" content="{{ csrf_token() }}"> in your layout. For a Sanctum SPA, call GET /sanctum/csrf-cookie once on startup and send the X-XSRF-TOKEN cookie value instead.

    fetchRows

    fetchRows is called on every page change, sort, and filter. Passing signal to fetch() lets the browser cancel stale in-flight requests when the user sorts or pages quickly. Throwing on a non-ok response lets notification: true display an error toast automatically.

    onRowsCreate, onRowsUpdate, onRowsRemove

    onRowsCreate must return the array of rows created by the server (including server-assigned id values). Handsontable uses the returned rows to update its internal row map so that subsequent updates and deletes reference the correct primary keys.

    onRowsCreate and onRowsRemove call notification.showMessage() with a success variant to confirm the operation to the user.

    Cell edits via onRowsUpdate appear in the grid immediately (optimistic update). If the server returns a non-2xx response or any callback throws, Handsontable rolls back the values and fires afterRowsMutationError.

    beforeRowsMutation

    beforeRowsMutation fires before any create, update, or remove operation. Returning false cancels the operation — onRowsRemove is not called and no rows are deleted on the server.

    Because beforeRowsMutation is synchronous and checks for a strict === false return, you cannot use window.confirm() or any async dialog. Instead, use notification.showMessage() with variant: 'warning' and two action buttons. Cancel the first attempt by returning false, then on Delete re-issue the remove via hot.getPlugin('dataProvider').removeRows(rowsRemove). The removeConfirmed flag lets the second pass through without re-prompting.

    notification: true and emptyDataState: true

    notification: true enables the built-in error toast. When fetchRows or a mutation callback throws, Handsontable shows a translated error message. Fetch failures also add a Refetch action that retries the last request.

    emptyDataState: true shows a loading overlay while fetchRows is in flight and an empty-state message when the server returns zero rows.

    contextMenu: true enables the right-click context menu with “Insert row above / below” and “Remove row” items.

How It Works — Complete Flow

  1. Initial load: fetchRows fires 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.
  2. Sort: The user clicks the Price header. fetchRows fires with sort: { prop: 'price', order: 'asc' }. Laravel applies orderBy('price', 'asc') and returns the first page sorted by price.
  3. Filter: The user opens the Category filter and types “Electronics”. fetchRows fires with the filter condition. Laravel applies WHERE LOWER(category) LIKE '%electronics%' and returns the matching rows.
  4. Edit: The user changes a price cell. The new value appears immediately. onRowsUpdate fires with [{ id: 4, changes: { price: 149.99 } }]. Laravel updates the row. On success, Handsontable silently refetches the current page.
  5. Insert: The user right-clicks and selects Insert row below. onRowsCreate fires with { position: 'below', referenceRowId: 4, rowsAmount: 1 }. Laravel creates a blank row and returns it. Handsontable refetches and shows a “Row added” success notification.
  6. Delete: The user selects two rows and chooses Remove rows. beforeRowsMutation intercepts the operation, returns false, and shows a warning notification with Delete and Cancel action buttons. On Delete, onRowsRemove fires with [4, 7]. Laravel deletes both rows and a “Rows deleted” success notification appears.
  7. Error: The server returns 500. fetchRows throws. Handsontable shows an error toast with a Refetch button.

What you learned

  • How to map Handsontable’s queryParameters to Laravel request()->input() with the buildUrl() helper.
  • How to apply Handsontable filter condition names as Eloquent where() clauses.
  • Why skip()/take() is simpler than paginate() when Handsontable sends page and pageSize directly.
  • How to validate column names against an allowlist before using them in whereRaw() and orderBy() to prevent SQL injection.
  • How to send CSRF tokens for Blade-rendered pages and for SPA Sanctum apps.
  • How notification: true provides error toasts and a Refetch action with no extra code.
  • How beforeRowsMutation intercepts operations before they reach the server.

Next steps