Module Upgrades, Overrides and Decoration

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.

A module is never finished after its first release. The shop that installed version 1.0.0 will one day receive 1.2.0, and somebody has to turn the database, the configuration and the registered hooks of the first into those of the second. That is the job of the upgrade scripts. The second half of this page covers the other long-lived corner of module code: changing something the core does. PrestaShop has an old mechanism for that, the override, which copies a class into the shop’s override/ folder, and two better ones, hooks and Symfony service decoration. The page explains all of them: first the upgrade mechanics, then the override mechanics, because stores and books still use them, and then the alternatives that should be tried first in new code. It was written against PrestaShop 9.2.0 (Symfony 6.4.46, PHP 8.5.12, MySQL 8.4.13 in the official Docker image) and checked on 2026-10-27. Upgrade scripts are unchanged since PrestaShop 1.5; the actionModuleUpgradeAfter hook is new in 9.1; #[AsDecorator] needs the Symfony 6.x container of 9.x.

The running example is the Pocket Coffee Coffee Club. The module pcfe46 stores the club discount, prints it on the product page and in the footer, and goes through three versions: 1.0.0 (a configuration key and one hook), 1.1.0 (a table of club notes and a footer hook) and 1.2.0 (the key renamed, a column added, the override refreshed). A second module, pcfe46b, exists only to show what happens when two modules want the same core class. Everything below was executed on the local shop: the upgrade from 1.0.0 to 1.2.0 with bin/console prestashop:module upgrade, a deliberately failing script, a fresh install of 1.2.0 compared with the upgraded schema, the override copy, merge, conflict and removal, a hook replacing the override, a service decoration and a controller decoration. The page builds on Module Basics (module structure, install() and uninstall()), Hooks and Module Services and Admin Controllers, and Module Translations for the wordings.

The module at its final version

The class below is version 1.2.0 of pcfe46. It is the file a new install sees. Read it as the reference state: every upgrade script exists to bring an older shop to exactly this state. The class keeps install() and the scripts in step, because install() is not run again on an upgrade.

<?php
/**
 * Pocket Coffee Coffee Club discount: shows the club discount and keeps it in the configuration.
 */
if (!defined('_PS_VERSION_')) {
    exit;
}

if (file_exists(__DIR__ . '/vendor/autoload.php')) {
    require_once __DIR__ . '/vendor/autoload.php';
}

class Pcfe46 extends Module
{
    public function __construct()
    {
        $this->name = 'pcfe46';
        $this->tab = 'pricing_promotion';
        $this->version = '1.2.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('Coffee Club discount', [], 'Modules.Pcfe46.Admin');
        $this->description = $this->trans('Shows the Coffee Club discount.', [], 'Modules.Pcfe46.Admin');
    }

    /**
     * A fresh install of 1.2.0 must end in the same state as 1.0.0 upgraded step by step.
     */
    public function install(): bool
    {
        return parent::install()
            && Configuration::updateValue('PCFE46_CLUB_PERCENT', 10)
            && $this->createClubNoteTable()
            && $this->registerHook('displayProductAdditionalInfo')
            && $this->registerHook('displayFooterBefore')
            && $this->registerHook('actionModuleUpgradeAfter')
            && $this->registerHook('actionFrontControllerInitAfter');
    }

