We ship 151 PrestaShop modules at mypresta.rocks, and the single most common support ticket we get from other developers reading our code is some variant of "my hook isn't firing." It's never the method. It's almost always one of four things: registered on the wrong hook, never registered, registered twice, or registered correctly but called from a context the developer didn't expect. This is the field guide we wrote for ourselves over the years.
For the full hook dictionary, override trade-offs, and PS 8/9 migration notes, the PrestaShop Hooks & Overrides reference is the longer reference page. This blog post is the practical companion — fewer tables, more code we actually use in production, and the specific traps we've fallen into ourselves.
Start with the job, not the hook name
Pick the hook by working backwards from the behaviour you need. If your module only needs CSS on product pages, don't inject markup into displayHeader. If you need to react after an order is placed, don't attach logic to a display hook on the thank-you page (we'll come back to why). If you're rebuilding checkout, expect to register on several hooks and accept that one hook can't carry the whole module.
| Module task | Better hook | Why | Common mistake |
|---|---|---|---|
| Load module CSS or JavaScript | actionFrontControllerSetMedia |
Registers assets through PrestaShop's pipeline, with the controller context already resolved. | Printing raw <script> tags from displayHeader — kills CCC and cache-busting. |
| Add a product trust block | displayProductAdditionalInfo |
Renders right next to the buy button and ships you the product in $params. |
Hooking a global footer position and re-deriving the product ID by hand. |
| React after cart changes | actionCartSave |
Fires after every cart update without any visible rendering. | Doing remote API calls on every save and wondering why the cart page is slow. |
| Queue ERP / CRM sync after purchase | actionValidateOrder |
Fires once when the order is actually created, with order, cart, customer and currency in scope. | Syncing from the thank-you page, which fires again on refresh and after payment returns. |
Check the real Positions screen
Before debugging code, look at the Back Office. The Positions page tells you which modules are currently attached to each hook, the order they fire in, and whether the hook is active for the shop. We use it daily — a perfect hook method will never run if the module wasn't registered during install, got transplanted to the wrong hook, or got unhooked by a merchant during a theme swap.
Real PrestaShop 9 Positions screen
From our ps9-dev shop, not a mockup. The filter field at the top is the fastest way to confirm a hook exists and to see who else is on it.
For product-page work, filter displayProductAdditionalInfo. For asset loading, actionFrontControllerSetMedia. For order integrations, actionValidateOrder. If your module name doesn't show up in that list, no amount of method-rewriting will fix it.
Register only the hooks you use
Hook registration belongs in install() and nowhere else. Keep the list explicit. A module that registers twenty hooks but only implements three methods adds a row to ps_hook_module for every empty hook — and on busy pages PrestaShop still walks that list before deciding nothing needs doing. We've audited modules that registered on 40+ hooks "just in case." Don't be that module.
<?php
public function install()
{
return parent::install()
&& $this->registerHook('actionFrontControllerSetMedia')
&& $this->registerHook('displayProductAdditionalInfo')
&& $this->registerHook('actionValidateOrder');
}
If you add a hook in v1.2 after the module has been in the wild on v1.0, existing shops never re-run install(). You need an upgrade script in upgrade/install-1.2.0.php:
<?php
function upgrade_module_1_2_0($module)
{
return $module->registerHook('actionCartSave');
}
We've shipped versions without the upgrade file before. The ticket queue makes it obvious quickly.
Example: load assets the PrestaShop way
actionFrontControllerSetMedia is the only correct place for front-office CSS or JS since 1.7. It runs after the controller has resolved which page is being rendered, so you can target a specific controller cleanly. Modules that still echo <link> tags from displayHeader bypass CCC entirely, can't be deferred, and don't get the cache-busting query string. Every slow-shop audit we've done in the last three years turned up at least two of these.
<?php
public function hookActionFrontControllerSetMedia(array $params): void
{
$controller = $this->context->controller;
if (!isset($controller->php_self) || $controller->php_self !== 'product') {
return;
}
$controller->registerStylesheet(
'my-module-product',
'modules/' . $this->name . '/views/css/product.css',
['media' => 'all', 'priority' => 150]
);
$controller->registerJavascript(
'my-module-product',
'modules/' . $this->name . '/views/js/product.js',
['position' => 'bottom', 'priority' => 150]
);
}
The php_self guard matters. Without it, every category page, CMS page, search page and checkout step pays the cost of product-only assets — extra HTTP requests, more bytes through PurgeCSS, slower First Contentful Paint. When we built Performance Revolution, the most common offender we saw on slow shops was exactly this pattern: a module registering global assets it only needed on one controller.
Example: render a product page block
Display hooks return markup. The pattern: pull what you need out of $params, assign to Smarty, render through a template that the theme can override.
<?php
public function hookDisplayProductAdditionalInfo(array $params): string
{
$productId = (int) ($params['product']['id_product'] ?? Tools::getValue('id_product'));
if ($productId <= 0) {
return '';
}
$this->context->smarty->assign([
'mpr_delivery_label' => $this->getCachedDeliveryLabel($productId),
'mpr_support_url' => $this->context->link->getCMSLink(3),
]);
return $this->fetch('module:' . $this->name . '/views/templates/hook/product-trust.tpl');
}
Two details from production. First, in 1.7+ $params['product'] is a presenter array, not a Product object — if you forget that and call $params['product']->reference, you get a fatal in PHP 8.1+. Second, that fetch() with the module: prefix is what lets a child theme override the template at themes/your-theme/modules/your-module/views/templates/hook/product-trust.tpl. If you use $this->display(__FILE__, ...) instead, theme override silently doesn't work and you'll get an angry email from whoever maintains the theme.
This is the pattern we use for trust badges, delivery messages, warranty notes, custom variant pickers, and the financing widget our Checkout Revolution sample shops show on every product. Same shape every time.
Example: queue order work, don't block checkout
Order hooks look like a free invitation to do anything that should happen "when an order is placed." Don't. actionValidateOrder fires inside the checkout transaction — the customer is still on the page waiting for the thank-you redirect. If you call an ERP webhook synchronously and the ERP takes four seconds, your customer waits four seconds. If the ERP is down, the checkout request can time out completely.
<?php
public function hookActionValidateOrder(array $params): void
{
/** @var Order $order */
$order = $params['order'];
if ($this->syncAlreadyQueued((int) $order->id)) {
return;
}
$this->queueOrderSync([
'id_order' => (int) $order->id,
'id_customer' => (int) $order->id_customer,
'total_paid' => (float) $order->total_paid_tax_incl,
'created_at' => date('Y-m-d H:i:s'),
]);
}
The syncAlreadyQueued() check earns its keep. We had one client shop on a flaky payment gateway where actionValidateOrder was firing twice on roughly 0.3% of orders — once on the genuine payment, once on a retry that the gateway processed before timing out. The ERP integration was happily creating two warehouse pick tickets per order for weeks before anyone noticed. Idempotency on this hook isn't paranoia, it's the cost of doing business with real payment providers.
Why actionValidateOrder and not displayOrderConfirmation? Because the confirmation page fires every time the customer refreshes or comes back from a payment provider, and you'll trigger your sync multiple times for the same order. actionValidateOrder fires once, when the order row is actually created.
Debug hook registration with SQL
If a method doesn't run, prove the hook is registered before you change anything else. The Positions page is the visual check; SQL is faster when you need numbers.
SELECT h.name, COUNT(hm.id_module) AS modules
FROM ps_hook h
LEFT JOIN ps_hook_module hm ON hm.id_hook = h.id_hook
WHERE h.name IN (
'actionFrontControllerSetMedia',
'displayProductAdditionalInfo',
'actionCartSave',
'actionValidateOrder',
'displayAdminOrder'
)
GROUP BY h.name
ORDER BY h.name;
On our live mypresta.rocks shop right now, common hooks have these registration counts: 35 modules on actionFrontControllerSetMedia, 10 on displayProductAdditionalInfo, 5 on actionCartSave, 11 on actionValidateOrder. Every one of those 35 modules runs in sequence on every front-office page load. That's why actionFrontControllerSetMedia handlers absolutely have to be fast — a 50ms handler across 35 modules is 1.75 seconds of TTFB on every page if you're unlucky with hook order.
The other query we run constantly is "is the module attached, yes or no?":
SELECT m.name, h.name AS hook, hm.position
FROM ps_hook_module hm
JOIN ps_hook h ON h.id_hook = hm.id_hook
JOIN ps_module m ON m.id_module = hm.id_module
WHERE m.name = 'your_module_name'
ORDER BY h.name;
If the row isn't there, your install() didn't run, or it ran and failed silently after the parent install. That's the bug. Not the method.
The custom hook we couldn't avoid building
We try not to create custom hooks. Most "I'll add a hook here for extensibility" decisions age badly — the hook ends up never used by anyone but the original module, and now it's a public API you have to maintain. But there's one exception worth talking about, because it shows when a custom hook is the right call.
In one of our larger modules, the admin product editor has a custom tab with its own form fields. Other modules wanted to extend that tab — add a row, inject a setting, show a status badge. We didn't want every extending module to override the same template file (override conflicts, see the KB reference). So we added a single custom display hook fired from inside the tab's template:
// In the module that owns the tab
$extraRows = Hook::exec(
'displayMprProductTabExtra',
['id_product' => (int) $idProduct, 'tab' => 'logistics'],
null,
true
);
// $extraRows is an associative array keyed by module name
// Render each extension's HTML inside the tab
And we registered the hook once on install, so it shows up in Design → Positions like any other:
$hook = new Hook();
$hook->name = 'displayMprProductTabExtra';
$hook->title = 'Product tab — extra content area';
$hook->add();
The rule of thumb: create a custom hook only when your module is itself an extension point, and other modules want to plug into it. Don't create hooks to organise your own internal code — that's what methods are for.
The one override we still ship
The reference page is firm that overrides are legacy and you should avoid them. We agree. But we still ship exactly one override across our entire catalogue, and it's worth explaining why because the same reasoning applies elsewhere.
It's an override on StockAvailable, in a warehouse module that needs to intercept how stock is decremented for multi-warehouse setups. The behaviour we need is conditional on which warehouse a particular order is being fulfilled from — there's no hook in the stock decrement path, no Symfony service to decorate on PS 1.7 (which the affected clients still run), and no event listener. The behaviour we want is "extend the existing method, don't replace it" — exactly what overrides are designed for.
It's documented in the module's README in big letters. It conflicts with any other module that overrides StockAvailable. We've accepted that trade-off because the alternative (patching it some other way) would be worse. Every override is technical debt with a known removal date; this one's removal date is "when our last 1.7 client upgrades to PS 9."
When a hook is the wrong tool
Hooks aren't a licence to cram every customisation into a single method. If you're replacing a whole workflow, build a real controller or service layer and use hooks only as the integration points. If you're changing template structure broadly, a child theme is cleaner. If you're patching what looks like a core bug, prove the root cause before wrapping hook logic around it.
| Situation | Use a hook? | Better direction |
|---|---|---|
| Add a small visible block to product pages | Yes | Display hook with template-override support via module: prefix. |
| Load a page-specific script | Yes | actionFrontControllerSetMedia with a php_self guard. |
| Replace checkout layout and behaviour | Partly | A proper module architecture; hooks plug it into PrestaShop. Checkout Revolution rebuilds checkout end-to-end instead of pretending one hook can carry it. |
| Change every product template detail | Sometimes | A child theme is usually cleaner — see the child themes guide. |
| Patch performance problems caused by many modules | No | Profile the hook stack, then narrow, cache or remove the slow handlers. See the performance guide. |
The four checks that catch 95% of hook bugs
When we open a support ticket that says "the hook isn't firing," the answer is one of these four, in roughly this order of frequency:
- The module wasn't registered on the hook. Check
ps_hook_module. If the row's missing, yourinstall()didn't run that line — or you added the hook in a later version without an upgrade script. - The method name is wrong.
hookActionValidateOrder, notHookActionValidateOrderoractionValidateOrder. PHP is case-insensitive for method names, but typos in the prefix are still typos. - You're returning nothing from a display hook. Display hooks need to
returnthe HTML; action hooks discard the return value. Easy to swap by accident. - The hook fires in a context you didn't expect.
displayHeaderfires on AJAX requests too.displayOrderConfirmationfires on every refresh.actionCartSavefires when the cart is saved for the customer's session even if nothing changed. Guard the handler.
Walk those four in order, and you'll almost never need to open a Symfony profiler.
Related reading
- PrestaShop Hooks & Overrides reference — the long-form companion: full hook dictionary, override trade-offs, PS 9 migration notes
- How to Migrate to PrestaShop 9: Complete Upgrade Guide — what changed for hooks in PS 9 specifically
- PrestaShop Child Themes guide — when template overrides beat hook customisation
- PrestaShop troubleshooting guide — proving root cause before reaching for a hook
- PrestaShop performance guide — profiling the hook stack on a slow shop
- Checkout Revolution and Performance Revolution — two of our modules that lean heavily on hooks; useful production references
Comments (2)
Leave a comment
Share a question, an installation detail, or feedback that could help another reader.