Grid and Forms

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against PrestaShop 9.2.0 (released on 2026-09-30, Symfony 6.4, Doctrine DBAL, PHP 8.1 to 8.5) and checked on 2026-10-27. Grids and forms are the building blocks of the modern back office of the open-source edition, so nothing here depends on a plan or an edition. Almost every list in the back office (products, customers, orders, order messages, manufacturers) is a Grid, and almost every create or edit page is a Symfony form handled by an identifiable-object form builder and handler. A module can use the same machinery to add pages that look and behave like the core ones, and, because both are built on hooks, it can also change the grids and forms of the core and of other modules without touching their code. The page sits after Hooks (whose dispatcher it relies on) and Module Basics, and before Module Services and Admin Controllers, which explains the service files, controllers and routes that the example uses in more detail.

The running example is Pocket Coffee. The module pcfcgrid adds two pages to the Catalog menu: "Roast batches", a grid of the batches of roasted beans with filters, sorting, pagination, a toggle, delete and bulk delete, and a form to create and edit a batch; and "Roast settings", a settings form with the freshness window and the badge label used by the storefront badge of the hooks page. A second module, pcfcgridext, extends the grid and the form through hooks alone. Everything was installed on a real 9.2.0 shop and exercised through authenticated HTTP requests to the back office; the results quoted below are from those requests.

The moving parts

flowchart LR R[GET list page] --> F[BatchFilters: saved or default search criteria] F --> GF[GridFactory getGrid] GF --> DEF[GridDefinitionFactory getDefinition] DEF -. hook .-> H1[actionPcfcgridBatchGridDefinitionModifier] GF --> DF[DoctrineGridDataFactory getData] DF --> QB[BatchQueryBuilder: search and count queries] QB -. hook .-> H2[actionPcfcgridBatchGridQueryBuilderModifier] DF --> SQL[(ps_pcfcgrid_batch)] GF -. hook .-> H3[actionPcfcgridBatchGridDataModifier] GF --> G[Grid: definition, data, criteria, filter form] G --> P[presentGrid] P -. hook .-> H4[actionPcfcgridBatchGridPresenterModifier] P --> T[grid_panel.html.twig]

A Grid is assembled from four independent pieces, and each one can be replaced or decorated.

Piece Responsibility

Definition (GridDefinitionFactoryInterface)

The structure of the grid: its id, name, columns, filters, grid actions and bulk actions. The base class AbstractGridDefinitionFactory builds the definition object and dispatches the definition hook; the variant AbstractFilterableGridDefinitionFactory also exposes the filter id so that the common search action can save the filters.

Data (GridDataFactoryInterface)

Returns the records and the total for a set of search criteria. DoctrineGridDataFactory takes a query builder with two queries, one for the page of rows and one for the count, and dispatches the query-builder hook.

Search criteria (SearchCriteriaInterface, in practice a Filters subclass)

Filters, order, offset and limit. It is immutable. The back office rebuilds it for each request from the query string, the saved filters of the employee and the defaults of the class.

Grid factory (GridFactory)

Combines the definition, the data and the filter form into a Grid and dispatches the data hook. The controller then calls presentGrid(), which dispatches the presenter hook, and renders the Twig template of the core.

The module

The main class declares two tabs. A tab is a menu entry plus a permission target: the module manager registers it on install (rows in ps_tab, with four role rows per tab in ps_authorization_role) and removes it on uninstall. Its class_name must equal the _legacy_controller of the routes. getContent() redirects the "Configure" button to the Symfony settings page.

<?php

/**
 * Pocket Coffee roast batches in the back office: a grid, a form and a settings page.
 */
if (!defined('_PS_VERSION_')) {
    exit;
}

if (file_exists(__DIR__ . '/vendor/autoload.php')) {
    require_once __DIR__ . '/vendor/autoload.php';
}

use PocketCoffee\PcfcGrid\Install\Installer;
use PrestaShop\PrestaShop\Adapter\SymfonyContainer;

class Pcfcgrid extends Module
{
    public function __construct()
    {
        $this->name = 'pcfcgrid';
        $this->tab = 'administration';
        $this->version = '1.0.0';
        $this->author = 'Pocket Coffee';
        $this->need_instance = 0;
        $this->ps_versions_compliancy = ['min' => '9.0.0', 'max' => '9.99.99'];
        $this->bootstrap = true;

        parent::__construct();

        $this->displayName = $this->trans('Pocket Coffee roast batches grid', [], 'Modules.Pcfcgrid.Admin');
        $this->description = $this->trans('Lists, filters and edits the roast batches.', [], 'Modules.Pcfcgrid.Admin');

        // The module manager registers these back office pages (menu entries and permissions) on install.
        $this->tabs = [
            [
                'name' => 'Roast batches',
                'class_name' => 'AdminPcfcgridBatches',
                'route_name' => 'pcfcgrid_batches_index',
                'parent_class_name' => 'AdminCatalog',
                'visible' => true,
            ],
            [
                'name' => 'Roast settings',
                'class_name' => 'AdminPcfcgridSettings',
                'route_name' => 'pcfcgrid_settings',
                'parent_class_name' => 'AdminCatalog',
                'visible' => true,
            ],
        ];
    }

    public function isUsingNewTranslationSystem(): bool
    {
        return true;
    }

    public function install(): bool
    {
        $installer = new Installer();

        return parent::install()
            && $installer->createTables()
            && $installer->createConfiguration();
    }

    public function uninstall(): bool
    {
        $installer = new Installer();

        return $installer->dropConfiguration()
            && $installer->dropSavedFilters()
            && $installer->dropTables()
            && parent::uninstall();
    }

    /**
     * The "Configure" button of the module manager opens the Symfony settings page.
     */
    public function getContent(): void
    {
        Tools::redirectAdmin(SymfonyContainer::getInstance()->get('router')->generate('pcfcgrid_settings'));
    }
}

The installer creates the table with four seed batches, the two configuration keys, and, in uninstall(), removes everything including the saved filters of the grid (explained in the filters section).

<?php

declare(strict_types=1);

namespace PocketCoffee\PcfcGrid\Install;

if (!defined('_PS_VERSION_')) {
    exit;
}

final class Installer
{
    public const KEY_FRESH_DAYS = 'PCFCGRID_FRESH_DAYS';
    public const KEY_BADGE_LABEL = 'PCFCGRID_BADGE_LABEL';

