Module Basics

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 supported and PHP 8.5.12 in the official Docker image) and checked on 2026-10-27. A module is the unit of extension of a self-hosted PrestaShop shop: it is available on every installation of the open-source edition, there are no plans or editions to choose from, and the same module can be written for 8.x and 9.x with the differences listed below. This page is the first of the module pages. It builds the smallest module that is still useful, explains every file and every attribute that the platform reads, and shows what the platform does on install, upgrade and uninstall. The next pages build on it: Hooks adds behaviour to the storefront and the back office, Module Configuration adds a settings page, Module Services and Admin Controllers adds Symfony controllers, and Module Upgrades, Overrides and Decoration goes deeper on versioning.

The running example is the Pocket Coffee shop. Its first extension is a module called pcfcbasics ("Pocket Coffee roast batches") that owns one table with the roast date of every batch of beans, two configuration keys, a stylesheet for the "roast date" badge and two hooks. The module in this page is small on purpose: it has one main class, one installer class and one upgrade script, and every line of it was installed, upgraded and removed on a real PrestaShop 9.2.0 shop. The technical name starts with pcfc to keep it apart from the other example modules of this section.

What a module is

A PrestaShop module is a folder under modules/ whose main PHP file defines a class that extends Module. The shop discovers it by scanning the folder, so a module needs no registration file: copying the folder is enough for it to appear in the module manager of the back office, in the state "not installed". Installing it records it in the ps_module table, creates its permissions, enables it for the shops in the current context and lets it do its own setup (tables, configuration keys, hooks, tabs). Everything that a module can do goes through one of four doors:

  • hooks, named extension points in the core and in themes, described in Hooks;

  • its own controllers and templates, front office controllers that extend ModuleFrontController and, since 9.0, back office controllers that are Symfony services;

  • services declared in config/services.yml, which also lets a module decorate or replace core services;

  • overrides, copies of core classes placed in override/, which are exclusive and discouraged (see Module Upgrades, Overrides and Decoration).

A module can also be a payment method (PaymentModule), a carrier, a theme companion or a widget. All of them are still modules, so everything on this page applies to them.

Naming rules

The folder name, the main file name and the value of $this->name must be the same string, and the main class is that string in PascalCase. The developer documentation says the technical name may only contain lowercase letters and digits ([a-z0-9]); underscores are accepted but strongly discouraged because they do not work with translation domains. The core modules that ship with the shop do use underscores (ps_checkout, ps_featuredproducts), so the rule is "discouraged" and not "forbidden".

Rule What the documentation says What was observed on 9.2.0

Characters of the technical name

Only [a-z0-9].

Validate::isModuleName() accepts [a-zA-Z0-9_-], so the installer is more permissive than the rule. Follow the documented rule anyway: the Addons validation and the translation domains depend on it.

Folder, main file and $this->name

Must be identical.

A module whose $this->name differed from its folder appeared in the module list under the value of $this->name, and prestashop:module install answered "The module …​ is invalid and cannot be loaded".

Main class name

Same as the module, in PascalCase (MyModule for mymodule).

PHP class names are case-insensitive, and Pcfcbasics for pcfcbasics is what the example uses. Keep the PascalCase form for readability and for the coding standard.

Prefix of what belongs to you

Prefix modules, configuration keys, Smarty variables, tables and CSS classes.

Used here as pcfcbasics, PCFCBASICS_*, ps_pcfcbasics_batch and .pcfcbasics-badge.

Zip archive

The archive must contain a single sub-folder named like the module.

Not packaged here.

Folder layout

File tree of the pcfcbasics module with the role of each file and folder

The next table lists everything the platform reads from a module folder. Only the main PHP file is mandatory.

Path Purpose

pcfcbasics.php

The main file, named like the folder. It holds the main class. Every PHP file of a module starts with a guard on _PS_VERSION_ so that it does nothing when requested directly.

config.xml

A cached copy of the module properties that makes the module list cheaper to build (see config.xml).

logo.png

The icon of the module list, a 32 by 32 pixel PNG. Use your own artwork or an icon you may redistribute; do not reuse the icon of a core module.

composer.json, vendor/

Composer metadata and the generated autoloader. Libraries can live in vendor, vendors, lib, libs, libraries or sdk.

