Guides Guide

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 pageA display* hook returning a Smarty templateEchoing inside displayHeader
React to a state change (order placed, cart saved, product updated)An action* hookAn override of the controller
Add CSS or JS to the front officeactionFrontControllerSetMedia + registerStylesheet / registerJavascriptRaw <link> tags via displayHeader — kills CCC and breaks asset versioning
Change a core calculation that has no hookA Symfony service decorator (PS 8+) or, as a last resort, an overrideEditing 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

HookFiresUse it for
displayProductAdditionalInfoBelow the Add to Cart buttonDelivery estimates, size guides, financing widgets — the most valuable real estate on a product page
displayShoppingCartCart summary pageCross-sells, shipping calculators, urgency messaging
displayOrderConfirmationThank-you page after checkoutConversion tracking, post-purchase upsells, referral prompts
displayCustomerAccount"My account" pageCustom sections — wishlists, loyalty points, RMA requests
displayBannerTop of page, above the headerSale strips, free-shipping bars, GDPR notices
displayHomeHomepage content areaFeatured collections, hero modules

Back office — display

HookFiresUse it for
displayAdminOrderOrder detail pageCustom panels — shipping integrations, ERP sync status, fraud signals
displayAdminProductsExtraNew tab in product editorCustom product fields, third-party data
displayBackOfficeHeaderInside admin <head>Admin CSS/JS, dashboard widgets, update notices

Action hooks

HookFiresUse it for
actionValidateOrderOrder successfully validatedPayment confirmation, stock decrement, post-payment workflow — the most important action hook
actionOrderStatusUpdateOrder status changesCarrier notifications, ERP sync, customer emails
actionCartSaveCart created or updatedAbandoned cart triggers, inventory reservation, analytics
actionProductUpdateProduct saved in adminExternal catalog sync, search reindexing, image regeneration
actionCustomerAccountAddNew customer registersWelcome email, CRM sync, segmentation
actionFrontControllerSetMediaFront controller loads assetsRegister 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.

PrestaShop 8 Back Office Positions page showing hook registrations and module ordering

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 Cart class isn't the one you think it is.
  • The class index can rot. cache/class_index.php caches 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:

SymptomMost likely causeFix
Method called, returns nothingTemplate path wrong, or missing return on a display hookEnable debug, check template path, confirm you returned the rendered output
Fires on every page when you only want product pagesNo controller checkif (!($this->context->controller instanceof ProductController)) return '';
Fires twice on the same pageRegistered twice in ps_hook_moduleDELETE the duplicate row, or reset the module
$params['product'] is an array not an objectPS 1.7+ uses presenter arrays in front officeUse $params['product']['id_product'] — or call new Product($id) if you need full object
Works in dev, breaks in prodCompiled Smarty templates cachedForce 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

Loading...
Back to top