PrestaShop Hooks Guide: Display, Action & Overrides
Developer reference for PrestaShop hooks and overrides — display hooks, action hooks, custom hooks, the override system, and migration to modern patterns.
What hooks actually do (and where most people get stuck)
Every module we ship at mypresta.rocks plugs into PrestaShop through hooks — and after running 140+ of them in production, we can tell you that most hook bugs we've fixed since 2014 trace back to the same handful of mistakes: registered on the wrong hook, registered nowhere at all, fired twice, or fired where the developer assumed an override.
This page is the reference we wish we'd had when we started. It's also the page we send to clients when they ask us "why does this module need an upgrade for PrestaShop 9?" — because the answer is almost always "the hook it used has changed."
Two kinds of hooks, and the one rule that matters
PrestaShop has display hooks (they ask your module for HTML to render) and action hooks (they tell your module something happened (order placed, cart saved, customer registered) and ignore whatever you return).
The rule that catches people: return value matters for display hooks, doesn't for action hooks. If you return $this->display(...) from an action hook, nothing breaks. If you forget to return from a display hook, your module renders nothing and you'll lose an hour wondering why.
How PrestaShop actually calls your code
When the core reaches a hook point, Hook::exec() queries ps_hook_module, sorts active modules by position, calls each module's hookXxx() method with a $params array, and (for display hooks) concatenates the returned HTML.
That's it. No magic. The reason your hook isn't firing is almost always a missing row in ps_hook_module — not anything wrong with your method.
The four patterns most modules use
Before reaching for documentation, ask yourself which of these four jobs you're doing. Pick the wrong pattern and you'll fight the framework for the rest of the module's life.
| You need to… | Reach for… | Don't bother with… |
|---|---|---|
| Render HTML in a theme or admin page | A display* hook returning a Smarty template | Echoing inside displayHeader |
| React to a state change (order placed, cart saved, product updated) | An action* hook | An override of the controller |
| Add CSS or JS to the front office | actionFrontControllerSetMedia + registerStylesheet / registerJavascript | Raw <link> tags via displayHeader — kills CCC and breaks asset versioning |
| Change a core calculation that has no hook | A Symfony service decorator (PS 8+) or, as a last resort, an override | Editing core files |
The hooks you'll actually use
PrestaShop ships with hundreds of hooks. We register on roughly twelve of them across our entire module catalogue. These are the ones worth memorising.
Front office — display
| Hook | Fires | Use it for |
|---|---|---|
displayProductAdditionalInfo | Below the Add to Cart button | Delivery estimates, size guides, financing widgets — the most valuable real estate on a product page |
displayShoppingCart | Cart summary page | Cross-sells, shipping calculators, urgency messaging |
displayOrderConfirmation | Thank-you page after checkout | Conversion tracking, post-purchase upsells, referral prompts |
displayCustomerAccount | "My account" page | Custom sections — wishlists, loyalty points, RMA requests |
displayBanner | Top of page, above the header | Sale strips, free-shipping bars, GDPR notices |
displayHome | Homepage content area | Featured collections, hero modules |
Back office — display
| Hook | Fires | Use it for |
|---|---|---|
displayAdminOrder | Order detail page | Custom panels — shipping integrations, ERP sync status, fraud signals |
displayAdminProductsExtra | New tab in product editor | Custom product fields, third-party data |
displayBackOfficeHeader | Inside admin <head> | Admin CSS/JS, dashboard widgets, update notices |
Action hooks
| Hook | Fires | Use it for |
|---|---|---|
actionValidateOrder | Order successfully validated | Payment confirmation, stock decrement, post-payment workflow — the most important action hook |
actionOrderStatusUpdate | Order status changes | Carrier notifications, ERP sync, customer emails |
actionCartSave | Cart created or updated | Abandoned cart triggers, inventory reservation, analytics |
actionProductUpdate | Product saved in admin | External catalog sync, search reindexing, image regeneration |
actionCustomerAccountAdd | New customer registers | Welcome email, CRM sync, segmentation |
actionFrontControllerSetMedia | Front controller loads assets | Register CSS/JS the proper way |
The asset hook — please use this, not displayHeader
Since PrestaShop 1.7, the only correct place to add front-office CSS or JS is actionFrontControllerSetMedia:
public function hookActionFrontControllerSetMedia($params)
{
$this->context->controller->registerStylesheet(
'my-module-style',
'modules/' . $this->name . '/views/css/front.css',
['media' => 'all', 'priority' => 150]
);
$this->context->controller->registerJavascript(
'my-module-script',
'modules/' . $this->name . '/views/js/front.js',
['position' => 'bottom', 'priority' => 150]
);
}
Modules that still inject raw <link> and <script> tags through displayHeader break Combine-Compress-Cache (CCC), bypass the cache-busting query string, and can't be deferred. Every time we audit a slow shop, at least two modules are doing this. Don't be one of them.
Wiring a hook into your module
Register on install
public function install()
{
return parent::install()
&& $this->registerHook('displayProductAdditionalInfo')
&& $this->registerHook('actionCartSave')
&& $this->registerHook('actionFrontControllerSetMedia');
}
Each registerHook() call inserts a row into ps_hook_module. Forget to register, and your hook method might as well not exist — PrestaShop will never call it. This is the single most common "my module doesn't work" cause we see in support.
One more trap: if you add a new hook in version 1.2 of your module after shipping 1.0, existing customers' installations don't get the registration. You need either an upgrade script (upgrade/install-1.2.0.php) calling registerHook(), or instructions to reset the module. Bundle that in the release notes or you will get tickets.
Implement the method
Method name = literally hook + the hook name, first letter uppercased:
public function hookDisplayProductAdditionalInfo($params)
{
$product = $params['product'];
$this->context->smarty->assign([
'delivery_estimate' => $this->calculateDeliveryEstimate($product),
]);
return $this->display(__FILE__, 'views/templates/hook/product-info.tpl');
}
// Action hooks: do work, return value is discarded
public function hookActionCartSave($params)
{
if (!isset($params['cart'])) {
return;
}
$this->logCartActivity($params['cart']->id);
}
What's inside $params
Depends on the hook. The five you'll see most are $params['cart'], $params['order'], $params['product'], $params['customer'], and $params['cookie']. If you're not sure, log it:
file_put_contents(
_PS_ROOT_DIR_ . '/var/logs/hook_debug.log',
date('c') . "\n" . print_r(array_keys($params), true) . "\n",
FILE_APPEND
);
Remove the logging before you ship. We've shipped modules with leftover debug logging more than once — it fills up var/logs/ on busy shops and chews disk.
Finding a hook when you don't know which one to use
The Positions page in PrestaShop 8 Back Office.
This page shows which modules are registered on which hooks, in what order. If a module isn't firing, this is the first place to check.
Debug mode shows you the page
Advanced Parameters → Performance → Debug mode → Yes. Reload the page. PrestaShop annotates every display hook insertion point with its name. In PS 8+ the Symfony toolbar also lists every hook fired on the request.
Important: never turn debug mode on in production. It exposes stack traces, slows the site, and on PS 9 will also reveal database credentials in error pages.
The database knows
-- All hooks with "product" in the name
SELECT name, title FROM ps_hook WHERE name LIKE '%product%' ORDER BY name;
-- Which modules are on a specific hook, in execution order
SELECT m.name, 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 h.name = 'displayProductAdditionalInfo'
ORDER BY hm.position;
Grep the source
# Display hooks in theme templates
grep -rn "{hook " themes/your-theme/templates/
# Action hooks in core PHP
grep -rn "Hook::exec" classes/ controllers/ src/
For PS 8.x and 9.x, also grep src/ — Symfony controllers fire hooks too, and they're easy to miss if you only look at the legacy controllers/ directory.
Creating your own hook
Only do this if your module is itself an extension point — i.e. you want other modules to plug into yours. Don't create hooks just to organise your own code; that's what methods are for.
// Anywhere in your module
$hookResult = Hook::exec('actionMyModuleBeforeProcess', [
'order_id' => $orderId,
'custom_data' => $myData,
]);
// For display hooks, pass null + true to get per-module HTML
$extraHtml = Hook::exec('displayMyModuleExtraContent', [
'product' => $product,
], null, true);
To make your hook visible in Design → Positions, register it once:
$hook = new Hook();
$hook->name = 'displayMyModuleExtraContent';
$hook->title = 'My Module — Extra Content Area';
$hook->add();
The override system: legacy but not dead
Overrides let you replace core methods without editing core files. They were the way you customised PrestaShop in the 1.5/1.6 era, before hooks covered most use cases. They still work (and we still use them, sparingly) but they have real problems you should understand before reaching for one.
How an override looks
// override/classes/Cart.php
class Cart extends CartCore
{
public function getOrderTotal($with_taxes = true, $type = Cart::BOTH,
$products = null, $id_carrier = null, $use_cache = false)
{
$total = parent::getOrderTotal($with_taxes, $type, $products, $id_carrier, $use_cache);
if ($this->hasSpecialProducts()) {
$total += $this->calculateSurcharge();
}
return $total;
}
}
The override directory mirrors the core: override/classes/, override/controllers/front/, override/controllers/admin/. PrestaShop's autoloader looks here first, so your Cart shadows CartCore.
Why we avoid overrides when we can
- They conflict. Two modules cannot override the same method. The second one to install fails outright. This is the #1 reason "two modules I want don't work together."
- They break on upgrade. Method signatures change between PrestaShop versions. An override written for 1.7.7 can crash 1.7.8 with a fatal error and take the entire shop down.
- They're invisible. Back Office shows no sign an override exists. You're debugging a problem and have no idea half your
Cartclass isn't the one you think it is. - The class index can rot.
cache/class_index.phpcaches what overrides what. When it gets out of sync (usually after a botched module install) the shop white-screens until you delete the file.
The PrestaShop core team has been telling people to stop writing overrides since 2017. They're right. Use one only when there's genuinely no hook, no service to decorate, and no event to listen for.
Modern alternatives (PS 8+)
For PS 8 and 9, three patterns replace most overrides:
Symfony service decorators — wrap a core service with your own logic. Multiple modules can decorate the same service without conflicts:
# modules/mymodule/config/services.yml
services:
mymodule.decorated_calculator:
class: MyModule\Service\CalculatorDecorator
decorates: 'prestashop.core.cart.calculator'
arguments:
- '@mymodule.decorated_calculator.inner'
Doctrine event listeners — react to entity persistence without subclassing ObjectModel:
# modules/mymodule/config/services.yml
services:
mymodule.product_listener:
class: MyModule\EventListener\ProductListener
tags:
- { name: doctrine.event_listener, event: postUpdate }
CQRS command/query handlers — for back-office operations, decorate the command bus. Steepest learning curve but the cleanest separation; this is what new PrestaShop core code uses internally.
When two modules collide
You'll see this in var/logs/:
The method Cart::getOrderTotal is already overridden by module "othermodule".
The only real fix is manual merging: open both override files, combine the logic into one, install the second module. After editing, drop the class index:
rm var/cache/prod/class_index.php
rm var/cache/dev/class_index.php
This is fragile. When either module updates, your merge can break. Document what you merged and keep the original files somewhere. And if a module ships an override where a hook would work, please open a support ticket with the developer — most of us will switch to hooks if asked.
Positions, ordering, and transplants
When multiple modules register on the same display hook, position determines render order. The merchant can drag-drop them in Design → Positions, unhook anything they don't want, or transplant a module from one hook to another.
Transplanting works in the simple cases. It does not always work, and modules that assume a specific hook (because they read controller context, expect certain $params, or use hook-specific Smarty variables) will render wrong or not at all. If the merchant complains "the module disappeared when I moved it," that's why.
You can also set position programmatically on install:
// In install() — move to position 1
$this->updatePosition($this->getHookId('displayHome'), false, 1);
Debugging when a hook isn't firing
Work through these in order. The cause is almost always in the first two.
-- 1. Is the module registered on the hook?
SELECT m.name, 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 h.name = 'displayProductAdditionalInfo'
AND m.name = 'your_module_name';
-- 2. Is the module active and installed?
SELECT name, active FROM ps_module WHERE name = 'your_module_name';
If both rows exist and the method still isn't firing, check:
| Symptom | Most likely cause | Fix |
|---|---|---|
| Method called, returns nothing | Template path wrong, or missing return on a display hook | Enable debug, check template path, confirm you returned the rendered output |
| Fires on every page when you only want product pages | No controller check | if (!($this->context->controller instanceof ProductController)) return ''; |
| Fires twice on the same page | Registered twice in ps_hook_module | DELETE the duplicate row, or reset the module |
$params['product'] is an array not an object | PS 1.7+ uses presenter arrays in front office | Use $params['product']['id_product'] — or call new Product($id) if you need full object |
| Works in dev, breaks in prod | Compiled Smarty templates cached | Force compile + clear cache in Advanced Parameters → Performance |
Hook profiling
Enable profiling to see timing per hook:
// PS 1.7 — defines.inc.php
define('_PS_DEBUG_PROFILING_', true);
// PS 8+ — .env.local
APP_DEBUG=1
APP_ENV=dev
What changed in PrestaShop 9
If you're migrating a module, this is the part to read carefully.
New hooks
PS 9 adds hooks in places that used to require overrides: admin product form extensions, cart price rule application, API resource operations, and email sending. Every override we removed during our PS 9 migration was replaced by one of these.
Deprecated hooks
Hooks tied to the legacy admin controllers are deprecated as those controllers migrate to Symfony. The deprecation list lives in _PS_DEPRECATED_HOOKS_ — search for it in the core constants file. Your module will keep working, but every page load will log a deprecation warning, and the hook will be removed in a future minor release.
Overrides are getting less useful, not gone
Overrides still work for legacy ObjectModel classes. They do not work for Symfony services or new admin controllers — there's nothing to subclass. As more of the back office migrates to Symfony, override coverage shrinks. We're treating every new override we write as technical debt with a known removal date.
Quick reference
// Register one or many
$this->registerHook(['displayHeader', 'actionCartSave']);
// Does this hook exist?
$hookId = Hook::getIdByName('displayMyCustomHook');
// Which modules are on this hook?
$modules = Hook::getHookModuleExecList('displayHeader');
// Fire a custom hook
$output = Hook::exec('displayMyHook', ['key' => 'value']);
// Render only on product pages
public function hookDisplayHeader($params) {
if (!($this->context->controller instanceof ProductController)) {
return '';
}
return $this->display(__FILE__, 'views/templates/hook/header.tpl');
}
-- All hooks a given module is on
SELECT h.name, 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;
-- Hooks with no modules registered (potentially abandoned hooks)
SELECT h.name FROM ps_hook h
LEFT JOIN ps_hook_module hm ON h.id_hook = hm.id_hook
WHERE hm.id_hook IS NULL
ORDER BY h.name;
Related reading
- How to Migrate to PrestaShop 9: Complete Upgrade Guide — what hooks were added and deprecated in PS 9
- PrestaShop Child Themes: Classic & Hummingbird Customization Guide — for template-level changes, prefer a child theme over an override
- Essential Tools for PrestaShop Development — the debugging tools we use day-to-day
- Mastering PrestaShop Hooks: A Developer Reference for 1.7, 8.x and 9.x — longer-form companion piece with more examples
- Checkout Revolution and Performance Revolution — two of our modules that lean heavily on hooks; useful as production references