src/

The recommended place for namespaced classes: installers, entities, grids, forms, CQRS handlers and Symfony controllers (src/Controller).

config/

services.yml and routes.yml for the Symfony container, plus config/admin/ and config/front/ for the back office and the light front office container. Since 9.2 the services file can also be services.php, services-9.2.yml or services-9.yml; only one file per folder is loaded, in that priority order.

controllers/

Legacy-style controllers: controllers/front for storefront pages and controllers/admin for legacy back office controllers.

views/

views/css, views/js, views/img, views/fonts for assets and views/templates/{hook,front,admin} for Smarty (.tpl) and Twig (.html.twig) templates.

translations/

Translation files; see Module Translations.

upgrade/

Upgrade scripts, one per version, described below.

override/

Core class overrides copied to the shop override/ folder on install.

index.php (in every folder)

A stub that redirects to the parent folder, so a directory listing never shows the contents.

Since PrestaShop 9.0 calling a PHP file of modules/ directly over HTTP is no longer a supported pattern (the change "Forbid sensitive files in modules directory"): requests go through a ModuleFrontController. On the 9.2.0 Docker image the modules/.htaccess file denies requests for .php, .log, .txt, .lock, .json, .yml, .yaml, .tpl, .twig and config.xml files, and a request for modules/pcfcbasics/pcfcbasics.php returned 403. Static assets such as views/css/badge.css are still served. This protection depends on Apache honouring .htaccess; on Nginx the same rules must be written by hand.

The main file and class

The main file has three parts: the guard, the optional autoloader and the class. The class below is the real module, version 1.1.0.

<?php

/**
 * Pocket Coffee roast batches: the smallest useful module skeleton.
 */
if (!defined('_PS_VERSION_')) {
    exit;
}

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

use PocketCoffee\PcfcBasics\Install\Installer;

class Pcfcbasics extends Module
{
    public function __construct()
    {
        $this->name = 'pcfcbasics';
        $this->tab = 'front_office_features';
        $this->version = '1.1.0';
        $this->author = 'Pocket Coffee';
        $this->need_instance = 0;
        $this->ps_versions_compliancy = [
            'min' => '9.0.0',
            'max' => '9.99.99',
        ];
        $this->bootstrap = true;
        $this->dependencies = [];

        parent::__construct();

        $this->displayName = $this->trans('Pocket Coffee roast batches', [], 'Modules.Pcfcbasics.Admin');
        $this->description = $this->trans('Stores the roast date of each bean batch.', [], 'Modules.Pcfcbasics.Admin');
        $this->confirmUninstall = $this->trans('Delete all roast batches?', [], 'Modules.Pcfcbasics.Admin');

        if (!Configuration::get('PCFCBASICS_FRESH_DAYS')) {
            $this->warning = $this->trans('The freshness window is not configured.', [], 'Modules.Pcfcbasics.Admin');
        }
    }

    public function isUsingNewTranslationSystem(): bool
    {
        return true;
    }

    public function install(): bool
    {
        if (Shop::isFeatureActive()) {
            Shop::setContext(Shop::CONTEXT_ALL);
        }

        $installer = new Installer();

        return parent::install()
            && $installer->createTables()
            && $installer->createConfiguration()
            && $this->registerHook('displayHeader')
            && $this->registerHook('displayFooterBefore');
    }

    public function uninstall(): bool
    {
        $installer = new Installer();

        return $installer->dropConfiguration()
            && $installer->dropTables()
            && parent::uninstall();
    }

    public function hookDisplayHeader(array $params): void
    {
        $this->context->controller->registerStylesheet(
            'module-pcfcbasics-badge',
            'modules/' . $this->name . '/views/css/badge.css',
            ['media' => 'all', 'priority' => 150]
        );
    }

    public function hookDisplayFooterBefore(array $params): string
    {
        if (!Configuration::get('PCFCBASICS_SHOW_LOT')) {
            return '';
        }

        return $this->fetch('module:' . $this->name . '/views/templates/hook/lot-notice.tpl');
    }
}

