Hooks
|
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, PHP 8.1 to 8.5, Hummingbird 2.1.2 as the default theme) and checked on 2026-10-27. Hooks are part of the open-source core, so there is no plan or edition that changes them. A hook is a named point in the code of the core, of a theme or of a module where other modules may add content or react to an event. It is the main extension mechanism of PrestaShop: nearly every module is a set of hook callbacks around a few classes of its own. The page follows the learning path after Module Basics: it explains how a module subscribes to a hook, what the dispatcher does with each subscriber, how to define and dispatch your own hooks, and how the hook system interacts with Smarty, Twig and Symfony.
The running example is Pocket Coffee. The module pcfchooks shows the "roast date" badge on the product page through the
theme hook displayProductAdditionalInfo, logs "order placed" with the order number and the total through
actionValidateOrder, and defines two hooks of its own so that other modules can extend the badge. A second module,
pcfchooksext, subscribes to those hooks, and a third one, pcfchookswid, answers them through the widget interface.
All three were installed on a real 9.2.0 shop and every output quoted below was produced there.
Two kinds of hooks
Hook names start with a prefix that tells what the hook is for.
| Prefix | Meaning | Callback result |
|---|---|---|
|
The hook renders content at a place in a page (front office or back office). Subscribers return HTML, usually from a template. |
A string. The dispatcher concatenates the strings of all subscribers in position order. An array is ignored and, in debug mode, reported as a notice. |
|
The hook announces an event ("an order was validated", "a configuration value is about to change"). Subscribers react, and may change the data they receive when it is passed by reference. |
Ignored in general. A few action hooks are dispatched as chain hooks and use the return value. |
The rule is implemented by Hook::isDisplayHookName(): a name is a display hook when it starts with display, with
three exceptions in one direction and one in the other. dashboardData, dashboardZoneOne and dashboardZoneTwo are
display hooks although they do not start with the prefix, and displayHeader is not treated as a display hook
although it does (nor is header, which has no prefix anyway).
This matters in the back office, where the access check on display hooks (below) does not apply to displayHeader. The
run confirmed it: displayHome, dashboardData and the custom displayPcfcRoastBadge returned true, while
actionValidateOrder, displayHeader and header returned false.
A fresh 9.2.0 database has 1251 rows in ps_hook (1069 names starting with action and 151 with display), and the
official list of hooks has about 715 documented pages, so not every hook is documented. Each page of that list shows the
file where the hook is dispatched, whether it belongs to the core, to a theme or to a module, its aliases, and whether it
is meant to be used with array_return, check_exceptions or chain. Hooks defined in the core are loaded from
install-dev/data/xml/hook.xml during installation and from the upgrade SQL files when a shop is updated.
Source: PrestaShop devdocs: Hooks
Registering a hook
A module subscribes in two steps: it registers the hook (a row in ps_hook_module) and it declares a public method named
hook followed by the hook name with its first letter in upper case. Registration is normally done in install(). Both
steps are needed; the first without the second is a mistake that the platform reports as described below.
<?php
/**
* Pocket Coffee hooks: a roast date badge, an order logger and a custom hook.
*/
if (!defined('_PS_VERSION_')) {
exit;
}
class Pcfchooks extends Module
{
/** Hooks this module listens to. */
private const SUBSCRIBED_HOOKS = [
'displayProductAdditionalInfo',
'actionFrontControllerSetMedia',
'actionValidateOrder',
'displayPcfcRoastBadge',
];
/** Hooks this module defines and dispatches itself: name => [title, description]. */
private const OWN_HOOKS = [
'displayPcfcRoastBadge' => [
'Roast badge',
'Content shown next to the roast date on the product page.',
],
'actionPcfcFilterRoastLabel' => [
'Filter the roast label',
'Lets modules change the label of the roast badge. Modules receive the previous result.',
],
];
public function __construct()
{
$this->name = 'pcfchooks';
$this->tab = 'front_office_features';
$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 hooks', [], 'Modules.Pcfchooks.Admin');
$this->description = $this->trans('Roast date badge, order logger and custom hooks.', [], 'Modules.Pcfchooks.Admin');
}
public function isUsingNewTranslationSystem(): bool
{
return true;
}
public function install(): bool
{
return parent::install()
&& $this->createOwnHooks()
&& $this->registerHook(self::SUBSCRIBED_HOOKS)
&& Configuration::updateValue('PCFCHOOKS_ROAST_DATE', '2026-10-26');
}
public function uninstall(): bool
{
return parent::uninstall()
&& $this->deleteOwnHooks()
&& Configuration::deleteByName('PCFCHOOKS_ROAST_DATE');
}
/**
* Declares the hooks of this module, with a title and a description for the Positions page.
* registerHook() alone would also create them, but with the name as title and no description.
*/
private function createOwnHooks(): bool
{
foreach (self::OWN_HOOKS as $name => [$title, $description]) {
if (Hook::getIdByName($name, false)) {
continue;
}
$hook = new Hook();
$hook->name = $name;
$hook->title = $title;
$hook->description = $description;
$hook->position = true;
if (!$hook->add()) {
return false;
}
}
return true;
}
/**
* A hook created by a module stays in ps_hook after the module is gone, so remove it explicitly.
*/
private function deleteOwnHooks(): bool
{
foreach (array_keys(self::OWN_HOOKS) as $name) {
$id = Hook::getIdByName($name, false);
if ($id && !(new Hook((int) $id))->delete()) {
return false;
}
}
return true;
}
public function hookActionFrontControllerSetMedia(array $params): void
{
$this->context->controller->registerStylesheet(
'module-pcfchooks-badge',
'modules/' . $this->name . '/views/css/badge.css',
['media' => 'all', 'priority' => 150]
);
}
/**
* Display hook of the theme: the product page asks every subscriber for HTML.
*/
public function hookDisplayProductAdditionalInfo(array $params): string
{
// $params['product'] is a ProductLazyArray, which can be read like an array.
$idProduct = (int) $params['product']['id_product'];
// Chain hook: every subscriber receives the array returned by the previous one.
$filtered = Hook::exec(
'actionPcfcFilterRoastLabel',
['label' => $this->trans('Roasted on', [], 'Modules.Pcfchooks.Shop'), 'id_product' => $idProduct],
null,
false,
true,
false,
null,
true
);
$label = is_array($filtered) && isset($filtered['label']) ? $filtered['label'] : '';
// Own display hook: concatenated HTML of every module registered on displayPcfcRoastBadge.
$extra = Hook::exec('displayPcfcRoastBadge', ['id_product' => $idProduct]);
$this->context->smarty->assign([
'pcfc_label' => $label,
'pcfc_roast_date' => Configuration::get('PCFCHOOKS_ROAST_DATE'),
'pcfc_extra' => $extra,
]);
return $this->fetch('module:' . $this->name . '/views/templates/hook/badge.tpl');
}
/**
* Default content of the custom hook, so the page works without any extension module.
*/
public function hookDisplayPcfcRoastBadge(array $params): string
{
return '<span class="pcfchooks-badge__fresh">' . $this->trans('Small batch', [], 'Modules.Pcfchooks.Shop') . '</span>';
}
/**
* Action hook: the order was created. The return value is ignored.
*/
public function hookActionValidateOrder(array $params): void
{
/** @var Order $order */
$order = $params['order'];
PrestaShopLogger::addLog(
sprintf('Pocket Coffee order %s placed, total %s', $order->reference, Tools::ps_round((float) $order->total_paid_tax_incl, 2)),
PrestaShopLogger::LOG_SEVERITY_LEVEL_INFORMATIVE,
null,
'Order',
(int) $order->id,
true
);
}
}
The template of the badge is a Smarty file. nofilter is used on pcfc_extra because that variable holds HTML produced
by other modules; everything else is escaped.
<div class="pcfchooks-badge">
<span class="pcfchooks-badge__label">{$pcfc_label|escape:'html':'UTF-8'}</span>
<time datetime="{$pcfc_roast_date|escape:'html':'UTF-8'}">{$pcfc_roast_date|escape:'html':'UTF-8'}</time>
{$pcfc_extra nofilter}
</div>
The facts about registration that the example relies on, and the ones that surprised during the run:
-
registerHook()takes a string or an array of names (the second form is the shortcut for several hooks in one call) and an optional list of shop ids. By default the registration is valid for all shops, and the module is appended at the end of the hook: after install the four rows ofps_hook_moduleforpcfchookshad the positions 10, 11, 5 and 1 on their four hooks, each one meaning "last at the time". -
A name that does not exist in
ps_hookis created byregisterHook(), with the name as its title. That is convenient for custom hooks and dangerous for typos: the row stays after the module is uninstalled. A throw-away module that registerednonExistingHookNameleft a permanent row inps_hookand had to be cleaned by hand. Theuninstall()ofpcfchooksdeletes the hooks it defined, and nothing was left after uninstalling the three modules. -
When the module has no callback for the registered name, PrestaShop checks
_PS_MODE_DEV_: in debug mode it throws aPrestaShopModuleException("Hook with the name … has been registered by …, but the corresponding method … has not been defined in the Module class"), otherwise it only logs a warning. A PHP script that booted the shop in debug mode got the exception. The same registration done bybin/console prestashop:module installpassed silently and created the hook, so a missing callback can go unnoticed in a console install; write the method first. The check is skipped for modules that implementWidgetInterface. -
The callback is public, not static, and receives one argument, the array of parameters. The name is
hookplus the hook name with its first letter upper-cased:hookDisplayProductAdditionalInfo,hookActionValidateOrder. Hook names may contain letters, digits,_and-(Validate::isHookName()). -
A hook added in a later version of a module must be registered again in the upgrade script, because
install()does not run on upgrade (see Module Basics). The 9.0.2 fix ofactionAttributeCombinationSaveis an example of why this matters: a hook that is never dispatched never calls anyone.
What the dispatcher does
Hook::exec() is the function that every legacy hook call goes through. Its signature in 9.2.0 is:
public static function exec(
$hook_name,
$hook_args = [],
$id_module = null,
$array_return = false,
$check_exceptions = true,
$use_push = false,
$id_shop = null,
$chain = false
)
| Parameter | Default | Meaning |
|---|---|---|
|
required |
The hook name, validated against |
|
|
The parameters passed to every subscriber. The dispatcher adds |
|
|
When set, only that module is notified, provided it is registered on the hook. |
|
|
|
|
|
|
|
|
Deprecated; passing |
|
|
Runs the dispatch in the context of another shop. |
|
|
|
The flow inside exec() explains every behaviour in the table.
A few consequences are worth stating plainly.
-
A hook with no subscriber costs one cached lookup. The list of registrations is cached per hook name (the cache key starts with
hook_module_exec_list_), which is why changingps_hook_moduleby SQL needs a cache flush to show up. Use the module API or the back office, which clean the cache for you. -
Return value of unknown and silent hooks. A hook that is not in
ps_hookor is disabled returnsnull(an empty array with$array_return); a known hook without subscribers returns an empty string. In the run a chain dispatch of an unknown hook returnedNULL, and$array_returnon the same name returnedarray(0) {}. -
Order is the position. Subscribers run in the order of
ps_hook_module.position. Moving a module is a back office operation (Design, Positions) orModule::updatePosition($idHook, $way). -
Aliases are deprecated. The table
ps_hook_aliasholds 86 old names (newOrderforactionValidateOrder,homefordisplayHome,productActionsanddisplayProductButtonsfordisplayProductAdditionalInfo). The dispatcher still calls a module registered on an alias, and raisesE_USER_DEPRECATEDtelling the author which name to use instead. -
Debug mode checks the return type. A display hook that returns an array triggers an
E_USER_NOTICEin debug mode and the value is dropped.
Running the hooks
After installing the three modules, the product page of the shop contained the badge produced by all of them. The label
was upper-cased by the chain hook of pcfchooksext, and the two spans after the date came from the custom display hook:
<div class="pcfchooks-badge">
<span class="pcfchooks-badge__label">ROASTED ON</span>
<time datetime="2026-10-26">2026-10-26</time>
<span class="pcfchooks-badge__fresh">Small batch</span><span class="pcfchooksext-note">Best between 7 and 21 days after roasting</span>
</div>
The same dispatch was repeated from a PHP script with the options of exec(), to see each option act:
<?php
require '/var/www/html/config/config.inc.php';
// concatenated HTML of every module registered on the hook
var_dump(Hook::exec('displayPcfcRoastBadge', ['id_product' => 1]));
// array indexed by module name
print_r(Hook::exec('displayPcfcRoastBadge', ['id_product' => 1], null, true));
// only one module
var_dump(Hook::exec('displayPcfcRoastBadge', ['id_product' => 1], Module::getModuleIdByName('pcfchooksext')));
// chain: each subscriber receives the array returned by the previous one
print_r(Hook::exec('actionPcfcFilterRoastLabel', ['label' => 'Roasted on', 'id_product' => 1], null, false, true, false, null, true));
The results were: the concatenation of the two spans; an array with the keys pcfchooks and pcfchooksext; only the
pcfchooksext span; and, for the chain, [label => ROASTED ON, id_product => 1, cart => '', altern => 1]. The last one shows two
details of chains: the dispatcher adds its own keys (altern, and cart when the cart is null), so read the keys you
need instead of comparing the whole array, and a chain subscriber must return the array it received, changed or not,
or the next subscriber gets the wrong input.
The extension module is the other half of the contract. It registers on the hooks that pcfchooks defined and answers
them:
<?php
/**
* Extension of pcfchooks: adds a note to the roast badge and rewrites its label.
*/
if (!defined('_PS_VERSION_')) {
exit;
}
class Pcfchooksext extends Module
{
public function __construct()
{
$this->name = 'pcfchooksext';
$this->tab = 'front_office_features';
$this->version = '1.0.0';
$this->author = 'Pocket Coffee';
$this->need_instance = 0;
$this->dependencies = ['pcfchooks'];
$this->ps_versions_compliancy = ['min' => '9.0.0', 'max' => '9.99.99'];
parent::__construct();
$this->displayName = 'Pocket Coffee hooks extension';
$this->description = 'Subscribes to the custom hooks of pcfchooks.';
}
public function install(): bool
{
return parent::install()
&& $this->registerHook(['displayPcfcRoastBadge', 'actionPcfcFilterRoastLabel']);
}
public function hookDisplayPcfcRoastBadge(array $params): string
{
return '<span class="pcfchooksext-note">Best between 7 and 21 days after roasting</span>';
}
public function hookActionPcfcFilterRoastLabel(array $params): array
{
$params['label'] = strtoupper($params['label']);
return $params;
}
}
$this->dependencies = ['pcfchooks'] makes the installer refuse the extension until the base module is installed. It
does not stop an administrator from uninstalling the base module afterwards, so extension code should tolerate the
custom hooks disappearing (a dispatch of a hook that no longer exists returns null, as seen above).
Custom hooks
Any module can define a hook; no registration step is needed to dispatch one, because Hook::exec() with an unknown name
simply returns null. Three decisions make a custom hook usable by other developers.
-
Name. Hook names are global and shared with the core, themes and every other module. Use the right prefix (
displayfor content,actionfor events), a vendor or module prefix (Pcfchere) and a precise meaning. The core can add a hook with the same name in a later version. -
Definition. Create the row with the
Hookobject (name,title,description,position) so that the title and the description appear on the Positions page;registerHook()would create it with the name as title and an empty description.positionset totruemakes the hook appear in the list of positions where a merchant can transplant modules. -
Contract. Document the parameters, whether the hook is a chain or collects arrays, and what the subscriber must return. The example documents the chain in the hook description. The dispatching module owns the hook: it should delete the row on uninstall, as
deleteOwnHooks()does.
The hook can be dispatched from a Smarty template, with a module filter when only one subscriber is wanted (both were run against the shop):
{hook h='displayPcfcRoastBadge' id_product=$product.id_product}
{hook h='displayPcfcRoastBadge' mod='pcfchooksext' id_product=$product.id_product}
The first call returned the concatenation of the two spans; the second, restricted with mod, returned only the span of
pcfchooksext. Extra attributes of the tag (id_product) become keys of the $params array of the callbacks.
Positions, exceptions and status
Three tables decide whether and when a registered module is called.
| Table | What it controls |
|---|---|
|
Which modules are subscribed on which hook, per shop, and in which order. |
|
Controllers on which a module must not run for a hook. The controller name is compared with the current controller ( |
|
The hook itself. |
Each of them was exercised:
-
$module->updatePosition($idHook, 0)movedpcfchooksextbeforepcfchooksondisplayPcfcRoastBadge(array_keys(Hook::exec(..., true))went frompcfchooks, pcfchooksexttopcfchooksext, pcfchooks), andupdatePosition($idHook, 1)moved it back. -
UPDATE ps_hook SET active = 0madeHook::getHookStatusByName()returnfalseandHook::exec()returnNULL; setting it back to1restored the output. In the core, the switch is the CQRS commandUpdateHookStatusCommand. -
$module->registerExceptions($idHook, ['product'], [1])removedpcfchooksextfrom the result when the current controller wasproduct; callingHook::exec()with$check_exceptions = falsebrought it back. Remember thatcheck_exceptions = falsealso bypasses the permission check of the back office for display hooks, so it is not a harmless switch there.
In the back office these operations are on Design, Positions: transplant a module on a hook, drag it to reorder, set
exceptions per controller. In 9.2 the page "Hook a module" is one of the migrated pages behind a feature flag
(hook_module_v2, enabled by default in the 9.2.0 image); a module that changes this page through grid or form hooks
should be tested against the migrated version.
Widgets: a hook without a callback
A module that implements PrestaShop\PrestaShop\Core\Module\WidgetInterface can answer display hooks without declaring a
callback for each of them. The interface has two methods, renderWidget($hookName, array $configuration) and
getWidgetVariables($hookName, array $configuration). When a display hook is dispatched and the module is registered on
it but has no hookXxx() method, the dispatcher calls renderWidget() instead. A theme can also call the widget
directly with {widget name='pcfchookswid'}, in which case $hookName is null.
<?php
if (!defined('_PS_VERSION_')) {
exit;
}
use PrestaShop\PrestaShop\Core\Module\WidgetInterface;
class Pcfchookswid extends Module implements WidgetInterface
{
public function __construct()
{
$this->name = 'pcfchookswid';
$this->tab = 'front_office_features';
$this->version = '1.0.0';
$this->author = 'Pocket Coffee';
$this->need_instance = 0;
$this->dependencies = ['pcfchooks'];
$this->ps_versions_compliancy = ['min' => '9.0.0', 'max' => '9.99.99'];
parent::__construct();
$this->displayName = 'Pocket Coffee roast widget';
$this->description = 'Widget fallback for the custom hook.';
}
public function install(): bool
{
// No hookDisplayPcfcRoastBadge() method: the WidgetInterface fallback answers instead.
return parent::install() && $this->registerHook('displayPcfcRoastBadge');
}
public function renderWidget($hookName, array $configuration): string
{
$this->smarty->assign($this->getWidgetVariables($hookName, $configuration));
return $this->fetch('module:' . $this->name . '/views/templates/hook/widget.tpl');
}
public function getWidgetVariables($hookName, array $configuration): array
{
return [
'pcfcw_hook' => $hookName ?? 'direct call',
'pcfcw_origin' => 'Colombia Huila',
];
}
}
Source: PrestaShop devdocs: Widgets
With this module installed, Hook::exec('displayPcfcRoastBadge', [], null, true) returned a third key, pcfchookswid,
holding the span rendered by the widget with data-hook="displayPcfcRoastBadge", and Hook::coreRenderWidget() called
directly gave the same span with data-hook="direct call". The widget fallback only applies to display hooks, and
coreRenderWidget() returns null for a module that is not enabled and an empty string if the widget throws while debug
mode is off.
Hooks in templates, controllers and Symfony
Hooks are dispatched from five places. The first two are legacy and the rest are modern; the bridge between them is what lets one module serve both.
| Where | How |
|---|---|
PHP, legacy code |
|
Smarty template |
|
Twig template |
|
Symfony controller or service |
The |
Symfony controller base class |
|
Two documentation details were wrong when checked. The Twig function is registered in lower case: renderhook. A template
that calls renderHook (the spelling used on one page of the developer documentation) fails bin/console lint:twig
with Unknown "renderHook" function. Did you mean "renderhook"?, while the lower-case spelling reports "All 1 Twig files
contain valid syntax". And the base controller of 9.x has dispatchHookWithParameters, not dispatchHook.
{# in a back office template: the same hook, rendered inside a migrated page #}
<div class="roast-badge">
{{ renderhook('displayPcfcRoastBadge', {'id_product': 1}) }}
</div>
The Symfony bridge
On migrated back office pages the project wants to replace hooks with the Symfony event dispatcher in the long term, but
both systems coexist, and a module must be notified wherever the hook is dispatched from. Two services do this.
HookDispatcher wraps the Symfony event dispatcher. LegacyHookSubscriber asks Hook::getHooks() for every hook and
Hook::getHookModuleExecList() for its subscribers, and registers one Symfony listener per subscriber, named
call_<id_hook>_<id_module> and prioritised by position. When such a listener fires, its __call() runs
Hook::exec($hookName, $params, $moduleId, $isRendering).
The bridge was run from a PHP script that booted the AdminKernel and called the dispatcher, with an employee set in the
legacy context. dispatchRenderingWithParameters('displayPcfcRoastBadge', ...) returned the content of the three modules
indexed by name, and outputContent() returned them concatenated. Two limits were visible.
-
The subscriptions are built from the context at the moment the listeners are collected. When the legacy context has no shop or no employee (a CLI script, a cache warm-up)
getSubscribedEvents()returns nothing, and the first attempt of the script, without an employee, rendered an empty result. -
The Symfony dispatcher does not know about chains. Dispatching
actionPcfcFilterRoastLabelthroughdispatchWithParameters()called the chain subscriber with$chainoff, the subscriber returned an array, and in debug modeHook::exec()raised "Module pcfchooksext returned an array on hook actionPcfcFilterRoastLabel. This is not allowed". Chain hooks must be dispatched withHook::exec(..., $chain = true).
Modern pages add a family of hooks of their own, named after the grid or the form they modify
(Grid and Forms shows them in use). They are dispatched through the same
HookDispatcher, which is why a module that only uses registerHook() can extend grids and forms.
What changed in 9.x
-
Removed in 9.0. The legacy product page was removed together with 18 hooks, the
actionAdmin*andactionAdminProducts*hooks for activate, deactivate, delete, duplicate and sort, before and after, andajaxDiewith its related hooks. Back office login moved to Symfony security, and seven hooks of the old login controller are gone (actionAdminLoginControllerBefore,...LoginBefore,...LoginAfter,...ForgotBefore,...ForgotAfter,...ResetBefore,...ResetAfter), whileactionAdminLoginControllerSetMediaanddisplayAdminLoginstay. New:actionBackOfficeLoginFormandactionEmployeeRequestPasswordResetForm, which receive the form builder. -
Kept in 9.0. The dispatcher hooks
actionDispatcher,actionDispatcherBeforeandactionDispatcherAftersurvive the new Symfony layout, as does the legacyAdminControllerworkflow notion in the pages that are still legacy. -
9.0.1 (2025-10-13). New:
actionOrderHasBeenShipped,actionOrderHasBeenDelivered,actionPaymentModuleProductVarTplAfter,actionGetPdfTemplateObjectandactionCheckoutStepRenderTemplate. -
9.0.2 (2025-12-10). New stock hooks
actionOverrideQuantityAvailableByProduct,actionCheckAttributeQuantityandactionOverrideProductQuantity.actionAttributeCombinationSavehad never been dispatched in 9.0.0 and works again. -
9.1.0 (2026-03-23). New lifecycle hooks
actionModuleEnable,actionModuleDisableandactionModuleUpgradeAfter, andactionConfigurationUpdateValueBefore, fired beforeConfiguration::updateValue(). Hummingbird removeddisplaySearch, addeddisplayOrderDetail(replacing the variable hook$HOOK_DISPLAYORDERDETAIL) anddisplayModalContent. -
9.2.0 (2026-09-30). New
actionCheckoutBuildProcessfor the One Page Checkout (the moduleps_onepagecheckoutis installed but the flag is off by default), and structured data provided throughactionFrontControllerSetVariables. Presenters receivedactionPresentCategory,actionPresentStoreandactionPresentManufacturerin the 9.0 line. -
Behaviour. Hook aliases raise
E_USER_DEPRECATED,Hook::exec()reports its$use_pushargument as deprecated, and the productcover_image_idoverride was removed, so the standard presenter hook is the way to change the cover.
Modules that must support 8.x and 9.x keep hooks in the intersection and check for the removed ones. A quick audit of an
older module is to list its registerHook() calls and compare them with the table of removed hooks in the 9.0
changelog.
Pitfalls
-
Returning an array from a display hook, or echoing instead of returning, loses the output.
-
Doing heavy work in a hook that runs on every page (
displayHeader,actionDispatcherBefore) slows the whole shop; restrict by controller (Tools::getValue('controller'),$this->context->controller) or register exceptions. -
Loading assets: use
actionFrontControllerSetMediaandregisterStylesheet()/registerJavascript()in the front office, andactionAdminControllerSetMediain the back office; on the back office configuration page of the module only, testTools::getValue('configure') === $this->name. -
A hook callback that throws brings the whole page down; in a display hook catch and log instead.
-
Hook names are shared: a module that registers on
displayFooterand removes output from other modules breaks them. -
Position is not stable across modules: never rely on running before or after a module you do not control.
-
A chain hook needs every subscriber to return the array; a
voidcallback breaks the chain.
What was and was not run
Run on the local PrestaShop 9.2.0 Docker shop (PHP 8.5.12, MySQL 8.4): installation of pcfchooks, pcfchooksext and
pcfchookswid; the Hummingbird product page with the badge; Hook::exec() with the default options, $array_return,
$id_module and $chain; the custom hooks, updatePosition(), the active flag and hook exceptions; the widget
fallback; Smarty {hook} with and without mod; lint:twig for renderhook and renderHook; the Symfony
bridge through the AdminKernel; actionValidateOrder with the existing demo order (the log entry "Pocket Coffee order
… placed, total 68.2" was written to ps_log and removed afterwards); uninstall of the three modules, which left no hook
row, registration or configuration key; PHP CS Fixer with the PrestaShop standard (no changes needed). Not run: a real
checkout that places an order (the hook was dispatched with an order that already existed, and with $id_module set because
the other modules registered on that hook fail when called outside a request), the Positions page of the back office, and
multistore dispatch with $id_shop.