    public function createTables(): bool
    {
        $db = \Db::getInstance();

        $created = $db->execute(
            'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'pcfcgrid_batch` (
                `id_batch` INT UNSIGNED NOT NULL AUTO_INCREMENT,
                `reference` VARCHAR(32) NOT NULL,
                `origin` VARCHAR(16) NOT NULL,
                `roast_date` DATE NOT NULL,
                `active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1,
                PRIMARY KEY (`id_batch`),
                UNIQUE KEY `uniq_reference` (`reference`)
            ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4'
        );

        return $created && $db->execute(
            'INSERT IGNORE INTO `' . _DB_PREFIX_ . 'pcfcgrid_batch` (`reference`, `origin`, `roast_date`, `active`) VALUES
                (\'PC-2026-10-A\', \'ethiopia\', \'2026-10-05\', 1),
                (\'PC-2026-10-B\', \'colombia\', \'2026-10-12\', 1),
                (\'PC-2026-10-C\', \'brazil\', \'2026-10-19\', 0),
                (\'PC-2026-10-D\', \'ethiopia\', \'2026-10-26\', 1)'
        );
    }

    public function dropTables(): bool
    {
        return (bool) \Db::getInstance()->execute('DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'pcfcgrid_batch`');
    }

    /**
     * The back office saves the filters of each employee per grid id; they outlive the module unless removed.
     */
    public function dropSavedFilters(): bool
    {
        return (bool) \Db::getInstance()->execute(
            'DELETE FROM `' . _DB_PREFIX_ . 'admin_filter` WHERE `filter_id` = \'pcfcgrid_batch\''
        );
    }

    public function createConfiguration(): bool
    {
        return \Configuration::updateValue(self::KEY_FRESH_DAYS, 21)
            && \Configuration::updateValue(self::KEY_BADGE_LABEL, 'Roasted on');
    }

    public function dropConfiguration(): bool
    {
        return \Configuration::deleteByName(self::KEY_FRESH_DAYS)
            && \Configuration::deleteByName(self::KEY_BADGE_LABEL);
    }
}

The Grid definition

A definition factory extends AbstractFilterableGridDefinitionFactory, returns a constant grid id from getId() and implements getColumns(), getFilters(), getGridActions() and getBulkActions(). The grid id is more than a label. It names the filter form (pcfcgrid_batch[...]), the key under which the filters of each employee are saved, the prefix of the bulk checkbox field, and the middle part of four hooks. The hook names are built by upper-casing each part of the id: pcfcgrid_batch gives actionPcfcgridBatchGridDefinitionModifier, ...GridQueryBuilderModifier, ...GridDataModifier and ...GridPresenterModifier. Choose a prefixed id so that it cannot collide with a core grid (customer, order_message).

<?php

declare(strict_types=1);

namespace PocketCoffee\PcfcGrid\Grid\Definition;

use PrestaShop\PrestaShop\Core\Grid\Action\Bulk\BulkActionCollection;
use PrestaShop\PrestaShop\Core\Grid\Action\GridActionCollection;
use PrestaShop\PrestaShop\Core\Grid\Action\Row\RowActionCollection;
use PrestaShop\PrestaShop\Core\Grid\Action\Row\Type\LinkRowAction;
use PrestaShop\PrestaShop\Core\Grid\Action\Type\LinkGridAction;
use PrestaShop\PrestaShop\Core\Grid\Action\Type\SimpleGridAction;
use PrestaShop\PrestaShop\Core\Grid\Column\ColumnCollection;
use PrestaShop\PrestaShop\Core\Grid\Column\Type\Common\ActionColumn;
use PrestaShop\PrestaShop\Core\Grid\Column\Type\Common\BulkActionColumn;
use PrestaShop\PrestaShop\Core\Grid\Column\Type\Common\DataColumn;
use PrestaShop\PrestaShop\Core\Grid\Column\Type\Common\DateTimeColumn;
use PrestaShop\PrestaShop\Core\Grid\Column\Type\Common\ToggleColumn;
use PrestaShop\PrestaShop\Core\Grid\Definition\Factory\AbstractFilterableGridDefinitionFactory;
use PrestaShop\PrestaShop\Core\Grid\Definition\Factory\BulkDeleteActionTrait;
use PrestaShop\PrestaShop\Core\Grid\Definition\Factory\DeleteActionTrait;
use PrestaShop\PrestaShop\Core\Grid\Filter\Filter;
use PrestaShop\PrestaShop\Core\Grid\Filter\FilterCollection;
use PrestaShopBundle\Form\Admin\Type\DateRangeType;
use PrestaShopBundle\Form\Admin\Type\SearchAndResetType;
use PrestaShopBundle\Form\Admin\Type\YesAndNoChoiceType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;

/**
 * Structure of the grid: its id, columns, filters and actions. No data is read here.
 */
final class BatchGridDefinitionFactory extends AbstractFilterableGridDefinitionFactory
{
    use BulkDeleteActionTrait;
    use DeleteActionTrait;

    /** Used in the hook names (actionPcfcgridBatchGridDefinitionModifier, ...) and as key of the saved filters. */
    public const GRID_ID = 'pcfcgrid_batch';

    public const ORIGINS = ['ethiopia' => 'Ethiopia Yirgacheffe', 'colombia' => 'Colombia Huila', 'brazil' => 'Brazil Cerrado'];

    protected function getId(): string
    {
        return self::GRID_ID;
    }

    protected function getName(): string
    {
        return $this->trans('Roast batches', [], 'Modules.Pcfcgrid.Admin');
    }

    protected function getColumns(): ColumnCollection
    {
        return (new ColumnCollection())
            ->add(
                (new BulkActionColumn('batches_bulk'))
                    ->setOptions(['bulk_field' => 'id_batch'])
            )
            ->add(
                (new DataColumn('id_batch'))
                    ->setName($this->trans('ID', [], 'Admin.Global'))
                    ->setOptions(['field' => 'id_batch'])
            )
            ->add(
                (new DataColumn('reference'))
                    ->setName($this->trans('Reference', [], 'Modules.Pcfcgrid.Admin'))
                    ->setOptions(['field' => 'reference'])
            )
            ->add(
                (new DataColumn('origin'))
                    ->setName($this->trans('Origin', [], 'Modules.Pcfcgrid.Admin'))
                    ->setOptions(['field' => 'origin'])
            )
            ->add(
                (new DateTimeColumn('roast_date'))
                    ->setName($this->trans('Roast date', [], 'Modules.Pcfcgrid.Admin'))
                    ->setOptions(['field' => 'roast_date', 'format' => 'Y-m-d'])
            )
            ->add(
                (new ToggleColumn('active'))
                    ->setName($this->trans('Enabled', [], 'Admin.Global'))
                    ->setOptions([
                        'field' => 'active',
                        'primary_field' => 'id_batch',
                        'route' => 'pcfcgrid_batches_toggle',
                        'route_param_name' => 'batchId',
                    ])
            )
            ->add(
                (new ActionColumn('actions'))
                    ->setName($this->trans('Actions', [], 'Admin.Global'))
                    ->setOptions([
                        'actions' => (new RowActionCollection())
                            ->add(
                                (new LinkRowAction('edit'))
                                    ->setName($this->trans('Edit', [], 'Admin.Actions'))
                                    ->setIcon('edit')
                                    ->setOptions([
                                        'route' => 'pcfcgrid_batches_edit',
                                        'route_param_name' => 'batchId',
                                        'route_param_field' => 'id_batch',
                                    ])
                            )
                            ->add($this->buildDeleteAction('pcfcgrid_batches_delete', 'batchId', 'id_batch')),
                    ])
            );
    }

    protected function getFilters(): FilterCollection
    {
        return (new FilterCollection())
            ->add(
                (new Filter('id_batch', TextType::class))
                    ->setTypeOptions(['required' => false, 'attr' => ['placeholder' => $this->trans('Search ID', [], 'Admin.Actions')]])
                    ->setAssociatedColumn('id_batch')
            )
            ->add(
                (new Filter('reference', TextType::class))
                    ->setTypeOptions(['required' => false, 'attr' => ['placeholder' => $this->trans('Search reference', [], 'Modules.Pcfcgrid.Admin')]])
                    ->setAssociatedColumn('reference')
            )
            ->add(
                (new Filter('origin', ChoiceType::class))
                    ->setTypeOptions(['required' => false, 'choices' => array_flip(self::ORIGINS), 'placeholder' => false])
                    ->setAssociatedColumn('origin')
            )
            ->add(
                (new Filter('roast_date', DateRangeType::class))
                    ->setTypeOptions(['required' => false, 'date_format' => 'YYYY-MM-DD'])
                    ->setAssociatedColumn('roast_date')
            )
            ->add(
                (new Filter('active', YesAndNoChoiceType::class))
                    ->setAssociatedColumn('active')
            )
            ->add(
                (new Filter('actions', SearchAndResetType::class))
                    ->setTypeOptions([
                        'reset_route' => 'admin_common_reset_search_by_filter_id',
                        'reset_route_params' => ['filterId' => self::GRID_ID],
                        'redirect_route' => 'pcfcgrid_batches_index',
                    ])
                    ->setAssociatedColumn('actions')
            );
    }

    protected function getGridActions(): GridActionCollection
    {
        return (new GridActionCollection())
            ->add(
                (new LinkGridAction('add'))
                    ->setName($this->trans('Add new batch', [], 'Modules.Pcfcgrid.Admin'))
                    ->setIcon('add_circle_outline')
                    ->setOptions(['route' => 'pcfcgrid_batches_create'])
            )
            ->add(
                (new SimpleGridAction('common_refresh_list'))
                    ->setName($this->trans('Refresh list', [], 'Admin.Advparameters.Feature'))
                    ->setIcon('refresh')
            )
            ->add(
                (new SimpleGridAction('common_show_query'))
                    ->setName($this->trans('Show SQL query', [], 'Admin.Actions'))
                    ->setIcon('code')
            );
    }

    protected function getBulkActions(): BulkActionCollection
    {
        return (new BulkActionCollection())
            ->add($this->buildBulkDeleteAction('pcfcgrid_batches_bulk_delete'));
    }
}

Columns

The column classes of 9.2.0 live in PrestaShop\PrestaShop\Core\Grid\Column\Type\Common. The ones that matter most:

Column What it renders and its main options

BulkActionColumn

The checkbox column. Option bulk_field names the record field that becomes the value of each checkbox. The submitted array is named after the grid id and the column id: pcfcgrid_batch_batches_bulk[] here.

DataColumn

A value from the record. Option field (required). It is sortable when the query builder understands the field name.

DateTimeColumn

A date. Options field and format (default Y-m-d H:i:s). The format is normalised so that - becomes a non-breaking hyphen (U+2011), which is why the list showed 2026‑10‑26 with that character; do not compare the rendered text with a plain hyphen.

ToggleColumn

A boolean icon that posts to a route. Options field, primary_field, route and route_param_name (all required in practice), extra_route_params. The route receives the record id under route_param_name and must accept POST.

ActionColumn

Holds the row actions. Option actions takes a RowActionCollection of LinkRowAction (a link to a route, with route, route_param_name and route_param_field) and SubmitRowAction (a POST, with an optional confirmation modal). DeleteActionTrait::buildDeleteAction() builds the standard delete action with its modal.

ChoiceColumn, BadgeColumn, StatusColumn, LinkColumn, ImageColumn, ColorColumn, HtmlColumn, PositionColumn, PreviewColumn, BooleanColumn

Special renderings. ChoiceColumn is an editable select that needs a choice_provider implementing ConfigurableFormChoiceProviderInterface and a route, which is why the example shows the origin with a plain DataColumn.

Filters

Each Filter has a name, a Symfony form type and an associated column. A column without a filter is displayed but cannot be searched; a filter whose associated column was removed leaves a dangling field in the filter form, so a module that removes a column must remove the filter too (the extension module does). The form type of a filter is any Symfony type; PrestaShop adds SearchAndResetType (the Search and Reset buttons, required once per grid, with the options reset_route, reset_route_params and redirect_route), YesAndNoChoiceType, DateRangeType (an array with from and to), CountryChoiceType and ProfileChoiceType. The filter whose name equals the SQL field makes the query builder side trivial, as shown next.

Actions

  • Grid actions act on the whole grid: SimpleGridAction (a built-in behaviour, such as common_refresh_list and common_show_query used here), LinkGridAction (a link to a route, used for "Add new batch") and SubmitGridAction.

  • Row actions are in the action column (above).

  • Bulk actions act on the checked rows: SubmitBulkAction posts the checked ids to a route; buildBulkDeleteAction() of BulkDeleteActionTrait adds the confirmation modal.

The query builder

The query builder returns two Doctrine DBAL QueryBuilder objects: one selects the page of rows, one counts all the rows that match the filters. Both are built from the same base query so that the pagination and the total agree. The helper services of the core do the repetitive work: DoctrineSearchCriteriaApplicator adds ORDER BY, LIMIT and OFFSET from the criteria, and DoctrineFilterApplicator turns the filters into WHERE clauses according to a SqlFilters map. The comparison modes of SqlFilters are WHERE_STRICT (equality), WHERE_LIKE, HAVING_LIKE, WHERE_DATE (a range with from and to; the times 0:0:0 and 23:59:59 are appended) and MIN_MAX.

<?php

declare(strict_types=1);

namespace PocketCoffee\PcfcGrid\Grid\Query;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use PrestaShop\PrestaShop\Core\Grid\Query\AbstractDoctrineQueryBuilder;
use PrestaShop\PrestaShop\Core\Grid\Query\DoctrineSearchCriteriaApplicatorInterface;
use PrestaShop\PrestaShop\Core\Grid\Query\Filter\DoctrineFilterApplicatorInterface;
use PrestaShop\PrestaShop\Core\Grid\Query\Filter\SqlFilters;
use PrestaShop\PrestaShop\Core\Grid\Search\SearchCriteriaInterface;

/**
 * Builds the two queries behind the grid: the page of rows and the total count.
 */
final class BatchQueryBuilder extends AbstractDoctrineQueryBuilder
{
    public function __construct(
        Connection $connection,
        string $dbPrefix,
        private readonly DoctrineFilterApplicatorInterface $filterApplicator,
        private readonly DoctrineSearchCriteriaApplicatorInterface $criteriaApplicator,
    ) {
        parent::__construct($connection, $dbPrefix);
    }

    public function getSearchQueryBuilder(SearchCriteriaInterface $searchCriteria): QueryBuilder
    {
        $qb = $this->buildBaseQuery($searchCriteria);
        $qb->select('b.id_batch, b.reference, b.origin, b.roast_date, b.active');

        $this->criteriaApplicator
            ->applyPagination($searchCriteria, $qb)
            ->applySorting($searchCriteria, $qb);

        return $qb;
    }

    public function getCountQueryBuilder(SearchCriteriaInterface $searchCriteria): QueryBuilder
    {
        $qb = $this->buildBaseQuery($searchCriteria);
        $qb->select('COUNT(b.id_batch)');

        return $qb;
    }

    private function buildBaseQuery(SearchCriteriaInterface $searchCriteria): QueryBuilder
    {
        $qb = $this->connection->createQueryBuilder()
            ->from($this->dbPrefix . 'pcfcgrid_batch', 'b');

        $filters = (new SqlFilters())
            ->addFilter('id_batch', 'b.id_batch', SqlFilters::WHERE_STRICT)
            ->addFilter('reference', 'b.reference', SqlFilters::WHERE_LIKE)
            ->addFilter('origin', 'b.origin', SqlFilters::WHERE_STRICT)
            ->addFilter('roast_date', 'b.roast_date', SqlFilters::WHERE_DATE)
            ->addFilter('active', 'b.active', SqlFilters::WHERE_STRICT);

        $this->filterApplicator->apply($qb, $filters, $searchCriteria->getFilters());

        return $qb;
    }
}

The name of each SQL column returned by the select must equal the field option of its column, because that is how a record is mapped to a cell. The filters are applied with bound parameters, which is also why the example never concatenates user input into the query.

Filters, sorting and pagination

The Filters class carries the default sorting and page size, and its type is all a controller needs to receive the criteria:

<?php

declare(strict_types=1);

namespace PocketCoffee\PcfcGrid\Grid\Filters;

use PocketCoffee\PcfcGrid\Grid\Definition\BatchGridDefinitionFactory;
use PrestaShop\PrestaShop\Core\Search\Filters;

/**
 * Default sorting and pagination of the grid. The class is resolved as a controller argument
 * and the filters of the employee are saved per grid id.
 */
final class BatchFilters extends Filters
{
    protected $filterId = BatchGridDefinitionFactory::GRID_ID;

    public static function getDefaults(): array
    {
        return [
            'limit' => 10,
            'offset' => 0,
            'orderBy' => 'roast_date',
            'sortOrder' => 'desc',
            'filters' => [],
        ];
    }
}

When the controller declares an argument of a class that extends Filters, an argument resolver (FiltersBuilderResolver) builds it from three sources, in this order of precedence: the query string (pcfcgrid_batch[orderBy]=reference, pcfcgrid_batch[sortOrder]=asc, pcfcgrid_batch[limit]=2, pcfcgrid_batch[offset]=2), the criteria saved in the database for this employee and grid, and getDefaults(). The result is saved again, in the table ps_admin_filter (employee, shop, controller, action, filter as JSON, filter_id). The filter form of the grid posts to the same URL; a route bound to CommonController::searchGridAction receives that POST, reads the filter id from the definition factory, saves the submitted filters and redirects to the list.

The behaviour was checked against the module with the four seed batches:

Request Rows returned

default list

4 rows, ordered by roast date descending (4, 3, 2, 1)

filter reference = 10-B

1 row (PC-2026-10-B), and the same single row on the next plain GET, because the filter was saved

filter origin = ethiopia and roast_date from 2026-10-10 to 2026-10-31

1 row (PC-2026-10-D)

filter active = 0

1 row (PC-2026-10-C)

orderBy = reference, sortOrder = asc

4 rows, ordered 1, 2, 3, 4

limit = 2, offset = 2, roast date descending

2 rows (batches 2 and 1)

POST to /common/reset_search/pcfcgrid_batch

The saved filters go back to the defaults: {"limit":"10","orderBy":"roast_date","sortOrder":"desc","filters":[]}

The reset button of the grid is not a link; the JavaScript extension FiltersResetExtension posts to admin_common_reset_search_by_filter_id. A saved filter row outlives the module, which is why uninstall() deletes it: after the first uninstall that did not, one ps_admin_filter row for pcfcgrid_batch remained.

Services and routes

The module registers its services in config/services.yml. The tag core.grid_definition_factory is added to every definition factory by an _instanceof rule, because the core rule of the same name applies only to the core files; without the tag, searchGridAction cannot find the factory by its service id, which must also be public. The form type classes are tagged form.type; the identifiable-object builder and handler are created by core factory services.

services:
  _defaults:
    public: false
    autowire: true
    autoconfigure: true

  # Every grid definition factory must carry this tag, so that the common search action can find it by service id.
  _instanceof:
    PrestaShop\PrestaShop\Core\Grid\Definition\Factory\GridDefinitionFactoryInterface:
      tags: ['core.grid_definition_factory']

  # --- data access -----------------------------------------------------------------------------------------
  PocketCoffee\PcfcGrid\Repository\BatchRepository:
    arguments:
      $connection: '@doctrine.dbal.default_connection'
      $dbPrefix: '%database_prefix%'

  # --- grid ------------------------------------------------------------------------------------------------
  pcfcgrid.grid.definition.factory.batch:
    class: PocketCoffee\PcfcGrid\Grid\Definition\BatchGridDefinitionFactory
    parent: PrestaShop\PrestaShop\Core\Grid\Definition\Factory\AbstractGridDefinitionFactory
    public: true

  pcfcgrid.grid.query_builder.batch:
    class: PocketCoffee\PcfcGrid\Grid\Query\BatchQueryBuilder
    arguments:
      $connection: '@doctrine.dbal.default_connection'
      $dbPrefix: '%database_prefix%'
      $filterApplicator: '@prestashop.core.grid.query.filter.doctrine_filter_applicator'
      $criteriaApplicator: '@prestashop.core.query.doctrine_search_criteria_applicator'

  pcfcgrid.grid.data_factory.batch:
    class: PrestaShop\PrestaShop\Core\Grid\Data\Factory\DoctrineGridDataFactory
    arguments:
      - '@pcfcgrid.grid.query_builder.batch'
      - '@prestashop.core.hook.dispatcher'
      - '@prestashop.core.grid.query.doctrine_query_parser'
      - 'pcfcgrid_batch'

  pcfcgrid.grid.factory.batch:
    class: PrestaShop\PrestaShop\Core\Grid\GridFactory
    arguments:
      - '@pcfcgrid.grid.definition.factory.batch'
      - '@pcfcgrid.grid.data_factory.batch'
      - '@prestashop.core.grid.filter.form_factory'
      - '@prestashop.core.hook.dispatcher'

  # --- create and edit form ----------------------------------------------------------------------------------
  PocketCoffee\PcfcGrid\Form\BatchType:
    tags: ['form.type']

  PocketCoffee\PcfcGrid\Form\BatchFormDataProvider: ~
  PocketCoffee\PcfcGrid\Form\BatchFormDataHandler: ~

  pcfcgrid.form.builder.batch:
    class: PrestaShop\PrestaShop\Core\Form\IdentifiableObject\Builder\FormBuilder
    factory: ['@prestashop.core.form.builder.form_builder_factory', 'create']
    arguments:
      - 'PocketCoffee\PcfcGrid\Form\BatchType'
      - '@PocketCoffee\PcfcGrid\Form\BatchFormDataProvider'

  pcfcgrid.form.handler.batch:
    class: PrestaShop\PrestaShop\Core\Form\IdentifiableObject\Handler\FormHandler
    factory: ['@prestashop.core.form.identifiable_object.handler.form_handler_factory', 'create']
    arguments:
      - '@PocketCoffee\PcfcGrid\Form\BatchFormDataHandler'

  # --- settings form -----------------------------------------------------------------------------------------
  PocketCoffee\PcfcGrid\Form\SettingsType:
    tags: ['form.type']

  PocketCoffee\PcfcGrid\Form\SettingsConfiguration:
    arguments:
      - '@prestashop.adapter.legacy.configuration'

  PocketCoffee\PcfcGrid\Form\SettingsFormDataProvider:
    arguments:
      - '@PocketCoffee\PcfcGrid\Form\SettingsConfiguration'

  pcfcgrid.form.settings_handler:
    class: PrestaShop\PrestaShop\Core\Form\Handler
    arguments:
      - '@form.factory'
      - '@prestashop.core.hook.dispatcher'
      - '@PocketCoffee\PcfcGrid\Form\SettingsFormDataProvider'
      - 'PocketCoffee\PcfcGrid\Form\SettingsType'
      - 'PcfcgridSettings'
      - 'pcfcgrid_settings'

  # --- controllers -------------------------------------------------------------------------------------------
  PocketCoffee\PcfcGrid\Controller\Admin\BatchController:
    public: true

  PocketCoffee\PcfcGrid\Controller\Admin\SettingsController:
    public: true

Module routes are mounted under /modules, so path: /pcfcgrid/batches is served at /modules/pcfcgrid/batches (bin/console debug:router lists it with that prefix). _legacy_controller must equal the class_name of the tab; it is what the #[AdminSecurity] check and the permission system use. The search route and the list route share a path and differ by method.

# Module routes are mounted under /modules by the back office router.
# _legacy_controller must equal the class_name of the tab, otherwise the permission check does not find the page.

pcfcgrid_batches_index:
  path: /pcfcgrid/batches
  methods: [GET]
  defaults:
    _controller: 'PocketCoffee\PcfcGrid\Controller\Admin\BatchController::indexAction'
    _legacy_controller: AdminPcfcgridBatches
    _legacy_link: AdminPcfcgridBatches

# The filter form of the grid posts here; the common action saves the filters and redirects to the list.
pcfcgrid_batches_search:
  path: /pcfcgrid/batches
  methods: [POST]
  defaults:
    _controller: 'PrestaShopBundle\Controller\Admin\CommonController::searchGridAction'
    gridDefinitionFactoryServiceId: pcfcgrid.grid.definition.factory.batch
    redirectRoute: pcfcgrid_batches_index
    _legacy_controller: AdminPcfcgridBatches
    _legacy_link: AdminPcfcgridBatches:submitFilterpcfcgrid_batch

pcfcgrid_batches_create:
  path: /pcfcgrid/batches/new
  methods: [GET, POST]
  defaults:
    _controller: 'PocketCoffee\PcfcGrid\Controller\Admin\BatchController::createAction'
    _legacy_controller: AdminPcfcgridBatches
    _legacy_link: AdminPcfcgridBatches:add

pcfcgrid_batches_edit:
  path: /pcfcgrid/batches/{batchId}/edit
  methods: [GET, POST]
  defaults:
    _controller: 'PocketCoffee\PcfcGrid\Controller\Admin\BatchController::editAction'
    _legacy_controller: AdminPcfcgridBatches
    _legacy_link: AdminPcfcgridBatches:update
  requirements:
    batchId: \d+

pcfcgrid_batches_toggle:
  path: /pcfcgrid/batches/{batchId}/toggle
  methods: [POST]
  defaults:
    _controller: 'PocketCoffee\PcfcGrid\Controller\Admin\BatchController::toggleAction'
    _legacy_controller: AdminPcfcgridBatches
  requirements:
    batchId: \d+

pcfcgrid_batches_delete:
  path: /pcfcgrid/batches/{batchId}/delete
  methods: [POST]
  defaults:
    _controller: 'PocketCoffee\PcfcGrid\Controller\Admin\BatchController::deleteAction'
    _legacy_controller: AdminPcfcgridBatches
  requirements:
    batchId: \d+

pcfcgrid_batches_bulk_delete:
  path: /pcfcgrid/batches/bulk-delete
  methods: [POST]
  defaults:
    _controller: 'PocketCoffee\PcfcGrid\Controller\Admin\BatchController::bulkDeleteAction'
    _legacy_controller: AdminPcfcgridBatches

pcfcgrid_settings:
  path: /pcfcgrid/settings
  methods: [GET, POST]
  defaults:
    _controller: 'PocketCoffee\PcfcGrid\Controller\Admin\SettingsController::indexAction'
    _legacy_controller: AdminPcfcgridSettings
    _legacy_link: AdminPcfcgridSettings

After prestashop:module install from the console the routes are not served until the cache is cleared (bin/console cache:clear): the first request to the new URL answered "No route found" until then. Installing from the back office clears the cache for you.

The controller

In 9.x the controllers of a module extend PrestaShopAdminController (the older FrameworkBundleAdminController is deprecated and removed in 10.0). Services come in as typed arguments; services that have several implementations are selected with #[Autowire(service: '...')]. Access control is the #[AdminSecurity] attribute, which takes an expression over the tab permissions (read, create, update, delete) and an optional redirectRoute for the denied case. The helpers used here are presentGrid(), render(), trans($id, $parameters, $domain) (the Symfony argument order), addFlash(), addFlashErrors() and redirectToRoute().

<?php

declare(strict_types=1);

namespace PocketCoffee\PcfcGrid\Controller\Admin;

use PocketCoffee\PcfcGrid\Form\BatchNotFoundException;
use PocketCoffee\PcfcGrid\Form\BatchReferenceAlreadyUsedException;
use PocketCoffee\PcfcGrid\Grid\Filters\BatchFilters;
use PocketCoffee\PcfcGrid\Repository\BatchRepository;
use PrestaShop\PrestaShop\Core\Form\IdentifiableObject\Builder\FormBuilderInterface;
use PrestaShop\PrestaShop\Core\Form\IdentifiableObject\Handler\FormHandlerInterface;
use PrestaShop\PrestaShop\Core\Grid\GridFactoryInterface;
use PrestaShopBundle\Controller\Admin\PrestaShopAdminController;
use PrestaShopBundle\Security\Attribute\AdminSecurity;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

/**
 * Back office pages of the roast batches: list, create, edit, toggle and delete.
 */
class BatchController extends PrestaShopAdminController
{
    #[AdminSecurity("is_granted('read', request.get('_legacy_controller'))")]
    public function indexAction(
        Request $request,
        BatchFilters $filters,
        #[Autowire(service: 'pcfcgrid.grid.factory.batch')]
        GridFactoryInterface $gridFactory,
    ): Response {
        return $this->render('@Modules/pcfcgrid/views/templates/admin/index.html.twig', [
            'layoutTitle' => $this->trans('Roast batches', [], 'Modules.Pcfcgrid.Admin'),
            'enableSidebar' => true,
            'batchGrid' => $this->presentGrid($gridFactory->getGrid($filters)),
        ]);
    }

    #[AdminSecurity("is_granted('create', request.get('_legacy_controller'))", redirectRoute: 'pcfcgrid_batches_index')]
    public function createAction(
        Request $request,
        #[Autowire(service: 'pcfcgrid.form.builder.batch')]
        FormBuilderInterface $formBuilder,
        #[Autowire(service: 'pcfcgrid.form.handler.batch')]
        FormHandlerInterface $formHandler,
    ): Response {
        $form = $formBuilder->getForm();
        $form->handleRequest($request);

        try {
            $result = $formHandler->handle($form);
            if ($result->getIdentifiableObjectId()) {
                $this->addFlash('success', $this->trans('Successful creation', [], 'Admin.Notifications.Success'));

                return $this->redirectToRoute('pcfcgrid_batches_index');
            }
        } catch (BatchReferenceAlreadyUsedException $e) {
            $this->addFlash('error', $this->trans('The reference %reference% is already used.', ['%reference%' => $e->getMessage()], 'Modules.Pcfcgrid.Admin'));
        }

        return $this->render('@Modules/pcfcgrid/views/templates/admin/form.html.twig', [
            'layoutTitle' => $this->trans('New roast batch', [], 'Modules.Pcfcgrid.Admin'),
            'batchForm' => $form->createView(),
        ]);
    }

    #[AdminSecurity("is_granted('update', request.get('_legacy_controller'))", redirectRoute: 'pcfcgrid_batches_index')]
    public function editAction(
        int $batchId,
        Request $request,
        #[Autowire(service: 'pcfcgrid.form.builder.batch')]
        FormBuilderInterface $formBuilder,
        #[Autowire(service: 'pcfcgrid.form.handler.batch')]
        FormHandlerInterface $formHandler,
    ): Response {
        try {
            $form = $formBuilder->getFormFor($batchId);
            $form->handleRequest($request);
            $result = $formHandler->handleFor($batchId, $form);
            if ($result->getIdentifiableObjectId()) {
                $this->addFlash('success', $this->trans('Successful update', [], 'Admin.Notifications.Success'));

                return $this->redirectToRoute('pcfcgrid_batches_index');
            }
        } catch (BatchNotFoundException) {
            $this->addFlash('error', $this->trans('The object cannot be loaded (or found).', [], 'Admin.Notifications.Error'));

            return $this->redirectToRoute('pcfcgrid_batches_index');
        } catch (BatchReferenceAlreadyUsedException $e) {
            $this->addFlash('error', $this->trans('The reference %reference% is already used.', ['%reference%' => $e->getMessage()], 'Modules.Pcfcgrid.Admin'));
        }

        return $this->render('@Modules/pcfcgrid/views/templates/admin/form.html.twig', [
            'layoutTitle' => $this->trans('Edit roast batch', [], 'Modules.Pcfcgrid.Admin'),
            'batchForm' => $form->createView(),
        ]);
    }

    #[AdminSecurity("is_granted('update', request.get('_legacy_controller'))", redirectRoute: 'pcfcgrid_batches_index')]
    public function toggleAction(int $batchId, BatchRepository $batches): RedirectResponse
    {
        $batches->toggle($batchId);
        $this->addFlash('success', $this->trans('The status has been successfully updated.', [], 'Admin.Notifications.Success'));

        return $this->redirectToRoute('pcfcgrid_batches_index');
    }

    #[AdminSecurity("is_granted('delete', request.get('_legacy_controller'))", redirectRoute: 'pcfcgrid_batches_index')]
    public function deleteAction(int $batchId, BatchRepository $batches): RedirectResponse
    {
        $batches->delete([$batchId]);
        $this->addFlash('success', $this->trans('Successful deletion', [], 'Admin.Notifications.Success'));

        return $this->redirectToRoute('pcfcgrid_batches_index');
    }

    #[AdminSecurity("is_granted('delete', request.get('_legacy_controller'))", redirectRoute: 'pcfcgrid_batches_index')]
    public function bulkDeleteAction(Request $request, BatchRepository $batches): RedirectResponse
    {
        // The field name is the bulk column id plus the grid id prefix: batches_bulk of pcfcgrid_batch.
        $ids = array_map('intval', $request->request->all('pcfcgrid_batch_batches_bulk'));
        $batches->delete($ids);
        $this->addFlash('success', $this->trans('The selection has been successfully deleted.', [], 'Admin.Notifications.Success'));

        return $this->redirectToRoute('pcfcgrid_batches_index');
    }
}

The templates are short because the core provides the markup. The list includes grid_panel.html.twig and the form templates use the prestashop_ui_kit form theme of the back office.

{% extends '@PrestaShop/Admin/layout.html.twig' %}

{% block content %}
  {{ include('@PrestaShop/Admin/Common/Grid/grid_panel.html.twig', {grid: batchGrid}) }}
{% endblock %}

{% block javascripts %}
  {{ parent() }}
  <script src="{{ asset('themes/default/js/bundle/pagination.js') }}"></script>
  <script src="{{ asset('../modules/pcfcgrid/views/js/grid.js') }}"></script>
{% endblock %}

The grid only becomes interactive when its JavaScript extensions are attached. The back office theme exposes the Grid class and the extensions on window.prestashop.component, so a module can ship a plain script (no bundler) for its page. The extension set below is the one the core uses for the order messages grid; the others (AsyncToggleColumnExtension, PositionExtension, ModalFormSubmitExtension, SubmitGridActionExtension and more) exist in the theme bundle.

// Turns the rendered grid into a working one: sorting, filter reset, row and bulk actions.
// The components are exposed by the back office theme on window.prestashop.
$(() => {
  const grid = new window.prestashop.component.Grid('pcfcgrid_batch');
  const extensions = window.prestashop.component.GridExtensions;

  grid.addExtension(new extensions.ReloadListExtension());
  grid.addExtension(new extensions.FiltersResetExtension());
  grid.addExtension(new extensions.SortingExtension());
  grid.addExtension(new extensions.LinkRowActionExtension());
  grid.addExtension(new extensions.SubmitRowActionExtension());
  grid.addExtension(new extensions.BulkActionCheckboxExtension());
  grid.addExtension(new extensions.SubmitBulkActionExtension());
  grid.addExtension(new extensions.FiltersSubmitButtonEnablerExtension());
});

The create and edit form

A form handled by the identifiable-object machinery has five parts that the core wires together:

sequenceDiagram participant C as BatchController participant FB as FormBuilder participant DP as BatchFormDataProvider participant FH as FormHandler participant DH as BatchFormDataHandler C->>FB: getForm or getFormFor id FB->>DP: getDefaultData or getData id DP-->>FB: array Note over FB: hooks FormDataProviderDefaultData or FormDataProviderData, then FormBuilderModifier FB-->>C: Symfony form C->>C: handleRequest C->>FH: handle form or handleFor id, form Note over FH: hook BeforeCreate or BeforeUpdate, with form_data by reference FH->>DH: create data or update id, data DH-->>FH: id Note over FH: hook AfterCreate or AfterUpdate FH-->>C: FormHandlerResult with the identifiable object id C-->>C: flash message and redirect

The form type is plain Symfony. The only PrestaShop touches are the SwitchType for booleans and the translation_domain option, which lets the labels be written in English and translated with the module domain. The form name, and therefore the hook names, comes from the class: BatchType is batch, which gives actionBatchFormBuilderModifier, actionBeforeCreateBatchFormHandler and actionAfterCreateBatchFormHandler.

<?php

declare(strict_types=1);

namespace PocketCoffee\PcfcGrid\Form;

use PocketCoffee\PcfcGrid\Grid\Definition\BatchGridDefinitionFactory;
use PrestaShopBundle\Form\Admin\Type\SwitchType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\LessThanOrEqual;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Regex;

/**
 * Create and edit form of a roast batch. Labels are translated with the translation domain option.
 */
final class BatchType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('reference', TextType::class, [
                'label' => 'Reference',
                'help' => 'Letters, digits and dashes, for example PC-2026-10-E.',
                'constraints' => [
                    new NotBlank(),
                    new Length(['max' => 32]),
                    new Regex(['pattern' => '/^[A-Za-z0-9-]+$/', 'message' => 'Use letters, digits and dashes only.']),
                ],
            ])
            ->add('origin', ChoiceType::class, [
                'label' => 'Origin',
                'choices' => array_flip(BatchGridDefinitionFactory::ORIGINS),
                'constraints' => [new NotBlank()],
            ])
            ->add('roast_date', DateType::class, [
                'label' => 'Roast date',
                'widget' => 'single_text',
                'input' => 'datetime_immutable',
                'constraints' => [new NotBlank(), new LessThanOrEqual(['value' => 'today', 'message' => 'A batch cannot be roasted in the future.'])],
            ])
            ->add('active', SwitchType::class, [
                'label' => 'Enabled',
                'required' => false,
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['translation_domain' => 'Modules.Pcfcgrid.Admin']);
    }
}

The data provider gives the form its initial array, either defaults for a new object or the stored values for an existing one. The data handler persists the validated array and returns the new id on create.

<?php

declare(strict_types=1);

namespace PocketCoffee\PcfcGrid\Form;

use PocketCoffee\PcfcGrid\Repository\BatchRepository;
use PrestaShop\PrestaShop\Core\Form\IdentifiableObject\DataProvider\FormDataProviderInterface;

/**
 * Fills the form: the stored values when editing, defaults when creating.
 */
final class BatchFormDataProvider implements FormDataProviderInterface
{
    public function __construct(private readonly BatchRepository $batches)
    {
    }

    public function getData($id): array
    {
        $batch = $this->batches->find((int) $id);
        if (null === $batch) {
            throw new BatchNotFoundException(sprintf('Batch %d does not exist.', (int) $id));
        }

        return [
            'reference' => $batch['reference'],
            'origin' => $batch['origin'],
            'roast_date' => new \DateTimeImmutable($batch['roast_date']),
            'active' => $batch['active'],
        ];
    }

    public function getDefaultData(): array
    {
        return [
            'origin' => 'ethiopia',
            'roast_date' => new \DateTimeImmutable('today'),
            'active' => true,
        ];
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\PcfcGrid\Form;

use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use PocketCoffee\PcfcGrid\Repository\BatchRepository;
use PrestaShop\PrestaShop\Core\Form\IdentifiableObject\DataHandler\FormDataHandlerInterface;

/**
 * Saves the validated form data. The form handler calls create() or update() and fires the
 * actionAfterCreateBatchFormHandler and actionAfterUpdateBatchFormHandler hooks afterwards.
 */
final class BatchFormDataHandler implements FormDataHandlerInterface
{
    public function __construct(private readonly BatchRepository $batches)
    {
    }

    public function create(array $data): int
    {
        try {
            return $this->batches->create(
                $data['reference'],
                $data['origin'],
                $data['roast_date']->format('Y-m-d'),
                (bool) $data['active']
            );
        } catch (UniqueConstraintViolationException) {
            throw new BatchReferenceAlreadyUsedException($data['reference']);
        }
    }

    public function update($id, array $data): void
    {
        try {
            $this->batches->update(
                (int) $id,
                $data['reference'],
                $data['origin'],
                $data['roast_date']->format('Y-m-d'),
                (bool) $data['active']
            );
        } catch (UniqueConstraintViolationException) {
            throw new BatchReferenceAlreadyUsedException($data['reference']);
        }
    }
}

The repository used by the handler, the toggle and the deletions is a thin DBAL class; the grid itself never goes through it.

<?php

declare(strict_types=1);

namespace PocketCoffee\PcfcGrid\Repository;

use Doctrine\DBAL\Connection;

/**
 * The only class that talks to the batch table; the grid reads it through the query builder.
 */
final class BatchRepository
{
    public function __construct(
        private readonly Connection $connection,
        private readonly string $dbPrefix,
    ) {
    }

    /**
     * @return array{id_batch: int, reference: string, origin: string, roast_date: string, active: bool}|null
     */
    public function find(int $batchId): ?array
    {
        $row = $this->connection->fetchAssociative(
            'SELECT id_batch, reference, origin, roast_date, active FROM ' . $this->table() . ' WHERE id_batch = :id',
            ['id' => $batchId]
        );

        return $row ? ['id_batch' => (int) $row['id_batch'], 'active' => (bool) $row['active']] + $row : null;
    }

    public function create(string $reference, string $origin, string $roastDate, bool $active): int
    {
        $this->connection->insert($this->table(), [
            'reference' => $reference,
            'origin' => $origin,
            'roast_date' => $roastDate,
            'active' => (int) $active,
        ]);

        return (int) $this->connection->lastInsertId();
    }

    public function update(int $batchId, string $reference, string $origin, string $roastDate, bool $active): void
    {
        $this->connection->update($this->table(), [
            'reference' => $reference,
            'origin' => $origin,
            'roast_date' => $roastDate,
            'active' => (int) $active,
        ], ['id_batch' => $batchId]);
    }

    public function toggle(int $batchId): void
    {
        $this->connection->executeStatement(
            'UPDATE ' . $this->table() . ' SET active = 1 - active WHERE id_batch = :id',
            ['id' => $batchId]
        );
    }

    /**
     * @param int[] $batchIds
     */
    public function delete(array $batchIds): void
    {
        $this->connection->executeStatement(
            'DELETE FROM ' . $this->table() . ' WHERE id_batch IN (:ids)',
            ['ids' => array_map('intval', $batchIds)],
            ['ids' => Connection::PARAM_INT_ARRAY]
        );
    }

    private function table(): string
    {
        return $this->dbPrefix . 'pcfcgrid_batch';
    }
}

What the requests showed:

  • A new batch with the reference bad ref! and a roast date in 2030 re-displayed the form with "Use letters, digits and dashes only." and "A batch cannot be roasted in the future."; nothing was saved.

  • A valid batch (PC-2026-10-E, brazil, 2026-10-27) redirected to the list with the flash "Successful creation".

  • The same reference again showed "The reference PC-2026-10-E is already used." The unique index of the table raised a DBAL UniqueConstraintViolationException, which the data handler converted into a domain exception that the controller maps to a message.

  • The edit page filled the fields with the stored values (PC-2026-10-E, brazil) and saved a change ("Successful update"); an unknown id (/99/edit) redirected to the list with "The object cannot be loaded (or found)."

  • A POST to the toggle route flipped active from 0 to 1 ("The status has been successfully updated."); the delete route answered "Successful deletion"; and the bulk route, posted with the two checked ids of two temporary batches, removed both ("The selection has been successfully deleted.").

  • A GET on the delete route answered 405 with "Method Not Allowed (Allow: POST)": destructive routes must be POST, and the grid sends them through a confirmation modal.

The settings form

A form that is not tied to an object, such as a module configuration page, uses a different, simpler trio: a DataConfigurationInterface that maps form fields to configuration keys and validates, a FormDataProviderInterface that reads and writes through it, and the core Handler class that builds the named form and dispatches two hooks. The controller calls getForm(), handleRequest() and save(); save() returns an array of error messages, empty on success.

<?php

declare(strict_types=1);

namespace PocketCoffee\PcfcGrid\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Range;

/**
 * Settings form: not tied to an object, it reads and writes configuration values.
 */
final class SettingsType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('fresh_days', IntegerType::class, [
                'label' => 'Freshness window (days)',
                'help' => 'How long a batch is announced as fresh after roasting.',
                'constraints' => [new NotBlank(), new Range(['min' => 1, 'max' => 90])],
            ])
            ->add('badge_label', TextType::class, [
                'label' => 'Badge label',
                'constraints' => [new NotBlank(), new Length(['max' => 32])],
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['translation_domain' => 'Modules.Pcfcgrid.Admin']);
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\PcfcGrid\Form;

use PocketCoffee\PcfcGrid\Install\Installer;
use PrestaShop\PrestaShop\Core\Configuration\DataConfigurationInterface;
use PrestaShop\PrestaShop\Core\ConfigurationInterface;

/**
 * Maps the form fields to configuration keys and validates before saving.
 */
final class SettingsConfiguration implements DataConfigurationInterface
{
    public function __construct(private readonly ConfigurationInterface $configuration)
    {
    }

    public function getConfiguration(): array
    {
        return [
            'fresh_days' => (int) $this->configuration->get(Installer::KEY_FRESH_DAYS),
            'badge_label' => (string) $this->configuration->get(Installer::KEY_BADGE_LABEL),
        ];
    }

    public function updateConfiguration(array $configuration): array
    {
        if (!$this->validateConfiguration($configuration)) {
            return ['The settings are incomplete.'];
        }

        $this->configuration->set(Installer::KEY_FRESH_DAYS, (int) $configuration['fresh_days']);
        $this->configuration->set(Installer::KEY_BADGE_LABEL, (string) $configuration['badge_label']);

        return [];
    }

    public function validateConfiguration(array $configuration): bool
    {
        return isset($configuration['fresh_days'], $configuration['badge_label']);
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\PcfcGrid\Form;

use PrestaShop\PrestaShop\Core\Configuration\DataConfigurationInterface;
use PrestaShop\PrestaShop\Core\Form\FormDataProviderInterface;

final class SettingsFormDataProvider implements FormDataProviderInterface
{
    public function __construct(private readonly DataConfigurationInterface $configuration)
    {
    }

    public function getData(): array
    {
        return $this->configuration->getConfiguration();
    }

    public function setData(array $data): array
    {
        return $this->configuration->updateConfiguration($data);
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\PcfcGrid\Controller\Admin;

use PrestaShop\PrestaShop\Core\Form\FormHandlerInterface;
use PrestaShopBundle\Controller\Admin\PrestaShopAdminController;
use PrestaShopBundle\Security\Attribute\AdminSecurity;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class SettingsController extends PrestaShopAdminController
{
    #[AdminSecurity("is_granted('read', request.get('_legacy_controller'))")]
    public function indexAction(
        Request $request,
        #[Autowire(service: 'pcfcgrid.form.settings_handler')]
        FormHandlerInterface $formHandler,
    ): Response {
        $form = $formHandler->getForm();
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $errors = $formHandler->save($form->getData());
            if (empty($errors)) {
                $this->addFlash('success', $this->trans('Successful update', [], 'Admin.Notifications.Success'));

                return $this->redirectToRoute('pcfcgrid_settings');
            }
            $this->addFlashErrors($errors);
        }

        return $this->render('@Modules/pcfcgrid/views/templates/admin/settings.html.twig', [
            'layoutTitle' => $this->trans('Roast settings', [], 'Modules.Pcfcgrid.Admin'),
            'settingsForm' => $form->createView(),
        ]);
    }
}

The service pcfcgrid.form.settings_handler in the file above passes the hook name PcfcgridSettings to Handler, which dispatches actionPcfcgridSettingsForm when the form is built and actionPcfcgridSettingsSave when it is saved, with the errors and the form data by reference. Submitting fresh_days = 0 re-displayed the form with "This value should be between 1 and 90."; submitting 30 saved the key (PCFCGRID_FRESH_DAYS was 30 in ps_configuration afterwards). Settings forms that must work per shop use AbstractMultistoreConfiguration instead of DataConfigurationInterface; the next pages cover Module Configuration with the legacy HelperForm as well.

The two Twig templates of the forms:

{% extends '@PrestaShop/Admin/layout.html.twig' %}

{% block content %}
  {% form_theme batchForm '@PrestaShop/Admin/TwigTemplateForm/prestashop_ui_kit.html.twig' %}
  {{ form_start(batchForm) }}
  <div class="card">
    <h3 class="card-header">
      <i class="material-icons">local_cafe</i> {{ 'Roast batch'|trans({}, 'Modules.Pcfcgrid.Admin') }}
    </h3>
    <div class="card-body">
      <div class="form-wrapper">
        {{ form_errors(batchForm) }}
        {{ form_widget(batchForm) }}
      </div>
    </div>
    <div class="card-footer">
      <a href="{{ path('pcfcgrid_batches_index') }}" class="btn btn-outline-secondary">
        {{ 'Cancel'|trans({}, 'Admin.Actions') }}
      </a>
      <button class="btn btn-primary float-right" id="save-button">
        {{ 'Save'|trans({}, 'Admin.Actions') }}
      </button>
    </div>
  </div>
  {{ form_end(batchForm) }}
{% endblock %}
{% extends '@PrestaShop/Admin/layout.html.twig' %}

{% block content %}
  {% form_theme settingsForm '@PrestaShop/Admin/TwigTemplateForm/prestashop_ui_kit.html.twig' %}
  {{ form_start(settingsForm) }}
  <div class="card">
    <h3 class="card-header">
      <i class="material-icons">settings</i> {{ 'Roast settings'|trans({}, 'Modules.Pcfcgrid.Admin') }}
    </h3>
    <div class="card-body">
      <div class="form-wrapper">
        {{ form_widget(settingsForm) }}
      </div>
    </div>
    <div class="card-footer">
      <button class="btn btn-primary float-right" id="save-button">
        {{ 'Save'|trans({}, 'Admin.Actions') }}
      </button>
    </div>
  </div>
  {{ form_end(settingsForm) }}
{% endblock %}

Extending grids and forms from another module

Every grid and every identifiable-object form of the core, and every one of a module that follows this page, can be extended through hooks. This is the supported alternative to overrides: the extension needs no service and no change to the extended code. The hooks seen in the run, with the parameters each one received (_ps_version, request, route, cookie, cart and altern are added to all of them):

Hook When Useful parameters

action<Grid>GridDefinitionModifier

The definition is built

definition: add, remove and move columns, filters and actions

action<Grid>GridQueryBuilderModifier

Before the queries run

search_query_builder, count_query_builder, search_criteria: add selected fields and joins to both builders

action<Grid>GridDataModifier

After the data is read

data: a GridData by reference

action<Grid>GridPresenterModifier

Before rendering

presented_grid: the array given to the template, by reference

action<Form>FormBuilderModifier

The form is built

form_builder, data, options, id

action<Form>FormDataProviderDefaultData, action<Form>FormDataProviderData

The initial data is computed (default for a new object, stored for an existing one)

data, options, and id for the second

actionBefore<Form>FormHandler / actionAfter<Form>FormHandler, with Create or Update

Around persistence

form_data by reference (before) and id

The extension module registered ten of these hooks and wrote a line to the shop log from each. Opening the list triggered the definition, query-builder, data and presenter hooks once each; opening and submitting the form triggered the default data, form builder, before-create and after-create hooks; and the settings page triggered the form hook on GET and on POST plus the save hook.

<?php

/**
 * Extends the grid and the forms of pcfcgrid through hooks only: no service, no override.
 */
if (!defined('_PS_VERSION_')) {
    exit;
}

use PrestaShop\PrestaShop\Core\Grid\Definition\GridDefinitionInterface;

class Pcfcgridext extends Module
{
    private const HOOKS = [
        'actionPcfcgridBatchGridDefinitionModifier',
        'actionPcfcgridBatchGridQueryBuilderModifier',
        'actionPcfcgridBatchGridDataModifier',
        'actionPcfcgridBatchGridPresenterModifier',
        'actionBatchFormBuilderModifier',
        'actionBatchFormDataProviderDefaultData',
        'actionBeforeCreateBatchFormHandler',
        'actionAfterCreateBatchFormHandler',
        'actionPcfcgridSettingsForm',
        'actionPcfcgridSettingsSave',
    ];

    public function __construct()
    {
        $this->name = 'pcfcgridext';
        $this->tab = 'administration';
        $this->version = '1.0.0';
        $this->author = 'Pocket Coffee';
        $this->need_instance = 0;
        $this->dependencies = ['pcfcgrid'];
        $this->ps_versions_compliancy = ['min' => '9.0.0', 'max' => '9.99.99'];

        parent::__construct();

        $this->displayName = 'Pocket Coffee grid extension';
        $this->description = 'Hides a column and normalises references through grid and form hooks.';
    }

    public function install(): bool
    {
        return parent::install() && $this->registerHook(self::HOOKS);
    }

    /** Remove the origin column and its filter: both must go, or the filter form keeps a dangling field. */
    public function hookActionPcfcgridBatchGridDefinitionModifier(array $params): void
    {
        /** @var GridDefinitionInterface $definition */
        $definition = $params['definition'];
        $definition->getColumns()->remove('origin');
        $definition->getFilters()->remove('origin');
        $this->note('definition modified');
    }

    public function hookActionPcfcgridBatchGridQueryBuilderModifier(array $params): void
    {
        $this->note('query builders: ' . implode(',', array_keys($params)));
    }

    public function hookActionPcfcgridBatchGridDataModifier(array $params): void
    {
        $this->note('data: ' . count($params['data']->getRecords()) . ' records');
    }

    public function hookActionPcfcgridBatchGridPresenterModifier(array $params): void
    {
        $this->note('presenter: ' . implode(',', array_keys($params)));
    }

    public function hookActionBatchFormBuilderModifier(array $params): void
    {
        $this->note('form builder: ' . implode(',', array_keys($params)));
    }

    public function hookActionBatchFormDataProviderDefaultData(array $params): void
    {
        $this->note('default data: ' . implode(',', array_keys($params)));
    }

    /** The data is passed by reference, so the change reaches the data handler. */
    public function hookActionBeforeCreateBatchFormHandler(array $params): void
    {
        $params['form_data']['reference'] = strtoupper($params['form_data']['reference']);
        $this->note('before create');
    }

    public function hookActionAfterCreateBatchFormHandler(array $params): void
    {
        $this->note('after create, id ' . $params['id']);
    }

    public function hookActionPcfcgridSettingsForm(array $params): void
    {
        $this->note('settings form: ' . implode(',', array_keys($params)));
    }

    public function hookActionPcfcgridSettingsSave(array $params): void
    {
        $this->note('settings save: ' . implode(',', array_keys($params)));
    }

    private function note(string $message): void
    {
        PrestaShopLogger::addLog('pcfcgridext ' . $message, PrestaShopLogger::LOG_SEVERITY_LEVEL_INFORMATIVE, null, 'Module', null, true);
    }
}

The effect was visible in the pages. With the extension installed the header of the list was "ID, Reference, Roast date, Enabled, Actions": the column and its filter were gone. Creating a batch with the reference pc-low-1 stored PC-LOW-1, because the before-create hook changed form_data by reference before the data handler ran. Hooks of grids built by the 9.2 Extra Properties feature are also dispatched through this mechanism: AbstractGridDefinitionFactory applies an ExtraPropertiesGridDefinitionModifier to every grid.

Pitfalls

  • Hook names depend on the grid id and the form name. Both are derived by camel-casing. A renamed grid id silently detaches every extension.

  • Remove the filter with the column. The definition hook removes a column; the filter and the SQL field stay unless they are removed or selected.

  • The queries must select what the columns name. An unmapped field renders an empty cell and no error.

  • Filters and sorting are saved per employee. A grid that suddenly shows a subset or a different order is usually a saved filter; reset it from the grid or delete the ps_admin_filter row.

  • Do not make BatchFilters a service. It is created by the argument resolver for each request.

  • Use POST for every state change and let the grid confirm destructive ones. The core uses SubmitRowAction with a modal and _token checks on all back office URLs.

  • Create and edit share one handler. The same form type and data handler serve both, so validation rules that differ (a unique reference that must ignore the current record) need the id.

  • Do not extend CommonAbstractType. It is deprecated since 9.0; extend Symfony’s AbstractType, or TranslatorAwareType when the form needs the translator and the language list.

  • Hooks registered in advance stay in ps_hook. An extension that registers on a grid or form hook that does not exist yet creates the row, and uninstalling the extension does not delete it; ten such rows were left by the extension module and removed by hand after the test.

  • Run cache:clear after changing services.yml or routes.yml in a development shop. In production the module manager does it on install, enable and upgrade.

What was and was not run

Run on the local PrestaShop 9.2.0 Docker shop: the install of pcfcgrid and pcfcgridext; authenticated requests to the list (filters, saved filters, reset, sorting, pagination), the create, edit, toggle, delete and bulk-delete routes, and the settings page including invalid input; the ten hooks of the extension module; the uninstall of both modules, which left no table, tab, role, configuration key or hook registration (and, after the fix described above, no saved filter); lint:twig, lint:yaml and the legacy-link and security-attribute linters; and PHP CS Fixer with the PrestaShop standard (four files received trailing commas, then no changes remained). Not run: the JavaScript behaviour in a browser (the grid extensions, the confirmation modal and the date picker were not exercised; the requests simulated what they submit), multistore, the translation of the labels into another language, PHPStan, and export of the grid to CSV or SQL Manager.