Three details of the constructor are easy to get wrong. The attributes name, tab, version and author must be assigned before parent::__construct(), because the parent constructor reads name to find the module in the cache and to load its id. The first call to $this->trans() must come after parent::__construct(), because translation needs the context that the parent sets up. And the constructor runs every time the module is instantiated, which includes every listing of the module manager when need_instance is 1, so it must stay cheap: no HTTP calls, no heavy queries.

The attributes the platform reads

Attribute Type and default Meaning

$this->name

string, required

The technical name; must equal the folder and the main file.

$this->tab

string, required

The section of the module manager. Known values: administration, advertising_marketing, analytics_stats, billing_invoicing, checkout, content_management, customer_reviews, dashboard, emailing, export, front_office_features, i18n_localization, market_place, merchandizing, migration_tools, mobile, others, payments_gateways, payment_security, pricing_promotion, quick_bulk_update, search_filter, seo, shipping_logistics, slideshows, smart_shopping, social_community and social_networks. A value that is not in the list creates a new section.

$this->version

string, required

The module version, shown in the list and compared with the stored one to decide whether an upgrade is pending. Use semantic versioning with three parts, so that upgrade/upgrade-1.1.0.php and the version agree.

$this->author

string, required

Shown as is in the list. author_uri (string, default empty) adds a link.

$this->need_instance

int, default 1

0 means the module class is not instantiated when the module list is built, which saves time. Keep 1 if the module must show a warning in the list; the list then instantiates it every time.

$this->ps_versions_compliancy

array min and max

The range of PrestaShop versions the module accepts. Outside it the installer refuses with "The version of your module is not compliant with your PrestaShop version." Set max to the last version of the newest major you support, 9.99.99 as in the devdocs tutorial, not to _PS_VERSION_, which also accepts the next major.

$this->bootstrap

bool, default false

true means the legacy configuration screen of the module is not wrapped in helper markup. Recommended for modules with a getContent() page; it has no effect on Symfony controllers.

$this->dependencies

array, default empty

Technical names of modules that must be installed first. Installation fails with "Before installing this module, you have to install this/these module(s) first" when one is missing.

$this->displayName, $this->description

string

Name and description of the module list; set them after parent::__construct() through trans().

$this->confirmUninstall

string, default empty

The confirmation text shown before an uninstall in the back office.

$this->warning

string, default empty

A warning shown in the module list when the module needs attention.

$this->limited_countries

array, default empty

Restricts the module to countries by ISO code (written to config.xml).

$this->controllers

array, default empty

Names of the front controllers of the module; on uninstall the shop removes their SEO meta entries.

$this->multistoreCompatibility

int constant, default MULTISTORE_COMPATIBILITY_UNKNOWN

One of MULTISTORE_COMPATIBILITY_NO (-20), NOT_CONCERNED (-10), UNKNOWN (0), PARTIAL (10) and YES (20); it declares how the module behaves in a multistore shop.

$this->module_key

string, default empty

The Addons licence key of a module sold on the marketplace; never commit a real one.

The method isUsingNewTranslationSystem() returns true so that the module uses translation domains such as Modules.Pcfcbasics.Admin and standard Symfony XLF files; the translation pages explain the rest. The signature of trans() on Module is trans($id, array $parameters = [], $domain = null, $locale = null) in 8.2.8 and in 9.2.0. The deprecated FrameworkBundleAdminController::trans($key, $domain, $parameters) has the domain second; the new PrestaShopAdminController::trans($id, $parameters, $domain, $locale) uses the standard Symfony order. Code ported from 8.x controllers must be checked for that swapped order.

What install does

parent::install() is not a stub: it is where the module becomes real. The order of its steps explains most installation errors. Every return false below leaves a message in $this->_errors, which the back office and the console print.

