Skip to content

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).

View full example on GitHub

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/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
  • 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_URL in .env)
  • Node.js 22 and Handsontable installed (npm install handsontable)
  1. Scaffold the backend

    Install the required Symfony packages:

    Terminal window
    composer require symfony/framework-bundle symfony/routing

    Create the entity, repository, controller, and seed command:

    Terminal window
    php bin/console make:entity Product
    php bin/console make:controller ProductController

    What’s happening:

    • make:entity Product generates src/Entity/Product.php and src/Repository/ProductRepository.php.
    • make:controller ProductController generates src/Controller/ProductController.php.
    • Routes are declared as PHP attributes directly on the controller class and methods — there is no separate routes file.
  2. Define the Doctrine migration

    Generate a migration from the entity mapping:

    Terminal window
    php bin/console doctrine:migrations:generate

    Replace the generated up() body with the products schema:

    PHP
    <?php
    declare(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:migrate
    final class Version20240101000000 extends AbstractMigration
    {
    public function getDescription(): string
    {
    return 'Create products table';
    }
    public function up(Schema $schema): void
    {
    $this->addSql(<<<SQL
    CREATE 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 = InnoDB
    SQL);
    }
    public function down(Schema $schema): void
    {
    $this->addSql('DROP TABLE products');
    }
    }

    What’s happening:

    • id INT AUTO_INCREMENT is the primary key. This is the value Handsontable uses as rowId.
    • sku VARCHAR UNIQUE is generated server-side, so the grid marks it readOnly: true.
    • price DECIMAL(10, 2) stores two decimal places, matching the numeric cell type in the frontend column definition.
    • sort_order INT UNSIGNED tracks 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:migrate
  3. Create the Product entity

    Open src/Entity/Product.php and define the Doctrine mapping with typed properties:

    PHP
    <?php
    namespace 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 $fillable array — Doctrine tracks persistence via the entity manager, not mass-assignment.
    • price is mapped as decimal and stored as a PHP string by Doctrine to preserve precision. The controller explicitly casts it to float when serializing the response, so Handsontable’s numeric cell type receives 1299.99 instead of "1299.99".
    • sortOrder is an extra column that keeps the display order stable when rows are inserted at arbitrary positions using the context menu.
  4. Create the ProductRepository

    The ProductRepository handles all query logic, keeping the controller thin. Open src/Repository/ProductRepository.php:

    PHP
    <?php
    namespace 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 conditions
    case '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 filter

    findPage() builds a Doctrine QueryBuilder and applies filters, sorting, and pagination in sequence.

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

    Query parameterExample valuePHP 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 use LOWER() for case-insensitive matching:

    Handsontable conditionDQL equivalent
    containsLOWER(p.prop) LIKE '%value%'
    begins_withLOWER(p.prop) LIKE 'value%'
    gtp.prop > :value
    betweenp.prop BETWEEN :v AND :v2
    not_betweenp.prop < :v OR p.prop > :v2
    emptyp.prop IS NULL OR p.prop = ''

    Both $prop values (for filters and for sort) are validated against ALLOWED_COLUMNS before being interpolated into DQL expressions, preventing injection through unsanitized user input.

    Why setFirstResult()/setMaxResults() instead of a paginator helper?

    Handsontable already sends page and pageSize directly, so manual offset/limit (setFirstResult(($page - 1) * $pageSize)->setMaxResults($pageSize)) returns the { data, total } shape that fetchRows expects without any adapter code.

    createBlankRows() — insert rows at position

    When the user inserts rows from the context menu, onRowsCreate sends:

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

    createBlankRows() calls resolveInsertOrder() to find the correct sortOrder value for the new row. It then shifts all existing rows at or after that position by $count to make room, and inserts the new rows. The entire operation runs inside wrapInTransaction().

    updateRows() — update changed cells

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

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

    updateRows() finds each product by id and calls the appropriate setter for each key in changes, so unchanged fields are not overwritten.

    deleteByIds() — delete rows

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

    [4, 7, 12]

    deleteByIds() deletes them in one DELETE ... WHERE id IN (...) DQL query.

  5. Seed the database

    Create a Symfony Console command to populate the products table:

    PHP
    <?php
    namespace 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([]) > 0 before 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
  6. Build the ProductController

    ProductController handles all four HTTP verbs. Routes are declared as PHP attributes on the class and each method:

    PHP
    <?php
    namespace 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 to ProductRepository::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 to createBlankRows(), maps the returned entities to plain arrays (including sort_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 to updateRows() and returns HTTP 200.
    • batchDestroy() delegates to deleteByIds() and returns HTTP 204.

    Verify the routes are registered:

    Terminal window
    php bin/console debug:router | grep 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 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/json requests from a browser frontend.
  8. Wire up Handsontable

    With the Symfony server running (symfony server:start or php -S localhost:8000 -t public/) and the Vite dev server running (npm run dev), open http://localhost:5173 to see the grid. The complete frontend code is in the files below.

    JavaScript
    import { useRef, useCallback, useMemo } from 'react';
    import { HotTable } from '@handsontable/react-wrapper';
    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', operation: 'conjunction',
    // 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]
    //
    // Each DataProviderFilterColumn can have multiple conditions (e.g. between),
    // so we flatten them: one entry per condition, incrementing the index.
    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}`;
    }
    const ExampleComponent = () => {
    // hotRef.current.hotInstance is the underlying Handsontable instance.
    const hotRef = useRef(null);
    // beforeRowsMutation is sync, so we use a ref (not state) to track whether
    // the user already confirmed deletion. A plain { current: false } object
    // would be recreated on every render — useRef persists across renders.
    const removeConfirmedRef = useRef(false);
    // totalRows is updated by fetchRows and read by onRowsCreate to keep the
    // pagination counter in sync after a manual row insertion.
    const totalRowsRef = useRef(0);
    // fetchRows is called on every page change, sort, and filter.
    // signal: AbortSignal — pass it to fetch() so stale requests cancel
    // when the user sorts, filters, or changes pages quickly.
    const fetchRows = useCallback(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();
    totalRowsRef.current = 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 }
    const onRowsCreate = useCallback(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];
    const hot = hotRef.current?.hotInstance;
    if (!hot) return;
    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));
    totalRowsRef.current += 1;
    hot.runHooks('afterDataProviderFetch', { queryParameters: {}, totalRows: totalRowsRef.current });
    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.
    const onRowsUpdate = useCallback(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).
    const onRowsRemove = useCallback(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}`);
    }, []);
    const dataProvider = useMemo(
    () => ({
    // 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,
    onRowsCreate,
    onRowsUpdate,
    onRowsRemove,
    }),
    [fetchRows, onRowsCreate, onRowsUpdate, onRowsRemove]
    );
    // 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 ref lets the second
    // pass through without re-prompting.
    const beforeRowsMutation = useCallback((operation, payload) => {
    if (operation === 'remove' && !removeConfirmedRef.current) {
    const count = payload.rowsRemove.length;
    const hot = hotRef.current?.hotInstance;
    if (!hot) return false;
    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);
    removeConfirmedRef.current = true;
    hot.getPlugin('dataProvider').removeRows(payload.rowsRemove).finally(() => {
    removeConfirmedRef.current = false;
    });
    },
    },
    {
    label: 'Cancel',
    type: 'secondary',
    callback: () => notification.hide(id),
    },
    ],
    });
    return false;
    }
    }, []);
    const filterElectronics = useCallback(() => {
    const filters = hotRef.current?.hotInstance?.getPlugin('filters');
    if (!filters) return;
    filters.clearConditions();
    filters.addCondition(2, 'eq', ['Electronics']);
    filters.filter();
    }, []);
    const clearFilters = useCallback(() => {
    const filters = hotRef.current?.hotInstance?.getPlugin('filters');
    if (!filters) return;
    filters.clearConditions();
    filters.filter();
    }, []);
    return (
    <div>
    <div className="toolbar">
    <button onClick={filterElectronics}>Show Electronics</button>
    <button onClick={clearFilters}>Clear filters</button>
    </div>
    <div id="example1">
    <HotTable
    ref={hotRef}
    dataProvider={dataProvider}
    // beforeRowsMutation fires before any create, update, or remove operation.
    beforeRowsMutation={beforeRowsMutation}
    // 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"
    />
    </div>
    </div>
    );
    };
    export default ExampleComponent;

    What’s happening:

    buildUrl helper

    buildUrl serializes the queryParameters object that fetchRows receives 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.

    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. totalRows = json.total saves the current server-side total so onRowsCreate can increment it without a refetch.

    onRowsCreate, onRowsUpdate, onRowsRemove

    No CSRF token header is required. Symfony’s built-in CSRF protection targets HTML form submissions, not JSON API requests.

    onRowsCreate uses 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, increments totalRows, and fires afterDataProviderFetch to update the pagination counter. It then throws new Error('stop refetch'), which signals Handsontable to skip the automatic refetch that would normally follow a create. This avoids a second round-trip because the sortOrder column ensures the inserted row always appears at a predictable offset on the current page — a full refetch is unnecessary.

    Cell edits via onRowsUpdate appear 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 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 other async dialogs. Instead, cancel the first attempt by returning false, show a notification with Delete and Cancel actions, and on Delete re-issue the remove via the DataProvider API. 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.

  9. Alternative — Use a GraphQL API

    Instead of the four REST endpoints you can expose the same logic through a single /graphql endpoint using webonyx/graphql-php.

    Install the package:

    Terminal window
    composer require webonyx/graphql-php

    9a. Define the schema

    Create src/GraphQL/ProductSchema.php. This file defines every type, query, and mutation the frontend can call:

    PHP
    <?php
    namespace 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 both products.data[] and createProducts[].
    • $sortInput / $filterInput / $productUpdateInput — input types that mirror the shapes Handsontable passes to fetchRows, onRowsCreate, and onRowsUpdate.
    • Query.products — replaces GET /api/products. Accepts page, pageSize, sort, and filters and delegates to ProductRepository::findPage().
    • Mutation.createProducts — replaces POST /api/products. Returns the newly created rows so the frontend can show a success notification.
    • Mutation.updateProducts / Mutation.deleteProducts — replace PATCH and DELETE. Both return a boolean; actual data comes from a subsequent fetchRows call.

    9b. Add the GraphQL controller

    Create src/Controller/GraphQLController.php:

    PHP
    <?php
    namespace 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 via GraphQL::executeQuery(), and returns the result as JSON. Stack traces are included in the response only when kernel.debug is true.

    9c. Wire up the frontend

    The frontend replaces the per-endpoint fetch() calls with a single gql() helper and adds a mapFilters() function to convert Handsontable’s conditions array into the flat FilterInput shape the schema expects. The Handsontable configuration — columns, plugins, beforeRowsMutation — is otherwise identical to the REST example.

    What’s happening:

    gql() helper

    Every GraphQL request is a POST to /graphql with a { query, variables } body. The helper throws on both HTTP-level errors and GraphQL-level errors (the errors array in the response), so notification: true displays an error toast automatically in both cases.

    mapFilters() helper

    Handsontable sends filters as [{ prop, conditions: [{ name, args }] }]. mapFilters() flattens each condition into a separate FilterInput object matching the $filterInput GraphQL type — the same field names that ProductRepository::findPage() already reads.

    Operations

    Frontend constantTypeReplaces
    FETCH_PRODUCTSqueryGET /api/products
    CREATE_PRODUCTSmutationPOST /api/products
    UPDATE_PRODUCTSmutationPATCH /api/products
    DELETE_PRODUCTSmutationDELETE /api/products

    fetchRows, onRowsCreate, onRowsUpdate, and onRowsRemove pass their variables directly to gql(). No URL building or query-string serialization is needed — variables are sent as a typed JSON object.

How It Works — Complete Flow

  1. Initial load: fetchRows fires 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.
  2. Sort: The user clicks the Price header. fetchRows fires with sort: { prop: 'price', order: 'asc' }. Symfony applies ->orderBy('p.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. Symfony applies LOWER(p.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 } }]. Symfony 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 }. Symfony shifts existing rows, inserts a blank row with the correct sortOrder, 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.
  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]. Symfony deletes both rows.
  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 Symfony’s $request->query->all() with the buildUrl() helper.
  • How to apply Handsontable filter condition names as Doctrine QueryBuilder andWhere() clauses.
  • Why a separate ProductRepository keeps the controller thin and makes the query logic testable in isolation.
  • Why sortOrder is 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 decimal columns to PHP strings and why the controller must cast them to float before serializing the JSON response.
  • How to validate column names against ALLOWED_COLUMNS before 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: true provides error toasts and a Refetch action with no extra code.
  • How beforeRowsMutation intercepts operations before they reach the server.

Next steps