    public function uninstall(): bool
    {
        return Db::getInstance()->execute('DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'pcfe46_club_note`')
            && Configuration::deleteByName('PCFE46_CLUB_PERCENT')
            && Configuration::deleteByName('PCFE46_LAST_UPGRADE')
            && parent::uninstall();
    }

    private function createClubNoteTable(): bool
    {
        return Db::getInstance()->execute(
            'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'pcfe46_club_note` (
                `id_note` INT UNSIGNED NOT NULL AUTO_INCREMENT,
                `id_customer` INT UNSIGNED NOT NULL,
                `note` VARCHAR(255) NOT NULL,
                `source` VARCHAR(16) NOT NULL DEFAULT \'web\',
                `date_add` DATETIME NOT NULL,
                PRIMARY KEY (`id_note`),
                KEY `id_customer` (`id_customer`)
            ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4'
        );
    }

    public function hookDisplayProductAdditionalInfo(array $params): string
    {
        $this->context->smarty->assign('clubPercent', (int) Configuration::get('PCFE46_CLUB_PERCENT'));

        return $this->fetch('module:pcfe46/views/templates/hook/club.tpl');
    }

    /**
     * The alternative to the FrontController override: a hook that runs after the front controller is initialised.
     */
    public function hookActionFrontControllerInitAfter(array $params): void
    {
        header('X-Pcfe46-Hook: 1.2.0');
    }

    public function hookDisplayFooterBefore(array $params): string
    {
        return '<p class="pcfe46-footer">Join the Coffee Club</p>';
    }

    /**
     * Fired by Module::upgradeModuleVersion() (9.1+) after a successful upgrade of any module.
     */
    public function hookActionModuleUpgradeAfter(array $params): void
    {
        Configuration::updateValue(
            'PCFE46_LAST_UPGRADE',
            sprintf('%s %s -> %s', $params['module_name'], $params['old_version'], $params['new_version'])
        );
    }
}

Module upgrades

What happens when a module is upgraded: compare the file version with the stored version, run the matching upgrade scripts in order, update the stored version and notify other modules; a script that returns false disables the module

How PrestaShop decides that an upgrade is needed

Two version numbers matter. The file version is the $this->version literal in the main module file (modules/pcfe46/pcfe46.php); PrestaShop reads it without loading the class, by parsing the file, and falls back to config.xml if the parse fails, so keep the assignment a plain string literal. The stored version is the column version of the table ps_module, written at install time and updated after every upgrade. When the file version is greater than the stored one, the module is "upgradable": the Module Manager of the back office shows an Upgrade button, and bin/console prestashop:module upgrade <name> does the same from the console. A deployment that replaces the module folder (git pull, zip upload, composer) therefore has to be followed by the upgrade action; copying the files does not touch the stored version.

The Update Assistant, which moves a whole shop from one PrestaShop version to another, upgrades the modules it carries as well (it is described with the release process in Testing, Upgrades and Addons); this page is about the module mechanism that every one of those paths calls.

The upgrade scripts

The scripts live in modules/pcfe46/upgrade/. The rules, taken from the module code of 9.2.0 and confirmed by running them:

Rule Detail

File name

upgrade-<version>.php with exactly one dash. The loader splits the file name on dashes and ignores a name with more than two parts, so upgrade-1.2.0-rc1.php is never run. Both upgrade-1.1.php (docs example) and upgrade-1.1.0.php work; use three-part versions.

Function

upgrade_module_<version with dots replaced by underscores>($module), for example upgrade_module_1_2_0. The old form upgradeModule120 (dots removed) is still looked up. The function receives the module instance and must return true on success. Any other return value, or a missing function, counts as a failure.

Selection

Every script whose version is greater than the stored version and not greater than the file version.

Order

Sorted by version, lowest first, with PHP’s version_compare (so 1.10.0 comes after 1.9.0).

No script at all

If nothing matches, the stored version is simply set to the file version.

Bootstrap

Start every file with if (!defined('_PS_VERSION_')) { exit; } and put an index.php in the folder, like in the other module folders.

Global functions

Scripts are included with include into the running request, and their functions are global. PrestaShop checks function_exists() before including the next script; a repeated function name makes the upgrade fail with a "duplicate" flag. Give helper functions a module-specific prefix and never copy a helper between two modules that may be upgraded in the same request.

Not re-run

install() is not called on upgrade. A hook, table, configuration key or tab added to install() in a later version must also be added by a script, and vice versa.

The docs table of which scripts run is the one to remember:

Upgrading from Target version Scripts executed

1.0.0

1.1.0

upgrade-1.1.0.php

1.0.0

1.2.0

upgrade-1.1.0.php, then upgrade-1.2.0.php

1.1.0

1.2.0

upgrade-1.2.0.php

Pocket Coffee scripts

Version 1.1.0 adds the table and the footer hook:

<?php
if (!defined('_PS_VERSION_')) {
    exit;
}

/**
 * 1.1.0: the club notes table and the footer hook.
 */
function upgrade_module_1_1_0($module)
{
    return Db::getInstance()->execute(
        'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'pcfe46_club_note` (
            `id_note` INT UNSIGNED NOT NULL AUTO_INCREMENT,
            `id_customer` INT UNSIGNED NOT NULL,
            `note` VARCHAR(255) NOT NULL,
            `date_add` DATETIME NOT NULL,
            PRIMARY KEY (`id_note`),
            KEY `id_customer` (`id_customer`)
        ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4'
    ) && $module->registerHook('displayFooterBefore');
}

Version 1.2.0 renames a configuration key without losing the merchant’s value, adds a column, registers two hooks and refreshes the override (the reason for the last step is explained in Refreshing an override on upgrade):

<?php
if (!defined('_PS_VERSION_')) {
    exit;
}

/**
 * 1.2.0: rename the configuration key, add a column, listen to upgrades and refresh the override.
 */
function upgrade_module_1_2_0($module)
{
    $db = Db::getInstance();

    // Rename PCFE46_CLUB_DISCOUNT to PCFE46_CLUB_PERCENT, keeping the merchant's value
    $old = Configuration::get('PCFE46_CLUB_DISCOUNT');
    if ($old !== false) {
        Configuration::updateValue('PCFE46_CLUB_PERCENT', (int) $old);
        Configuration::deleteByName('PCFE46_CLUB_DISCOUNT');
    }

    // Idempotent ALTER: the script may run again after a half-finished upgrade
    $hasColumn = (bool) $db->getValue(
        'SELECT COUNT(*) FROM information_schema.COLUMNS
         WHERE TABLE_SCHEMA = DATABASE()
           AND TABLE_NAME = \'' . pSQL(_DB_PREFIX_ . 'pcfe46_club_note') . '\'
           AND COLUMN_NAME = \'source\''
    );
    if (!$hasColumn) {
        $db->execute(
            'ALTER TABLE `' . _DB_PREFIX_ . 'pcfe46_club_note`
             ADD COLUMN `source` VARCHAR(16) NOT NULL DEFAULT \'web\' AFTER `note`'
        );
    }

    // Overrides are copied only at install time: refresh the one shipped by this version
    return $module->registerHook('actionModuleUpgradeAfter')
        && $module->registerHook('actionFrontControllerInitAfter')
        && $module->removeOverride('FrontController')
        && $module->addOverride('FrontController');
}

Four habits keep scripts safe. Write each statement so that it can run twice (CREATE TABLE IF NOT EXISTS, a column check before ALTER, registerHook that ignores an already registered hook, Configuration::updateValue), because a failure part-way through is repaired by fixing the script and running it again. Escape every value that comes from outside with pSQL() or an (int) cast and every identifier with bqSQL(), and build table names from _DB_PREFIX_. Never alter core tables: add a table of your own. And keep the work proportional: an ALTER TABLE on a table with millions of rows inside a web request is a timeout waiting to happen, so large data migrations belong in a console command that the script only schedules.

Running an upgrade

Start the shop at 1.0.0 (install the module, change the discount to 15 to prove the value survives), put the 1.2.0 files in place and run:

bin/console prestashop:config set PCFE46_CLUB_DISCOUNT --value=15
# ... replace modules/pcfe46 with the 1.2.0 files ...
bin/console prestashop:module upgrade pcfe46

The command printed "Update action on module pcfe46 succeeded." After it:

Check Result

select version from ps_module where name='pcfe46'

1.2.0

ps_configuration keys starting with PCFE46

PCFE46_CLUB_PERCENT = 15 (the merchant’s 15, not the default 10); the old key is gone

show tables like '%pcfe46%'

ps_pcfe46_club_note

Hooks of the module

displayProductAdditionalInfo, displayFooterBefore, actionModuleUpgradeAfter, actionFrontControllerInitAfter

Response header of any front page

X-Pcfe46-Override: 1.2.0 (the override was refreshed, see below)

A fresh install of 1.2.0 on another database must end in the same state. The check that matters is a diff of show create table ps_pcfe46_club_note after the upgrade and after a clean uninstall plus install: the two outputs were identical (a table id_note, id_customer, note, source, date_add with a primary key and an index on id_customer, in InnoDB with utf8mb4). Keep that test in the module’s own test suite whenever a script changes the schema.

Hooks around an upgrade

ModuleManager::upgrade (the code behind both the console command and the Module Manager button) does four things in this order: it disables the hooks of the module for the duration of the upgrade, executes the hook actionBeforeUpgradeModule (parameters moduleName and source), runs the scripts, and dispatches the upgrade event. When the stored version has moved, Module::upgradeModuleVersion executes actionModuleUpgradeAfter with the parameters module_name, old_version and new_version. The hook is new in 9.1 and the 9.1 changelog lists it with actionModuleEnable, actionModuleDisable and actionConfigurationUpdateValueBefore.

The first consequence was observed by running it: the hooks of a module are disabled while that module is being upgraded. With the hook method hookActionModuleUpgradeAfter in pcfe46, a plain version bump from 1.2.0 to 1.2.1 did not write PCFE46_LAST_UPGRADE, while the second module pcfe46b, which listens to the same hook, recorded pcfe46 1.2.0 -> 1.2.1; the same held for an upgrade that ran a script (pcfe46 1.2.1 -> 1.2.2). Calling the hook by hand with Hook::exec('actionModuleUpgradeAfter', [...]) ran the handler of pcfe46 normally, so the handler itself is fine. Use the hook to react to other modules being upgraded (clear a cache, re-register a widget), and put your own post-upgrade work in the script.

<?php
if (!defined('_PS_VERSION_')) {
    exit;
}

class Pcfe46b extends Module
{
    public function __construct()
    {
        $this->name = 'pcfe46b';
        $this->tab = 'pricing_promotion';
        $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'];
        parent::__construct();

        $this->displayName = 'Coffee Club upgrade watcher';
        $this->description = 'Records which modules were upgraded.';
    }

    public function install(): bool
    {
        return parent::install() && $this->registerHook('actionModuleUpgradeAfter');
    }

    public function uninstall(): bool
    {
        return Configuration::deleteByName('PCFE46B_SEEN') && parent::uninstall();
    }

    public function hookActionModuleUpgradeAfter(array $params): void
    {
        Configuration::updateValue(
            'PCFE46B_SEEN',
            sprintf('%s %s -> %s', $params['module_name'], $params['old_version'], $params['new_version'])
        );
    }
}

When a script fails

A script that returns anything but true stops the chain. In 9.2.0 a test script upgrade-1.3.0.php returning false produced:

Cannot upgrade module pcfe46. No update has been applied, To prevent any problem, this module has been turned off

and the table showed active = 0 for the module. The remaining scripts do not run, the hook actionModuleUpgradeAfter is not sent, and the module is disabled so that code written for the new version does not run on half-migrated data. Two details are easy to miss. First, the stored version was nevertheless set to 1.3.0 (the console and the Module Manager call Module::upgradeModuleVersion with the file version after the scripts, whatever they returned), so running upgrade again does nothing and the failed script is never retried. Second, scripts that succeeded before the failing one stay applied.

Recovering is manual and was tested: fix the script, set the stored version back to the last good one, enable the module and upgrade again.

# the failed step was 1.3.0; the last good one is 1.2.2
echo "update ps_module set version='1.2.2' where name='pcfe46'" | mysql prestashop
bin/console prestashop:module enable pcfe46
bin/console prestashop:module upgrade pcfe46

After this the console printed "Update action on module pcfe46 succeeded." and the module was active with version 1.3.0, and pcfe46b recorded pcfe46 1.2.2 -> 1.3.0. Because of this behaviour, test the upgrade path in a copy of the production database before shipping, back up before upgrading, and make every script idempotent so that the replay is safe.

Overrides

An override is a class or controller that extends a core class and takes its place. PrestaShop’s autoloader (a class index in var/cache/<env>/class_index.php) maps every class name to a file; when a file with the same class name exists under override/, the index points at it, and that file declares class Product extends ProductCore. All the code that says new Product() therefore gets the override without knowing. A module can ship such a file in its own override/ folder, and PrestaShop merges it into the shop’s override/ at install and removes it at uninstall.

Rules before you write one

Overrides are exclusive: if one module overrides a method, another module cannot override it as well, or must collide with it. The official docs therefore allow overrides only for the modules you build for your own shop, advise against them in modules you distribute, and forbid them in Addons partner modules. The docs themselves disagree: the good-practices page for Addons modules says that overrides are permitted but should be few (two or three at most) and not dangerous, while the overrides page says they are forbidden in partner modules. Treat the stricter rule as binding. The docs also state that creating a custom table and a custom ObjectModel is always preferable to overriding a core class to add fields.

This is the legacy habit. Older books teach PrestaShop through core overrides (Pérez Castaño’s PrestaShop Recipes, Apress, 2017, targets 1.6, where overriding Product, Cart or Customer was the normal way to change behaviour), and many stores still carry such code. On 9.x the same job is done with a hook, a decorated service or an own table; the next sections show how.

Where override files go

What you override File inside the module Copied to

A core class (Product, Cart, Tools)

override/classes/Product.php, class Product extends ProductCore

override/classes/Product.php

A class in a subfolder (FrontController)

override/classes/controller/FrontController.php

override/classes/controller/FrontController.php

A front or back office legacy controller

override/controllers/front/ProductController.php, class ProductController extends ProductControllerCore

override/controllers/front/ProductController.php

The main class of another module

override/modules/pcfe45classic/pcfe45classic.php, class Pcfe45classicOverride extends Pcfe45classic (suffix Override)

override/modules/pcfe45classic/pcfe45classic.php

A controller class of another module

override/modules/<module>/controllers/front/<Name>.php, same Override suffix

override/modules/<module>/controllers/front/<Name>.php

The path of the module-side file is the path of the core file under the shop root: the module code asks the autoloader for the location of <Class>Core and looks for the same relative path under its own override/ folder. Also, a theme cannot override a module (the docs say it is not possible and never will be); the opposite, a theme overriding a module’s templates, is covered in Theme template overrides.

What install does

The module in this page ships this file in override/classes/controller/FrontController.php:

<?php

class FrontController extends FrontControllerCore
{
    public function initContent()
    {
        parent::initContent();
        header('X-Pcfe46-Override: 1.2.0');
    }
}

Installing the module (bin/console prestashop:module install pcfe46) created override/classes/controller/FrontController.php in the shop with this content. The comment block before every method is added by PrestaShop and is how it knows later which module owns the method; other comments of the module file are removed.

<?php
class FrontController extends FrontControllerCore
{
    /*
    * module: pcfe46
    * date: 2026-10-27 16:53:37
    * version: 1.0.0
    */
    public function initContent()
    {
        parent::initContent();
        header('X-Pcfe46-Override: 1.0.0');
    }
}

Every front page then answered with X-Pcfe46-Override: 1.0.0. The same applies to properties and constants: each gets its own module/date/version block. The class index is regenerated, and the new file is created together with an index.php in each folder it needed. If an override file already exists for that class (another module, or the merchant), the module’s methods are merged into it. If the same method, property or constant already exists in that file, installation stops.

Two modules, one class

pcfe46b first shipped an override of the same method, FrontController::initContent, with a different header. Installing it failed, and since reset is an uninstall plus an install, the module ended up uninstalled:

Cannot install module pcfe46b. The override file /var/www/html/modules/pcfe46b/override/classes/controller/FrontController.php conflicts with an existing override in /var/www/html/override/classes/controller/FrontController.php.

The failure printed by the install of 9.2.0 names both files and leaves the existing override untouched. Changing the second module to override a different method, initHeader, installed without error and produced one merged file with two blocks, each stamped with its own module name. Both headers were then sent on every page. In other words two modules can share an overridden class only if they override different methods, properties and constants, and neither can know that in advance, which is the practical meaning of "exclusive".

Uninstalling pcfe46 removed only its own method block from the shared file; the pcfe46b block stayed. When the last method was gone (after pcfe46b was uninstalled too), the file and its now empty folder were removed from override/. This is why a manual edit of an override file (adding a method by hand without the comment block) is dangerous: the uninstall cannot tell which module owns it.

Overriding a module

Because the main class of another module is just a class, pcfe46b can ship override/modules/pcfe45classic/pcfe45classic.php:

<?php

class Pcfe45classicOverride extends Pcfe45classic
{
    public function hookDisplayProductAdditionalInfo($params)
    {
        return '<p class="pcfe46b-wrapped">' . parent::hookDisplayProductAdditionalInfo($params) . '</p>';
    }
}

After the install, the product page printed the classic badge of Module Translations wrapped in the paragraph. The docs add that you should clear the cache after adding such an override and that the pattern "is not recommended, since the exact usage of a module to be overridden is unknown": the other module’s author may rename the method in the next release.

Refreshing an override on upgrade

Overrides are copied to the shop only by install(), never by an upgrade. When version 1.2.0 changed the header text of the override, prestashop:module upgrade alone would have left the old file in place and the shop would still send X-Pcfe46-Override: 1.0.0. That is why upgrade-1.2.0.php calls $module->removeOverride('FrontController') and then $module->addOverride('FrontController'), both public methods of Module. The test confirmed that after the upgrade the override file carried version: 1.2.0 in its comment block and the header was 1.2.0.

Console flags and the performance setting

prestashop:module install|uninstall|reset|... <name> --skip-overrides is documented as "Skip installing/uninstalling module overrides". On 9.2.0, install --skip-overrides really did install the module without creating the override (no block for the module in the shop file), but uninstall --skip-overrides still removed the module’s override block. If you need to keep an override after uninstalling a module, copy the file elsewhere first.

If the module’s override/ folder disappears from disk before the module is uninstalled, the override stays behind in override/classes/, because uninstall reads the module’s own file to know what to remove. Delete the file and var/cache/<env>/class_index.php by hand in that case.

The Performance page of the back office has a switch "Disable all overrides" (the setting PS_DISABLE_OVERRIDES). In 9.2.0 the code that reads this key is the back office template lookup of the legacy AdminController; the form clears the class index when saved. Setting the key to 1 from the console and rebuilding the class index did not stop the class override above from running (the header was still sent), so do not rely on the switch as a kill switch for a misbehaving class override; uninstall the module or remove the file from override/ instead, then clear the cache.

Maintenance risks

An override copies the signature and behaviour of a core method at the time it was written. When the core changes the method, the override silently runs with the old assumptions or breaks with a fatal error. After every PrestaShop update, rerun the override’s scenarios, and compare the methods you override with the new core version. The files under override/ are the inventory of what the shop carries; the comment blocks name the module, the date and the version of every method.

Alternatives to overrides

Ways to change PrestaShop behaviour, from least to most invasive: hook, service decoration, service replacement, class override, editing core files; with the Pocket Coffee example run for each

The official docs name the same ladder as the figure: hooks, extending classes, extending controllers, Symfony service overrides and decorations, and a contribution to the core when none of these is enough. Each was run for Pocket Coffee.

A hook instead of the override

The override above only adds an HTTP header after the controller content is initialised. The hook actionFrontControllerInitAfter is executed by every front controller at that point, so the same effect needs no core class at all: the module registers the hook (in install() and in the 1.2.0 script, as shown above) and sends the header in hookActionFrontControllerInitAfter. The shop answered with X-Pcfe46-Hook: 1.2.0 next to the override’s header. Hooks of the same name from many modules coexist, so this is always the first choice. When the existing hooks do not expose what you need, propose a new hook in the core repository (the docs encourage a pull request).

Subclassing instead of overriding

A class that only adds helpers can extend the core class under its own name (class MyCustomTools extends Tools) and be used from the module’s own code. Nothing is replaced, so every other module keeps seeing the original, and the class lives in the module’s src/ folder. Likewise, a controller of the module can extend a core controller to reuse its features. This was not run here, because it needs no PrestaShop mechanism.

Decorating a service

PrestaShop 9 builds a large part of the back office on the Symfony container, and a module can modify the container definition through its config/services.yml. Replacing a service means declaring your own definition under the existing id: the original disappears and every consumer gets the replacement. Decorating it means declaring a new service that wraps the old one: the container gives the decorator the original service under the id <decorator id>.inner, and every consumer of the original id gets the decorator.

The documentation example decorates prestashop.core.b2b.b2b_feature. On 9.2.0 that id is an alias that is deprecated since 9.0 (the container definition says so) pointing at the class id PrestaShop\PrestaShop\Core\B2b\B2bFeature, which is final and implements FeatureInterface. The real, supported id is the class, and the decorator must implement the interface, not extend the class. The decorator counts how often the shop asks whether B2B is active, and declares itself with the Symfony attribute (the core uses the same attribute in its own API code):

<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfe46\Decorator;

use PrestaShop\PrestaShop\Core\Feature\FeatureInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

/**
 * Wraps the B2B feature switch and counts how often the shop asks whether it is active.
 */
#[AsDecorator(decorates: 'PrestaShop\PrestaShop\Core\B2b\B2bFeature')]
final class CountingB2bFeature implements FeatureInterface
{
    private static int $calls = 0;

    public function __construct(
        #[AutowireDecorated]
        private readonly FeatureInterface $inner
    ) {
    }

    public static function calls(): int
    {
        return self::$calls;
    }

    public function isUsed()
    {
        return $this->inner->isUsed();
    }

    public function isActive()
    {
        ++self::$calls;

        return $this->inner->isActive();
    }

    public function enable()
    {
        $this->inner->enable();
    }

    public function disable()
    {
        $this->inner->disable();
    }

    public function update($status)
    {
        $this->inner->update($status);
    }
}

A small back office controller shows that a consumer receives the decorator. It asks for the service by its class id but types the parameter with the interface, because a consumer that type-hints the final class would reject the decorator:

<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfe46\Controller;

use PrestaShop\Module\Pcfe46\Decorator\CountingB2bFeature;
use PrestaShop\PrestaShop\Core\B2b\B2bFeature;
use PrestaShop\PrestaShop\Core\Feature\FeatureInterface;
use PrestaShopBundle\Controller\Admin\PrestaShopAdminController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;

class DiagnosticsController extends PrestaShopAdminController
{
    public function indexAction(
        #[Autowire(service: B2bFeature::class)]
        FeatureInterface $b2b
    ): JsonResponse {
        $active = $b2b->isActive();

        return new JsonResponse([
            'service_class' => $b2b::class,
            'b2b_active' => $active,
            'calls_in_this_request' => CountingB2bFeature::calls(),
        ]);
    }
}
services:
  _defaults:
    public: false
    autowire: true
    autoconfigure: true

  PrestaShop\Module\Pcfe46\Controller\DiagnosticsController: ~

  # Decoration through the #[AsDecorator] attribute on the class
  PrestaShop\Module\Pcfe46\Decorator\CountingB2bFeature: ~
pcfe46_diagnostics:
  path: /pcfe46/diagnostics
  methods: [GET]
  defaults:
    _controller: 'PrestaShop\Module\Pcfe46\Controller\DiagnosticsController::indexAction'
    _legacy_controller: AdminPcfe46
    _legacy_link: AdminPcfe46

After bin/console cache:clear, bin/console debug:container 'PrestaShop\PrestaShop\Core\B2b\B2bFeature' reported the class id as a private alias of PrestaShop\Module\Pcfe46\Decorator\CountingB2bFeature, with the tag container.decorator (id and inner service named), and listed the core consumers (CustomerType, the customer grid factory, the customer form data provider and handler) among its users. The route returned:

{"service_class":"PrestaShop\\Module\\Pcfe46\\Decorator\\CountingB2bFeature","b2b_active":false,"calls_in_this_request":1}

and the Customers page, which consumes the same service, still rendered. The same decoration can be written without the attribute, in YAML, with decorates: and arguments: ['@.inner'].

The caveats from the docs are real. The decorator must stay compatible with every consumer: if a consumer type-hints an interface, implement it; if it type-hints a class that is not final, extend the class. If you need to know the id of a service, bin/console debug:container <part of the name> lists it. A replacement (same id, new class) is documented but was not run: it removes the original, so only one module can do it.

Decorating a core controller

Back office controllers are services, so they can be decorated too. The docs show the pattern for the Design  Pages controller; on 9.2.0 the action has a long signature with autowired attributes, so the decorator extends CmsPageController to keep the type, receives the original as .inner, repeats the signature of the action it wants to change (the route resolves the action arguments from the decorator’s method) and delegates:

<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfe46\Decorator;

use PrestaShop\PrestaShop\Core\CMS\CmsPageViewDataProviderInterface;
use PrestaShop\PrestaShop\Core\Grid\GridFactoryInterface;
use PrestaShop\PrestaShop\Core\Search\Filters\CmsPageCategoryFilters;
use PrestaShop\PrestaShop\Core\Search\Filters\CmsPageFilters;
use PrestaShop\PrestaShop\Core\Util\HelperCard\DocumentationLinkProviderInterface;
use PrestaShopBundle\Controller\Admin\Improve\Design\CmsPageController;
use PrestaShopBundle\Security\Attribute\AdminSecurity;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

/**
 * Decorates the "Design > Pages" controller: same action, plus a response header.
 */
class CmsPagesControllerDecorator extends CmsPageController
{
    public function __construct(private readonly CmsPageController $inner)
    {
    }

    #[AdminSecurity("is_granted('read', request.get('_legacy_controller'))")]
    public function indexAction(
        CmsPageCategoryFilters $categoryFilters,
        CmsPageFilters $cmsFilters,
        Request $request,
        DocumentationLinkProviderInterface $helperBlockLinkProvider,
        #[Autowire(service: 'prestashop.core.cms_page.data_provider.cms_page_view')]
        CmsPageViewDataProviderInterface $cmsPageViewDataProvider,
        #[Autowire(service: 'prestashop.core.grid.factory.cms_page_category')]
        GridFactoryInterface $cmsCategoryGridFactory,
        #[Autowire(service: 'prestashop.core.grid.factory.cms_page')]
        GridFactoryInterface $cmsGridFactory,
    ): Response {
        $response = $this->inner->indexAction(
            $categoryFilters,
            $cmsFilters,
            $request,
            $helperBlockLinkProvider,
            $cmsPageViewDataProvider,
            $cmsCategoryGridFactory,
            $cmsGridFactory
        );
        $response->headers->set('X-Pcfe46-Decorated', 'cms-pages');

        return $response;
    }
}
  # Decoration through the decorates key: the original controller is injected as .inner
  PrestaShop\Module\Pcfe46\Decorator\CmsPagesControllerDecorator:
    decorates: PrestaShopBundle\Controller\Admin\Improve\Design\CmsPageController
    arguments: ['@.inner']

Requesting improve/design/cms-pages/ as the administrator rendered the normal "Pages" screen and the response carried X-Pcfe46-Decorated: cms-pages. No routing change was needed. The routes of the page still point at CmsPageController::indexAction; the container returns the decorator for that service id. The security attribute of the decorated action is repeated on the decorator so that the page keeps its permission check; whether it would be inherited without it was not tested. If you need to change the route itself, the docs give two further tools: remapping a route to a controller of your module (declare the same route name in config/routes.yml with _disable_module_prefix: true) and replacing the controller service entirely, which the docs advise against unless you rewrite the whole controller. Neither was run.

Theme template overrides

A theme may replace a module’s template without touching the module: put a file at the same relative path under themes/<theme>/modules/<module>/. For pcfe46 that is themes/hummingbird/modules/pcfe46/views/templates/hook/club.tpl:

<p class="pcfe46-club pcfe46-themed">Club members save {$clubPercent|intval}% on beans</p>

After clearing the compiled Smarty templates (var/cache/<env>/smarty/compile and .../cache) the product page printed this paragraph instead of the module’s. Without that clearing, the page still showed the module’s original output. This is the supported way for a theme to change what a module displays, and a module cannot do the opposite to a theme.

Choosing

Need Use

Run code when something happens, or print content in a hook position

A hook (action* or display*)

Change a Symfony service or a back office controller

Service decoration; replacement only if you own the whole behaviour

Add fields to a core entity

Own table and own ObjectModel, or the 9.2 Extra Properties (see Database and Entities)

Change what a module prints

A template override in the theme

Change a legacy core class that has no hook

A class override only in your own shop, with one module per class, and with a plan for every PrestaShop update

Distribute on Addons

No overrides

What was run and what was not

On PrestaShop 9.2.0 in Docker the following was executed and the outputs quoted above are real: install, upgrade (1.0.0 to 1.2.0 with two scripts, then version bumps with and without a script), a failing script and its recovery, a fresh install compared with the upgraded schema, override install, merge, conflict, uninstall and --skip-overrides, the module override, a hook alternative, the B2B decoration and the controller decoration, the theme template override, and php -l, lint:yaml and the legacy-link linter on the module. Not run: the Module Manager Upgrade button (browser), the Update Assistant, a script that fails after another module already defined the same function in the same request, an overridden controller of the back office, a service replacement, YAML decorates: on the B2B service, and PHPStan or php-cs-fixer on these modules.