flowchart TD A[prestashop:module install pcfcbasics or the module manager] --> B[hook actionModuleInstallBefore] B --> C{Validate::isModuleName} C -- no --> X[error: module name is not valid] C -- yes --> D{ps_versions_compliancy covers the shop version} D -- no --> X2[error: version not compliant] D -- yes --> E{every dependency installed} E -- no --> X3[error: install these modules first] E -- yes --> F{already in ps_module} F -- yes --> X4[error: already installed] F -- no --> G[override conflict check, then install controllers] G --> H[INSERT into ps_module, enable for the shops in context] H --> I[create the four ROLE_MOD_MODULE_NAME_ permissions] I --> J[restrictions for customer groups, hook actionModuleInstallAfter] J --> K[back in your install: tables, configuration, registerHook]
  1. The hook actionModuleInstallBefore fires and an entry "Starting module install" is added to the log (ps_log, shown under Advanced Parameters, Logs).

  2. The technical name, the compliancy range and the dependencies are validated.

  3. A module that is already in ps_module is refused, and the override files of the module are checked for conflicts with overrides installed by other modules.

  4. The controllers of the module (controllers/) are registered, then a row is inserted into ps_module with the version, and the module is enabled for the shops in the current context (ps_module_shop).

  5. Four permission roles are created, ROLE_MOD_MODULE_PCFCBASICS_CREATE, _READ, _UPDATE and _DELETE, and granted to the profiles that may manage modules. These rows were present after install and gone after uninstall.

  6. Restrictions per customer group are added for every shop, actionModuleInstallAfter fires, and the module translations are updated.

Your own code in install() therefore runs after the module exists, which is why parent::install() && ... is the usual shape: if the parent fails, nothing of yours has run. If a step of yours fails after the parent succeeded, the module is installed but half configured. Wrap multi-step setup in a method that undoes its work, or make every step idempotent (CREATE TABLE IF NOT EXISTS, Configuration::updateValue) so that reset repairs it.

The installer class

Keeping the setup in a separate class makes install() readable and lets the upgrade script and the tests reuse it. The class lives in src/Install/Installer.php and is loaded by the Composer autoloader.

<?php

declare(strict_types=1);

namespace PocketCoffee\PcfcBasics\Install;

use Configuration;

if (!defined('_PS_VERSION_')) {
    exit;
}

/**
 * Creates and removes everything the module owns: its table and its configuration keys.
 */
final class Installer
{
    /** Every configuration key the module owns, including keys added by upgrade scripts. */
    private const CONFIGURATION_KEYS = [
        'PCFCBASICS_FRESH_DAYS' => 21,
        'PCFCBASICS_SHOW_LOT' => 0,
    ];

    public function createTables(): bool
    {
        return (bool) \Db::getInstance()->execute(
            'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'pcfcbasics_batch` (
                `id_batch` INT UNSIGNED NOT NULL AUTO_INCREMENT,
                `id_product` INT UNSIGNED NOT NULL,
                `roast_date` DATE NOT NULL,
                `lot_code` VARCHAR(32) NULL,
                PRIMARY KEY (`id_batch`),
                KEY `idx_product_roast` (`id_product`, `roast_date`)
            ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4'
        );
    }

    public function dropTables(): bool
    {
        return (bool) \Db::getInstance()->execute(
            'DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'pcfcbasics_batch`'
        );
    }

    public function createConfiguration(): bool
    {
        $ok = true;
        foreach (self::CONFIGURATION_KEYS as $key => $default) {
            $ok = \Configuration::updateValue($key, $default) && $ok;
        }

        return $ok;
    }

    public function dropConfiguration(): bool
    {
        $ok = true;
        foreach (array_keys(self::CONFIGURATION_KEYS) as $key) {
            $ok = \Configuration::deleteByName($key) && $ok;
        }

        return $ok;
    }
}

The rules that the example follows come straight from the good-practices page and are worth stating as a checklist:

  • create your own tables and never alter a core table; start CREATE TABLE with IF NOT EXISTS and DROP TABLE with IF EXISTS, so a repeated install or uninstall does not fail;

  • build table names with _DB_PREFIX_, the engine with _MYSQL_ENGINE_, and cast or escape every value you interpolate ((int), pSQL(), bqSQL()) as shown in Database and Entities;

  • prefix configuration keys with the module name in upper case (PCFCBASICS_FRESH_DAYS), store them with Configuration::updateValue() and remove them with Configuration::deleteByName();

  • in a multistore shop, install() first moves the context to all shops (Shop::setContext(Shop::CONTEXT_ALL)), so the defaults are written once for every shop; Shop::isFeatureActive() is false on a fresh single-shop install;

  • delete in uninstall() everything that install() and the upgrade scripts created: tables, configuration keys, admin tabs and files written outside the module folder.

