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
A Grid is assembled from four independent pieces, and each one can be replaced or decorated.
| Piece | Responsibility |
|---|---|
Definition ( |
The structure of the grid: its id, name, columns, filters, grid actions and bulk actions. The base class |
Data ( |
Returns the records and the total for a set of search criteria. |
Search criteria ( |
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 ( |
Combines the definition, the data and the filter form into a |
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 |
|---|---|
|
The checkbox column. Option |
|
A value from the record. Option |
|
A date. Options |
|
A boolean icon that posts to a route. Options |
|
Holds the row actions. Option |
|
Special renderings. |
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 ascommon_refresh_listandcommon_show_queryused here),LinkGridAction(a link to a route, used for "Add new batch") andSubmitGridAction. -
Row actions are in the action column (above).
-
Bulk actions act on the checked rows:
SubmitBulkActionposts the checked ids to a route;buildBulkDeleteAction()ofBulkDeleteActionTraitadds 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 |
1 row (PC-2026-10-B), and the same single row on the next plain GET, because the filter was saved |
filter |
1 row (PC-2026-10-D) |
filter |
1 row (PC-2026-10-C) |
|
4 rows, ordered 1, 2, 3, 4 |
|
2 rows (batches 2 and 1) |
POST to |
The saved filters go back to the defaults: |
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
Source: PrestaShop devdocs: Services
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:
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']);
}
}
}
Source: PrestaShop devdocs: CRUD forms
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
activefrom 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 |
|---|---|---|
|
The definition is built |
|
|
Before the queries run |
|
|
After the data is read |
|
|
Before rendering |
|
|
The form is built |
|
|
The initial data is computed (default for a new object, stored for an existing one) |
|
|
Around persistence |
|
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);
}
}
Source: PrestaShop devdocs: Hooks
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
fieldstay unless they are removed or selected. -
The queries must select what the columns name. An unmapped
fieldrenders 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_filterrow. -
Do not make
BatchFiltersa 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
SubmitRowActionwith a modal and_tokenchecks 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’sAbstractType, orTranslatorAwareTypewhen 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:clearafter changingservices.ymlorroutes.ymlin 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.