Server-side data with Symfony
This tutorial shows how to connect Handsontable’s dataProvider plugin to a Symfony backend. You will build a product inventory grid that loads data with server-side pagination, sorting, and filtering, and that persists row create, update, and delete operations to a Symfony/Doctrine database. The recipe covers two API styles: a REST API (Steps 1–8) and an optional GraphQL variant using webonyx/graphql-php (Step 9).
Difficulty: Intermediate
Time: ~30 minutes
Stack: Symfony 7 (PHP 8.2+), Doctrine 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 - Uses a stateless JSON API with no CSRF tokens required
- 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 Symfony 7 project created (
composer create-project symfony/skeleton inventory) - Doctrine ORM and Doctrine Migrations installed:
Terminal window composer require doctrine/orm doctrine/doctrine-bundle doctrine/doctrine-migrations-bundle - A configured database (MySQL or PostgreSQL; set
DATABASE_URLin.env) - Node.js 22 and Handsontable installed (
npm install handsontable)
Scaffold the backend
Install the required Symfony packages:
Terminal window composer require symfony/framework-bundle symfony/routingCreate the entity, repository, controller, and seed command:
Terminal window php bin/console make:entity Productphp bin/console make:controller ProductControllerWhat’s happening:
make:entity Productgeneratessrc/Entity/Product.phpandsrc/Repository/ProductRepository.php.make:controller ProductControllergeneratessrc/Controller/ProductController.php.- Routes are declared as PHP attributes directly on the controller class and methods — there is no separate routes file.
Define the Doctrine migration
Generate a migration from the entity mapping:
Terminal window php bin/console doctrine:migrations:generateReplace the generated
up()body with the products schema:PHP <?phpdeclare(strict_types=1);namespace DoctrineMigrations;use Doctrine\DBAL\Schema\Schema;use Doctrine\Migrations\AbstractMigration;// Generated by: php bin/console doctrine:migrations:generate// Run with: php bin/console doctrine:migrations:migratefinal class Version20240101000000 extends AbstractMigration{public function getDescription(): string{return 'Create products table';}public function up(Schema $schema): void{$this->addSql(<<<SQLCREATE TABLE products (id INT AUTO_INCREMENT NOT NULL,name VARCHAR(255) NOT NULL,sku VARCHAR(255) NOT NULL,category VARCHAR(255) NOT NULL,price DECIMAL(10, 2) NOT NULL,stock INT NOT NULL,sort_order INT UNSIGNED NOT NULL DEFAULT 0,INDEX IDX_PRODUCTS_SORT_ORDER (sort_order),UNIQUE INDEX UNIQ_PRODUCTS_SKU (sku),PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDBSQL);}public function down(Schema $schema): void{$this->addSql('DROP TABLE products');}}What’s happening:
id INT AUTO_INCREMENTis the primary key. This is the value Handsontable uses asrowId.sku VARCHAR UNIQUEis generated server-side, so the grid marks itreadOnly: true.price DECIMAL(10, 2)stores two decimal places, matching thenumericcell type in the frontend column definition.sort_order INT UNSIGNEDtracks display order independently of the primary key so that row insertions at arbitrary positions work correctly.
Run the migration:
Terminal window php bin/console doctrine:migrations:migrateCreate the Product entity
Open
src/Entity/Product.phpand define the Doctrine mapping with typed properties:PHP <?phpnamespace App\Entity;use App\Repository\ProductRepository;use Doctrine\ORM\Mapping as ORM;#[ORM\Entity(repositoryClass: ProductRepository::class)]#[ORM\Table(name: 'products')]class Product{#[ORM\Id]#[ORM\GeneratedValue]#[ORM\Column]private ?int $id = null;#[ORM\Column(length: 255)]private string $name = '';// SKU is generated server-side, so it is read-only in the grid.#[ORM\Column(length: 255, unique: true)]private string $sku = '';#[ORM\Column(length: 255)]private string $category = '';// Stored as decimal string by Doctrine; cast to float in the controller response.#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]private string $price = '0.00';#[ORM\Column]private int $stock = 0;// Tracks display order independently of the primary key so that// row insertions at arbitrary positions work correctly.#[ORM\Column(options: ['unsigned' => true, 'default' => 0])]private int $sortOrder = 0;public function getId(): ?int { return $this->id; }public function getName(): string { return $this->name; }public function setName(string $name): self { $this->name = $name; return $this; }public function getSku(): string { return $this->sku; }public function setSku(string $sku): self { $this->sku = $sku; return $this; }public function getCategory(): string { return $this->category; }public function setCategory(string $category): self { $this->category = $category; return $this; }public function getPrice(): string { return $this->price; }public function setPrice(mixed $price): self { $this->price = (string) $price; return $this; }public function getStock(): int { return $this->stock; }public function setStock(mixed $stock): self { $this->stock = (int) $stock; return $this; }public function getSortOrder(): int { return $this->sortOrder; }public function setSortOrder(int $sortOrder): self { $this->sortOrder = $sortOrder; return $this; }}What’s happening:
- Each column is declared as a PHP 8 typed property with a Doctrine
#[ORM\Column]attribute. There is no$fillablearray — Doctrine tracks persistence via the entity manager, not mass-assignment. priceis mapped asdecimaland stored as a PHP string by Doctrine to preserve precision. The controller explicitly casts it tofloatwhen serializing the response, so Handsontable’s numeric cell type receives1299.99instead of"1299.99".sortOrderis an extra column that keeps the display order stable when rows are inserted at arbitrary positions using the context menu.
- Each column is declared as a PHP 8 typed property with a Doctrine
Create the ProductRepository
The
ProductRepositoryhandles all query logic, keeping the controller thin. Opensrc/Repository/ProductRepository.php:PHP <?phpnamespace App\Repository;use App\Entity\Product;use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;use Doctrine\ORM\QueryBuilder;use Doctrine\Persistence\ManagerRegistry;class ProductRepository extends ServiceEntityRepository{// Columns that may appear in filter and sort parameters.// Validated before use in any query to prevent SQL injection.private const ALLOWED_COLUMNS = ['name', 'sku', 'category', 'price', 'stock'];public function __construct(ManagerRegistry $registry){parent::__construct($registry, Product::class);}// Returns paginated products + total count for the given filters and sort.// Called by ProductController::index().public function findPage(int $page, int $pageSize, array $sort, array $filters): array{$qb = $this->buildFilteredQuery($filters);// Clone the builder before adding ORDER BY / LIMIT so the COUNT query// counts all filtered rows, not just the current page.$total = (int) (clone $qb)->select('COUNT(p.id)')->getQuery()->getSingleScalarResult();if (!empty($sort['prop']) && in_array($sort['prop'], self::ALLOWED_COLUMNS, true)) {$direction = in_array(strtolower($sort['order'] ?? ''), ['asc', 'desc'])? strtolower($sort['order']): 'asc';$qb->orderBy("p.{$sort['prop']}", $direction);} else {// Default: preserve insertion order via sort_order column.$qb->orderBy('p.sortOrder', 'asc');}$products = $qb->setFirstResult(($page - 1) * $pageSize)->setMaxResults($pageSize)->getQuery()->getResult();return ['products' => $products, 'total' => $total];}/** @return Product[] */public function createBlankRows(int $count, string $position = 'below', ?int $referenceRowId = null): array{$em = $this->getEntityManager();$created = [];$em->wrapInTransaction(function () use ($em, $count, $position, $referenceRowId, &$created) {$insertAt = $this->resolveInsertOrder($referenceRowId, $position, $count);for ($i = 0; $i < $count; $i++) {$product = (new Product())->setName('')->setSku('NEW-' . strtoupper(bin2hex(random_bytes(3))))->setCategory('Electronics')->setPrice(0)->setStock(0)->setSortOrder($insertAt + $i);$em->persist($product);$created[] = $product;}$em->flush();});return $created;}// Determines the sortOrder for the new row(s) and shifts existing rows to make room.private function resolveInsertOrder(?int $referenceRowId, string $position, int $count): int{if ($referenceRowId !== null) {$ref = $this->find($referenceRowId);if ($ref) {$insertAt = $position === 'above'? $ref->getSortOrder(): $ref->getSortOrder() + 1;// Shift rows at or after insertAt to make room for the new rows.$this->createQueryBuilder('p')->update()->set('p.sortOrder', 'p.sortOrder + :count')->where('p.sortOrder >= :insertAt')->setParameter('count', $count)->setParameter('insertAt', $insertAt)->getQuery()->execute();return $insertAt;}}// No reference row — append after the current maximum.$max = $this->createQueryBuilder('p')->select('MAX(p.sortOrder)')->getQuery()->getSingleScalarResult();return (int) ($max ?? 0) + 1;}// Body: [{ id, changes: { name?, price?, ... } }, ...]public function updateRows(array $rows): void{$em = $this->getEntityManager();foreach ($rows as $row) {$product = $this->find($row['id'] ?? null);if (!$product) {continue;}$changes = $row['changes'] ?? [];unset($changes['id']);foreach ($changes as $field => $value) {if (!in_array($field, self::ALLOWED_COLUMNS, true)) {continue;}$setter = 'set' . ucfirst($field);if (method_exists($product, $setter)) {$product->$setter($value);}}}$em->flush();}// Body: [1, 4, 7]public function deleteByIds(array $ids): void{if (empty($ids)) {return;}$this->createQueryBuilder('p')->delete()->where('p.id IN (:ids)')->setParameter('ids', $ids)->getQuery()->execute();}private function buildFilteredQuery(array $filters): QueryBuilder{$qb = $this->createQueryBuilder('p');$i = 0;foreach ($filters as $filter) {$prop = $filter['prop'] ?? null;$condition = $filter['condition'] ?? null;$value = $filter['value'] ?? null;$value2 = $filter['value2'] ?? null;// Validate $prop before interpolating it into DQL to prevent injection.if (!$prop || !$condition || !in_array($prop, self::ALLOWED_COLUMNS, true)) {continue;}$p = "v{$i}";$p2 = "v2{$i}";$i++;switch ($condition) {// Text conditions — use LOWER() for case-insensitive matching.case 'contains':$qb->andWhere("LOWER(p.{$prop}) LIKE :{$p}")->setParameter($p, '%' . $this->escapeLike(strtolower((string) $value)) . '%');break;case 'not_contains':$qb->andWhere("LOWER(p.{$prop}) NOT LIKE :{$p}")->setParameter($p, '%' . $this->escapeLike(strtolower((string) $value)) . '%');break;case 'begins_with':$qb->andWhere("LOWER(p.{$prop}) LIKE :{$p}")->setParameter($p, $this->escapeLike(strtolower((string) $value)) . '%');break;case 'ends_with':$qb->andWhere("LOWER(p.{$prop}) LIKE :{$p}")->setParameter($p, '%' . $this->escapeLike(strtolower((string) $value)));break;// Numeric conditionscase 'eq': $qb->andWhere("p.{$prop} = :{$p}")->setParameter($p, $value); break;case 'neq': $qb->andWhere("p.{$prop} != :{$p}")->setParameter($p, $value); break;case 'gt': $qb->andWhere("p.{$prop} > :{$p}")->setParameter($p, $value); break;case 'gte': $qb->andWhere("p.{$prop} >= :{$p}")->setParameter($p, $value); break;case 'lt': $qb->andWhere("p.{$prop} < :{$p}")->setParameter($p, $value); break;case 'lte': $qb->andWhere("p.{$prop} <= :{$p}")->setParameter($p, $value); break;case 'between':$qb->andWhere("p.{$prop} BETWEEN :{$p} AND :{$p2}")->setParameter($p, $value)->setParameter($p2, $value2);break;case 'not_between':// DQL has no NOT BETWEEN — express as two comparisons.$qb->andWhere("p.{$prop} < :{$p} OR p.{$prop} > :{$p2}")->setParameter($p, $value)->setParameter($p2, $value2);break;case 'empty':$isString = in_array($prop, ['name', 'sku', 'category'], true);$qb->andWhere($isString? "p.{$prop} IS NULL OR p.{$prop} = ''": "p.{$prop} IS NULL");break;case 'not_empty':$isString = in_array($prop, ['name', 'sku', 'category'], true);$qb->andWhere($isString? "p.{$prop} IS NOT NULL AND p.{$prop} != ''": "p.{$prop} IS NOT NULL");break;}}return $qb;}// Escape 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:
findPage()— paginate, sort, and filterfindPage()builds a DoctrineQueryBuilderand applies filters, sorting, and pagination in sequence.Handsontable sends query parameters through the
buildUrl()frontend helper:Query parameter Example value PHP access page,pageSize1,10$request->query->get('page')sort[prop],sort[order]'name','asc'$request->query->all('sort')filters[0][prop],filters[0][condition],filters[0][value]'price','gt','100'$request->query->all('filters')The filter loop in
buildFilteredQuery()maps Handsontable condition names to DQL clauses. Text conditions useLOWER()for case-insensitive matching:Handsontable condition DQL equivalent containsLOWER(p.prop) LIKE '%value%'begins_withLOWER(p.prop) LIKE 'value%'gtp.prop > :valuebetweenp.prop BETWEEN :v AND :v2not_betweenp.prop < :v OR p.prop > :v2emptyp.prop IS NULL OR p.prop = ''Both
$propvalues (for filters and for sort) are validated againstALLOWED_COLUMNSbefore being interpolated into DQL expressions, preventing injection through unsanitized user input.Why
setFirstResult()/setMaxResults()instead of a paginator helper?Handsontable already sends
pageandpageSizedirectly, so manual offset/limit (setFirstResult(($page - 1) * $pageSize)->setMaxResults($pageSize)) returns the{ data, total }shape thatfetchRowsexpects without any adapter code.createBlankRows()— insert rows at positionWhen the user inserts rows from the context menu,
onRowsCreatesends:{ "position": "above", "referenceRowId": 5, "rowsAmount": 1 }createBlankRows()callsresolveInsertOrder()to find the correctsortOrdervalue for the new row. It then shifts all existing rows at or after that position by$countto make room, and inserts the new rows. The entire operation runs insidewrapInTransaction().updateRows()— update changed cellsAfter a cell edit,
onRowsUpdatecallsPATCH /api/productswith:[{ "id": 4, "changes": { "price": 149.99 }, "rowData": { "..." } }]updateRows()finds each product byidand calls the appropriate setter for each key inchanges, so unchanged fields are not overwritten.deleteByIds()— delete rowsonRowsRemovecallsDELETE /api/productswith a plain array of row IDs:[4, 7, 12]deleteByIds()deletes them in oneDELETE ... WHERE id IN (...)DQL query.Seed the database
Create a Symfony Console command to populate the products table:
PHP <?phpnamespace App\Command;use App\Entity\Product;use Doctrine\ORM\EntityManagerInterface;use Symfony\Component\Console\Attribute\AsCommand;use Symfony\Component\Console\Command\Command;use Symfony\Component\Console\Input\InputInterface;use Symfony\Component\Console\Output\OutputInterface;// Run with: php bin/console app:seed-products#[AsCommand(name: 'app:seed-products', description: 'Seed the products table with sample data')]class SeedProductsCommand extends Command{public function __construct(private readonly EntityManagerInterface $em){parent::__construct();}protected function execute(InputInterface $input, OutputInterface $output): int{// Skip if already seeded — safe to re-run.if ($this->em->getRepository(Product::class)->count([]) > 0) {$output->writeln('Products already seeded, skipping.');return Command::SUCCESS;}$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 = (new Product())->setName($data['name'])->setSku($data['sku'])->setCategory($data['category'])->setPrice($data['price'])->setStock($data['stock'])->setSortOrder($i + 1);$this->em->persist($product);}$this->em->flush();$output->writeln(count($products) . ' products seeded successfully.');return Command::SUCCESS;}}What’s happening:
- The command is registered automatically via the
#[AsCommand]attribute. - It checks
count([]) > 0before inserting, so it is safe to re-run on container restarts. - 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 bin/console app:seed-products- The command is registered automatically via the
Build the ProductController
ProductControllerhandles all four HTTP verbs. Routes are declared as PHP attributes on the class and each method:PHP <?phpnamespace App\Controller;use App\Entity\Product;use App\Repository\ProductRepository;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\Routing\Attribute\Route;// Routes are declared as PHP attributes directly on the controller.// All four HTTP verbs share the base /api/products path.#[Route('/api/products')]class ProductController extends AbstractController{public function __construct(private readonly ProductRepository $products) {}// 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]#[Route('', methods: ['GET'])]public function index(Request $request): JsonResponse{$page = max(1, (int) $request->query->get('page', 1));$pageSize = max(1, (int) $request->query->get('pageSize', 10));$sort = $request->query->all('sort'); // ['prop' => ..., 'order' => ...]$filters = $request->query->all('filters'); // array of filter objects['products' => $products, 'total' => $total] =$this->products->findPage($page, $pageSize, $sort, $filters);// Map entities to plain arrays. Cast price to float so Handsontable's// numeric cell type receives 1299.99 instead of the Doctrine string "1299.99".$data = array_map(fn(Product $p) => ['id' => $p->getId(),'name' => $p->getName(),'sku' => $p->getSku(),'category' => $p->getCategory(),'price' => (float) $p->getPrice(),'stock' => $p->getStock(),'sort_order' => $p->getSortOrder(),], $products);// Return the shape that fetchRows expects: { data: [...], total: n }return $this->json(['data' => $data, 'total' => $total]);}// POST /api/products// Body (JSON): { position, referenceRowId, rowsAmount }// Matches the onRowsCreate payload shape.#[Route('', methods: ['POST'])]public function store(Request $request): JsonResponse{$payload = json_decode($request->getContent(), true) ?? [];$rowsAmount = max(1, (int) ($payload['rowsAmount'] ?? 1));$position = $payload['position'] ?? 'below';$referenceRowId = isset($payload['referenceRowId']) ? (int) $payload['referenceRowId'] : null;$created = $this->products->createBlankRows($rowsAmount, $position, $referenceRowId);$data = array_map(fn(Product $p) => ['id' => $p->getId(),'name' => $p->getName(),'sku' => $p->getSku(),'category' => $p->getCategory(),'price' => (float) $p->getPrice(),'stock' => $p->getStock(),'sort_order' => $p->getSortOrder(),], $created);return $this->json($data, 201);}// PATCH /api/products// Body (JSON): [{ id, changes: { name?, price?, ... }, rowData? }, ...]// Matches the onRowsUpdate payload shape.#[Route('', methods: ['PATCH'])]public function batchUpdate(Request $request): JsonResponse{$rows = json_decode($request->getContent(), true) ?? [];$this->products->updateRows($rows);return $this->json(null, 200);}// DELETE /api/products// Body (JSON): [1, 4, 7] — array of product IDs// Matches the onRowsRemove payload shape.#[Route('', methods: ['DELETE'])]public function batchDestroy(Request $request): JsonResponse{$ids = json_decode($request->getContent(), true) ?? [];$this->products->deleteByIds($ids);return $this->json(null, 204);}}What’s happening:
#[Route('/api/products')]on the class sets the shared base path. Method-level#[Route('', methods: ['GET'])]attributes add the HTTP method constraint.index()delegates all query logic toProductRepository::findPage()and maps the returned entities to plain arrays. The(float)cast on$p->getPrice()converts Doctrine’s decimal string to a PHP float so Handsontable’s numeric cell type receives a number, not a string.store()delegates tocreateBlankRows(), maps the returned entities to plain arrays (includingsort_order), and returns the created rows as JSON with HTTP 201. The frontend reads this response to display a success notification and manually insert the row into the current page.batchUpdate()delegates toupdateRows()and returns HTTP 200.batchDestroy()delegates todeleteByIds()and returns HTTP 204.
Verify the routes are registered:
Terminal window php bin/console debug:router | grep productsSet 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 Symfony without triggering CORS:import { defineConfig } from 'vite';export default defineConfig({server: {port: 5173,proxy: {'/api': 'http://localhost:8000',},},});What’s happening:
- The Vite dev server forwards every
/api/*request to the Symfony 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 on the Symfony server for your production origin.
- No CSRF token is needed for this stateless JSON API. Symfony’s CSRF protection applies to form-based flows, not
Content-Type: application/jsonrequests from a browser frontend.
- The Vite dev server forwards every
Wire up Handsontable
With the Symfony server running (
symfony server:startorphp -S localhost:8000 -t public/) and the Vite dev server running (npm run dev), openhttp://localhost:5173to see the grid. 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 Symfony// reads via $request->query->all().//// Handsontable sends:// sort: { prop: 'name', order: 'asc' } or null// filters: [{ prop: 'price', conditions: [{ name: 'gt', args: [100] }] }] or null//// Symfony 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_betweenif (a[1] != null) params.set(`filters[${idx}][value2]`, String(a[1]));idx++;});});}return `${base}?${params}`;}const container = document.querySelector('#example1');let removeConfirmed = false;let totalRows = 0;// eslint-disable-next-line no-unused-varsconst hot = new Handsontable(container, {dataProvider: {// rowId must match the primary key field returned by the Symfony controller.// 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}`);// Symfony returns: { data: [...], total: n }const json = await res.json();totalRows = json.total;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' },body: JSON.stringify(payload),});if (!res.ok) throw new Error(`HTTP ${res.status}`);// The server returns the newly created rows. Instead of triggering a full// refetch, we manually insert the row into the current page and update the// pagination counter. Throwing 'stop refetch' prevents the default refetch.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,});const pageSize = hot.getSettings().pagination?.pageSize ?? 10;const rows = hot.getSourceData();const index = rows.findIndex(r => r.id === payload.referenceRowId);const insertAt = index >= 0 ? index + (payload.position === 'above' ? 0 : 1) : rows.length;rows.splice(insertAt, 0, row);hot.loadData(rows.slice(0, pageSize));totalRows += 1;hot.runHooks('afterDataProviderFetch', { queryParameters: {}, totalRows });throw new Error('stop refetch');},// 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' },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' },body: JSON.stringify(rowIds),});if (!res.ok) throw new Error(`HTTP ${res.status}`);},},// 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,width: '100%',height: 'auto',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' },],licenseKey: 'non-commercial-and-evaluation',});document.getElementById('btn-filter-empty').addEventListener('click', () => {const filters = hot.getPlugin('filters');filters.clearConditions();filters.addCondition(2, 'eq', ['Electronics']);filters.filter();});document.getElementById('btn-clear-filters').addEventListener('click', () => {const filters = hot.getPlugin('filters');filters.clearConditions();filters.filter();});export default hot;What’s happening:
buildUrlhelperbuildUrlserializes thequeryParametersobject thatfetchRowsreceives into a URL query string that Symfony reads with$request->query->all(). It converts the Handsontable filter shape —{ prop, conditions: [{ name, args }] }(each filter can carry multiple conditions) — into the flat bracket-notation parameters Symfony parses automatically.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.totalRows = json.totalsaves the current server-side total soonRowsCreatecan increment it without a refetch.onRowsCreate,onRowsUpdate,onRowsRemoveNo CSRF token header is required. Symfony’s built-in CSRF protection targets HTML form submissions, not JSON API requests.
onRowsCreateuses a Symfony-specific pattern instead of a simple return. After the server responds with the new row, the callback: shows a success notification, splices the row into the current page data at the correct position, incrementstotalRows, and firesafterDataProviderFetchto update the pagination counter. It then throwsnew Error('stop refetch'), which signals Handsontable to skip the automatic refetch that would normally follow a create. This avoids a second round-trip because thesortOrdercolumn ensures the inserted row always appears at a predictable offset on the current page — a full refetch is unnecessary.Cell edits via
onRowsUpdateappear in the grid immediately (optimistic update). If the server returns a non-2xx response or a callback throws for a reason other than'stop refetch', 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 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: 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.Alternative — Use a GraphQL API
Instead of the four REST endpoints you can expose the same logic through a single
/graphqlendpoint usingwebonyx/graphql-php.Install the package:
Terminal window composer require webonyx/graphql-php9a. Define the schema
Create
src/GraphQL/ProductSchema.php. This file defines every type, query, and mutation the frontend can call:PHP <?phpnamespace App\GraphQL;use App\Entity\Product;use App\Repository\ProductRepository;use GraphQL\Type\Definition\InputObjectType;use GraphQL\Type\Definition\ObjectType;use GraphQL\Type\Definition\Type;use GraphQL\Type\Schema;class ProductSchema{public static function build(ProductRepository $repository): Schema{// --- Output types ---$productType = new ObjectType(['name' => 'Product','fields' => ['id' => Type::nonNull(Type::int()),'name' => Type::nonNull(Type::string()),'sku' => Type::nonNull(Type::string()),'category' => Type::nonNull(Type::string()),'price' => Type::nonNull(Type::float()),'stock' => Type::nonNull(Type::int()),'sort_order' => Type::nonNull(Type::int()),],]);// Wraps the paginated list so fetchRows can read { data, total }.$productsPageType = new ObjectType(['name' => 'ProductsPage','fields' => ['data' => Type::nonNull(Type::listOf(Type::nonNull($productType))),'total' => Type::nonNull(Type::int()),],]);// --- Input types ---// Matches queryParameters.sort from fetchRows: { prop, order }$sortInput = new InputObjectType(['name' => 'SortInput','fields' => ['prop' => Type::nonNull(Type::string()),'order' => Type::nonNull(Type::string()),],]);// Flat representation of one Handsontable condition after mapFilters():// { prop, condition, value?, value2? }$filterInput = new InputObjectType(['name' => 'FilterInput','fields' => ['prop' => Type::nonNull(Type::string()),'condition' => Type::nonNull(Type::string()),'value' => Type::string(),'value2' => Type::string(),],]);$productChangesInput = new InputObjectType(['name' => 'ProductChangesInput','fields' => ['name' => Type::string(),'sku' => Type::string(),'category' => Type::string(),'price' => Type::float(),'stock' => Type::int(),],]);// Matches one entry in the onRowsUpdate payload: { id, changes }$productUpdateInput = new InputObjectType(['name' => 'ProductUpdateInput','fields' => ['id' => Type::nonNull(Type::int()),'changes' => Type::nonNull($productChangesInput),],]);// --- Query ---$queryType = new ObjectType(['name' => 'Query','fields' => [// Replaces GET /api/products — paginate, sort, filter.'products' => ['type' => Type::nonNull($productsPageType),'args' => ['page' => ['type' => Type::int(), 'defaultValue' => 1],'pageSize' => ['type' => Type::int(), 'defaultValue' => 10],'sort' => ['type' => $sortInput],'filters' => ['type' => Type::listOf(Type::nonNull($filterInput))],],'resolve' => function ($root, array $args) use ($repository): array {$page = max(1, $args['page'] ?? 1);$pageSize = max(1, $args['pageSize'] ?? 10);$sort = $args['sort'] ?? [];$filters = $args['filters'] ?? [];['products' => $products, 'total' => $total] =$repository->findPage($page, $pageSize, $sort, $filters);return ['data' => array_map(fn(Product $p) => ['id' => $p->getId(),'name' => $p->getName(),'sku' => $p->getSku(),'category' => $p->getCategory(),'price' => (float) $p->getPrice(),'stock' => $p->getStock(),'sort_order' => $p->getSortOrder(),], $products),'total' => $total,];},],],]);// --- Mutations ---$mutationType = new ObjectType(['name' => 'Mutation','fields' => [// Replaces POST /api/products — insert blank rows at position.'createProducts' => ['type' => Type::nonNull(Type::listOf(Type::nonNull($productType))),'args' => ['rowsAmount' => ['type' => Type::int(), 'defaultValue' => 1],'position' => ['type' => Type::string(), 'defaultValue' => 'below'],'referenceRowId' => ['type' => Type::int()],],'resolve' => function ($root, array $args) use ($repository): array {$created = $repository->createBlankRows(max(1, $args['rowsAmount'] ?? 1),$args['position'] ?? 'below',$args['referenceRowId'] ?? null,);return array_map(fn(Product $p) => ['id' => $p->getId(),'name' => $p->getName(),'sku' => $p->getSku(),'category' => $p->getCategory(),'price' => (float) $p->getPrice(),'stock' => $p->getStock(),'sort_order' => $p->getSortOrder(),], $created);},],// Replaces PATCH /api/products — update changed cells.'updateProducts' => ['type' => Type::nonNull(Type::boolean()),'args' => ['rows' => Type::nonNull(Type::listOf(Type::nonNull($productUpdateInput))),],'resolve' => function ($root, array $args) use ($repository): bool {$repository->updateRows($args['rows']);return true;},],// Replaces DELETE /api/products — delete rows by ID.'deleteProducts' => ['type' => Type::nonNull(Type::boolean()),'args' => ['ids' => Type::nonNull(Type::listOf(Type::nonNull(Type::int()))),],'resolve' => function ($root, array $args) use ($repository): bool {$repository->deleteByIds($args['ids']);return true;},],],]);return new Schema(['query' => $queryType, 'mutation' => $mutationType]);}}What’s happening:
$productType— the shape of a single product row, returned by bothproducts.data[]andcreateProducts[].$sortInput/$filterInput/$productUpdateInput— input types that mirror the shapes Handsontable passes tofetchRows,onRowsCreate, andonRowsUpdate.Query.products— replacesGET /api/products. Acceptspage,pageSize,sort, andfiltersand delegates toProductRepository::findPage().Mutation.createProducts— replacesPOST /api/products. Returns the newly created rows so the frontend can show a success notification.Mutation.updateProducts/Mutation.deleteProducts— replacePATCHandDELETE. Both return a boolean; actual data comes from a subsequentfetchRowscall.
9b. Add the GraphQL controller
Create
src/Controller/GraphQLController.php:PHP <?phpnamespace App\Controller;use App\GraphQL\ProductSchema;use App\Repository\ProductRepository;use GraphQL\Error\DebugFlag;use GraphQL\GraphQL;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\Routing\Attribute\Route;// A single route handles every GraphQL request — query and mutation alike.#[Route('/graphql', methods: ['POST'])]class GraphQLController extends AbstractController{public function __construct(private readonly ProductRepository $products) {}public function __invoke(Request $request): JsonResponse{$body = json_decode($request->getContent(), true) ?? [];$query = $body['query'] ?? '';$variables = $body['variables'] ?? null;$schema = ProductSchema::build($this->products);$result = GraphQL::executeQuery($schema, $query, null, null, $variables);// Include stack traces in debug mode only.$debug = $this->getParameter('kernel.debug')? DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE: DebugFlag::NONE;return $this->json($result->toArray($debug));}}What’s happening:
The controller handles every GraphQL request in one place. It decodes the request body, builds the schema from
ProductSchema::build(), executes the query or mutation viaGraphQL::executeQuery(), and returns the result as JSON. Stack traces are included in the response only whenkernel.debugistrue.9c. Wire up the frontend
The frontend replaces the per-endpoint
fetch()calls with a singlegql()helper and adds amapFilters()function to convert Handsontable’sconditionsarray into the flatFilterInputshape the schema expects. TheHandsontableconfiguration — columns, plugins,beforeRowsMutation— is otherwise identical to the REST example.JavaScript import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';registerAllModules();// Sends a GraphQL request to POST /graphql and returns response.data.// Throws on HTTP errors and on GraphQL-level errors so notification: true// can display an error toast automatically.async function gql(query, variables = {}) {const res = await fetch('/graphql', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ query, variables }),});if (!res.ok) throw new Error(`HTTP ${res.status}`);const json = await res.json();if (json.errors?.length) throw new Error(json.errors[0].message);return json.data;}// --- GraphQL operations ---const FETCH_PRODUCTS = `query FetchProducts($page: Int, $pageSize: Int, $sort: SortInput, $filters: [FilterInput!]) {products(page: $page, pageSize: $pageSize, sort: $sort, filters: $filters) {data { id name sku category price stock sort_order }total}}`;const CREATE_PRODUCTS = `mutation CreateProducts($rowsAmount: Int, $position: String, $referenceRowId: Int) {createProducts(rowsAmount: $rowsAmount, position: $position, referenceRowId: $referenceRowId) {id name sku category price stock sort_order}}`;const UPDATE_PRODUCTS = `mutation UpdateProducts($rows: [ProductUpdateInput!]!) {updateProducts(rows: $rows)}`;const DELETE_PRODUCTS = `mutation DeleteProducts($ids: [Int!]!) {deleteProducts(ids: $ids)}`;// Converts Handsontable's filters shape — [{ prop, conditions: [{ name, args }] }] —// to the flat FilterInput array the GraphQL schema expects.function mapFilters(filters) {const result = [];filters.forEach(({ prop, conditions }) => {(conditions || []).forEach(({ name, args }) => {if (!name) return;const a = args ?? [];result.push({prop,condition: name,value: a[0] != null ? String(a[0]) : null,value2: a[1] != null ? String(a[1]) : null,});});});return result;}const container = document.querySelector('#example1');let removeConfirmed = false;// eslint-disable-next-line no-unused-varsconst hot = new Handsontable(container, {dataProvider: {rowId: 'id',fetchRows: async ({ page, pageSize, sort, filters }, { signal }) => {const data = await gql(FETCH_PRODUCTS, {page,pageSize,sort: sort ? { prop: sort.prop, order: sort.order } : null,filters: filters ? mapFilters(filters) : null,});return { rows: data.products.data, totalRows: data.products.total };},onRowsCreate: async (payload) => {const data = await gql(CREATE_PRODUCTS, {rowsAmount: payload.rowsAmount,position: payload.position,referenceRowId: payload.referenceRowId ?? null,});const rows = data.createProducts;const row = rows[0];hot.getPlugin('notification').showMessage({variant: 'success',title: 'Row added',message: `Created: ${row.sku} (id: ${row.id})`,duration: 3000,});// Return the created rows so dataProvider can update its row map with// server-assigned ids.return rows;},onRowsUpdate: async (rows) => {await gql(UPDATE_PRODUCTS, {rows: rows.map(({ id, changes }) => ({ id, changes })),});},onRowsRemove: async (rowIds) => {await gql(DELETE_PRODUCTS, { ids: rowIds });},},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: 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',});document.getElementById('btn-filter-empty').addEventListener('click', () => {const filters = hot.getPlugin('filters');filters.clearConditions();filters.addCondition(2, 'eq', ['Electronics']);filters.filter();});document.getElementById('btn-clear-filters').addEventListener('click', () => {const filters = hot.getPlugin('filters');filters.clearConditions();filters.filter();});export default hot;What’s happening:
gql()helperEvery GraphQL request is a
POSTto/graphqlwith a{ query, variables }body. The helper throws on both HTTP-level errors and GraphQL-level errors (theerrorsarray in the response), sonotification: truedisplays an error toast automatically in both cases.mapFilters()helperHandsontable sends filters as
[{ prop, conditions: [{ name, args }] }].mapFilters()flattens each condition into a separateFilterInputobject matching the$filterInputGraphQL type — the same field names thatProductRepository::findPage()already reads.Operations
Frontend constant Type Replaces FETCH_PRODUCTSqueryGET /api/productsCREATE_PRODUCTSmutationPOST /api/productsUPDATE_PRODUCTSmutationPATCH /api/productsDELETE_PRODUCTSmutationDELETE /api/productsfetchRows,onRowsCreate,onRowsUpdate, andonRowsRemovepass their variables directly togql(). No URL building or query-string serialization is needed — variables are sent as a typed JSON object.
How It Works — Complete Flow
- Initial load:
fetchRowsfires with{ page: 1, pageSize: 10, sort: null, filters: null }. Symfony 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' }. Symfony applies->orderBy('p.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. Symfony appliesLOWER(p.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 } }]. Symfony 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 }. Symfony shifts existing rows, inserts a blank row with the correctsortOrder, and returns the created row as JSON. The frontend inserts it into the current page data and shows a success notification — no automatic refetch is triggered. - 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]. Symfony 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 Symfony’s$request->query->all()with thebuildUrl()helper. - How to apply Handsontable filter condition names as Doctrine QueryBuilder
andWhere()clauses. - Why a separate
ProductRepositorykeeps the controller thin and makes the query logic testable in isolation. - Why
sortOrderis needed: the primary key reflects insertion order, but rows inserted via the context menu need an independent ordering column to preserve their display position. - How Doctrine maps
decimalcolumns to PHP strings and why the controller must cast them tofloatbefore serializing the JSON response. - How to validate column names against
ALLOWED_COLUMNSbefore interpolating them into DQL expressions to prevent injection. - Why no CSRF token is needed for a stateless JSON API and how to avoid CORS entirely by proxying API requests through the Vite dev server.
- 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 Laravel — the same Handsontable frontend wired to a Laravel backend