The last rule was broken on purpose during development to see what happens. The first version of the installer removed only PCFCBASICS_FRESH_DAYS, while the 1.1.0 upgrade script had added PCFCBASICS_SHOW_LOT. After a reset (an uninstall followed by an install) the key PCFCBASICS_SHOW_LOT was still in ps_configuration: a key that an upgrade script creates is not removed by an uninstall() that does not know it. The list CONFIGURATION_KEYS above is the fix: install, upgrade and uninstall all read the same list.

Files that a module writes at run time should not go into the module folder: the documentation recommends var/cache/<env>/modules/<module>/ for cache files and var/modules/<module>/ for static files, because writing under modules/ makes distributed deployments harder and files outside the module survive an uninstall.

config.xml

config.xml is a small cached description of the module. Its fields are the name, display name, version, description, author, tab, an optional confirmation text, is_configurable, need_instance and, when the module is limited to a country, limited_countries. The module list reads this file instead of instantiating the class, which is why need_instance is 0 here.

<?xml version="1.0" encoding="UTF-8" ?>
<module>
    <name>pcfcbasics</name>
    <displayName><![CDATA[Pocket Coffee roast batches]]></displayName>
    <version><![CDATA[1.1.0]]></version>
    <description><![CDATA[Stores the roast date of each bean batch.]]></description>
    <author><![CDATA[Pocket Coffee]]></author>
    <tab><![CDATA[front_office_features]]></tab>
    <confirmUninstall><![CDATA[Delete all roast batches?]]></confirmUninstall>
    <is_configurable>0</is_configurable>
    <need_instance>0</need_instance>
</module>

The documentation says the file is generated when the module is installed. On 9.2.0 the file was not present right after prestashop:module install; it appeared the first time Module::getModulesOnDisk() ran, which is what the module manager does when it builds its list (the call was made here from a PHP script with the English language in context). Plan for both situations. Three practical consequences follow. First, shipping config.xml in the release lets the shop see the new version number of an updated zip before the module is instantiated, so upgrade scripts can run immediately after the download. Second, the listing considers the file outdated, and writes it again, when its modification time is older than the one of the main PHP file; so editing the main file refreshes the copy, but a config.xml that is newer than the main file keeps being used as it is. Third, the file is written in the language of the context: config.xml for English and config_xx.xml for the other languages (config_es.xml). is_configurable is not something you set in the constructor: the listing sets it to 1 when the class has a getContent() method, which is how the module manager decides to show a "Configure" button.

Composer

Composer gives the module an autoloader, a place for dependencies and an environment check. The composer.json of the example declares one PSR-4 namespace and, most importantly, turns off prepend-autoloader.

{
  "name": "pocketcoffee/pcfcbasics",
  "description": "Pocket Coffee roast batches (module skeleton example)",
  "type": "prestashop-module",
  "license": "AFL-3.0",
  "authors": [{ "name": "Pocket Coffee" }],
  "require": { "php": ">=8.1" },
  "autoload": {
    "psr-4": { "PocketCoffee\\PcfcBasics\\": "src/" }
  },
  "config": {
    "preferred-install": "dist",
    "prepend-autoloader": false
  }
}
# regenerate the optimized autoloader; the shop image has no Composer, so use a throw-away container
docker run --rm -v "$PWD:/app" -w /app composer:2 dump-autoload -o --no-dev

The rules that the documentation insists on:

  • Disable prepend-autoloader. By default the module’s autoloader is put in front of the core one, so a library bundled in vendor/ would win over the version PrestaShop itself needs and break the core. With false the core autoloader keeps priority.

  • Ship vendor/. The shop does not run Composer, so the release zip must contain the generated vendor/ folder. Run composer dump-autoload -o --no-dev before packaging.

  • Do not ship development dependencies. PHPUnit, PHPStan and similar tools are require-dev only; check that they are not in the packaged vendor/.

  • The main file requires vendor/autoload.php only when it exists, so the module still loads from a source checkout that has not run Composer yet, as in the guard at the top of the main class.

  • The conventional namespace is PrestaShop\Module\<ModuleName>; the example uses its own vendor prefix, which is equally valid. Use a unique prefix: two modules defining the same class name conflict at run time.

  • Since 9.0 the legacy PrestaShopAutoload class is gone, and the core no longer ships Guzzle or SwiftMailer. A module that relied on those libraries must require them itself or use the Symfony components that the core ships (symfony/http-client, symfony/mailer).

The upgrade script

When the version stored in ps_module is lower than $this->version, the shop looks in upgrade/ for scripts named upgrade-x.y.z.php whose version is higher than the installed one and runs them in order. Each file defines a function upgrade_module_x_y_z($module) that returns true on success. install() is not run again, so everything that the new version added (schema, configuration keys, hook registrations) belongs in the script.

<?php

if (!defined('_PS_VERSION_')) {
    exit;
}

/**
 * Runs once when an installed 1.0.x module is updated to 1.1.0.
 * install() is not executed again, so new schema, keys and hooks go here.
 */
function upgrade_module_1_1_0($module)
{
    return Db::getInstance()->execute(
        'ALTER TABLE `' . _DB_PREFIX_ . 'pcfcbasics_batch` ADD COLUMN `lot_code` VARCHAR(32) NULL AFTER `roast_date`'
    ) && Configuration::updateValue('PCFCBASICS_SHOW_LOT', 0)
      && $module->registerHook('displayFooterBefore');
}

The documentation example uses two-part names (upgrade-1.1.php and upgrade_module_1_1); three-part names work as well and are what this module used. The script above was run against a real 1.0.0 installation: after prestashop:module upgrade pcfcbasics the table gained lot_code, the key PCFCBASICS_SHOW_LOT existed, the hook displayFooterBefore was registered and ps_module.version read 1.1.0. A fresh install of 1.1.0 skips the script and creates the same objects through Installer, which is why the installer and the script must describe the same final state.

Driving the lifecycle from the console

Every action of the module manager has a console twin, which is the right tool for scripts and CI.

# inside the shop container, as the web server user
php bin/console prestashop:module install pcfcbasics      # also: uninstall enable disable reset upgrade configure delete
php bin/console prestashop:module upgrade pcfcbasics      # runs the pending upgrade scripts
php bin/console prestashop:module reset pcfcbasics        # uninstall followed by install
php bin/console prestashop:module:list                    # name, version, status of the installed modules
php bin/console prestashop:module:list --all              # include the modules that are not installed
php bin/console prestashop:module uninstall pcfcbasics --skip-overrides   # do not touch override files

install, uninstall, enable, disable, reset, upgrade, configure and delete are the eight actions of prestashop:module (the optional third argument is a YAML file with configuration for the configure action). prestashop:module:list appeared in 9.2 and accepts --all, --active, --disabled, --not-installed and --simple. The same operations are available in the back office under Modules, Module Manager, and by uploading a zip.

The difference between the verbs is worth knowing. Disable keeps the data and the tables, and hooks stop calling the module. Uninstall runs your uninstall() and then removes the module row, its hook registrations, its permissions and its group restrictions. Reset is the quickest way to rerun install() during development, but it also runs uninstall(), so it destroys the data of the module. Delete removes the module folder from disk.

Failure modes that were run

To see the error messages the installer produces, four throw-away modules were created from a ten-line skeleton and installed with the console. They were removed afterwards.

Defect Result of prestashop:module install

$this->name differs from the folder (pcfcbadx in folder pcfcbada)

"The module pcfcbada is invalid and cannot be loaded." The module list shows it under the wrong name.

ps_versions_compliancy max is 8.9.9

"The version of your module is not compliant with your PrestaShop version."

install() returns false

"Unfortunately, the module pcfcbadc did not return additional details." Fill $this->_errors[] to say why.

registerHook('nonExistingHookName')

Succeeds from the console: an unknown hook name is created in ps_hook and stays there after the module is uninstalled, so a typo in a hook name creates a permanent row. The missing hookNoSuchName() method is only logged in that run; a PHP script that booted the shop in debug mode got a PrestaShopModuleException for the same call (see Hooks).

Checks and coding standards

The module was checked with the tools that the 9.2.0 image offers, plus the PrestaShop coding standard run from a separate PHP 8.5 Composer container, because the shop image contains neither Composer nor development tools.

docker exec -u www-data -w /var/www/html ec-prestashop php -l modules/pcfcbasics/pcfcbasics.php
docker exec -u www-data -w /var/www/html ec-prestashop php -l modules/pcfcbasics/src/Install/Installer.php
docker exec -u www-data -w /var/www/html ec-prestashop php -l modules/pcfcbasics/upgrade/upgrade-1.1.0.php

# coding standard (PrestaShop rules for PHP CS Fixer), in a throw-away container
docker run --rm -v "$PWD:/app" -w /app composer:2 sh -c '
  composer require --dev -q prestashop/php-dev-tools friendsofphp/php-cs-fixer
  PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run --diff'

php -l reported "No syntax errors detected" for the main file, the installer and the upgrade script. The first run of PHP CS Fixer 3.95.27 with the PrestaShop configuration (.php-cs-fixer.dist.php generated by prestashop-coding-standards cs-fixer:init) flagged twelve files, all for style: a blank line after the opening tag, single quotes in the index.php stub, and global classes written with a leading backslash inside a namespace (\Db, \Configuration). After fix a second dry run reported nothing, and the listings on this page are the fixed files. The generated phpstan.neon of php-dev-tools 5.0 targets PHPStan 0.12 and did not start under PHPStan 2.x, so static analysis is not shown here.

Putting it together

The complete sequence of the run that produced this page was: write the files, build the autoloader, copy the folder into modules/, install, look at the database, switch a configuration key and read the home page, upgrade from a 1.0.0 variant, and uninstall.

php bin/console prestashop:module install pcfcbasics
# ps_module row, hooks displayHeader and displayFooterBefore, ps_pcfcbasics_batch, two PCFCBASICS_* keys, 4 roles
php bin/console prestashop:config set PCFCBASICS_SHOW_LOT --value=1
curl -s http://localhost:8080/ | grep pcfcbasics-lot
# <p class="pcfcbasics-lot">Every bag carries the lot code of its roast batch.</p>
php bin/console prestashop:module uninstall pcfcbasics
# no table, no PCFCBASICS_* key, no role, no hook row left

The stylesheet registered in displayHeader was found inside the combined theme stylesheet (themes/hummingbird/assets/cache/theme-*.css) because CCC (combine, compress and cache CSS) is enabled by default on new shops since 9.0; without CCC the stylesheet is linked on its own. The template used by displayFooterBefore is a Smarty file:

<p class="pcfcbasics-lot">{l s='Every bag carries the lot code of its roast batch.' d='Modules.Pcfcbasics.Shop'}</p>

What changes between 8.x and 9.x for module authors

  • FrameworkBundleAdminController is deprecated in 9.0 and removed in 10.0; back office controllers extend PrestaShopAdminController and are services (see Module Services and Admin Controllers). $this->get('translator') and $this->get('session') are gone from controllers.

  • The legacy product page and 18 of its hooks were removed in 9.0, together with 7 login hooks of the old AdminLoginController.

  • PrestaShopAutoload is removed; use Composer. Guzzle and SwiftMailer are no longer bundled.

  • ps_versions_compliancy should now read 'min' => '8.0.0' (or 9.0.0) and 'max' => '9.99.99', as the tutorial does.

  • PHP 8.1 is the minimum of 9.x, so promoted constructor properties, readonly, enums and match are available; PHP 8.1 itself reached the end of its security support on 2025-12-31 and 8.2 follows on 2026-12-31.

  • 9.2 adds services.php and services-9.2.yml as service files and the console commands prestashop:module:list, employee:create-admin, employee:change-password and htaccess:generate.

What was and was not run

Run on the local PrestaShop 9.2.0 Docker shop (Symfony 6.4.46, PHP 8.5.12, MySQL 8.4): the install, upgrade, reset and uninstall of pcfcbasics; the inspection of ps_module, ps_hook_module, ps_authorization_role, ps_configuration and the module table after each step; the front office page with the Smarty hook output; the four failure modes above; php -l; PHP CS Fixer with the PrestaShop standard. Not run: the module manager upload of a zip, the Addons marketplace validation (it needs an account), multistore installation with two shops, and PHPStan (see above).