Testing, Upgrades and Addons

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 2026-09-30, Symfony 6.4.46, PHP 8.5.12 in the official Docker image), PHPUnit 10.5.66, PHPStan 2.3.3, PHP CS Fixer 3.95.27, prestashop/php-dev-tools 5 and the Update Assistant module 7.6.6, and it was checked on 2026-10-27. It applies to the open-source, self-hosted PrestaShop; nothing here depends on a plan or an edition. It is the last page of the module path: after the pages on module basics and security, it explains how to prove that a module works, how to ship it and how to keep a shop up to date.

The running example is Pocket Coffee. The module pcfitesting holds the shop’s price rules: beans cost 10% less for members of the Coffee Club, shipping is a flat 4.90 EUR in Spain (free from 40 EUR) and 9.90 EUR in the rest of the European Union. The rules live in a small class without any PrestaShop dependency, so most of them can be tested without a shop, and the module class reads the free-shipping threshold from the shop configuration, so a smaller part needs a real shop. The page runs every tool against that module and shows the output.

What to test, and with which tool

A PrestaShop module is PHP code that runs inside a large legacy application, so a useful test strategy mixes checks that do not need the shop with checks that do. The table lists the layers in the order in which they should run in a pipeline, cheapest first.

Layer What it finds Needs a shop? Tool

Syntax check

Code that a supported PHP version cannot parse

No

php -l

Coding standard

Style differences from the PrestaShop standard

No

PHP CS Fixer with prestashop/php-dev-tools

Static analysis

Calls to methods or classes that do not exist in a given PrestaShop version, wrong types

The shop sources, not an installed shop

PHPStan

Unit tests

Logic errors in classes that do not touch PrestaShop

No

PHPUnit

Integration tests

Wrong use of the shop API, configuration, database

An installed shop with the module

PHPUnit with the shop bootstrapped

Console smoke test

Install, configure, disable, reset, upgrade and uninstall failures

An installed shop

bin/console prestashop:module

Behaviour tests

Business rules of the core (carts, discounts, prices)

The core checkout and a test database

Behat (core)

UI tests

Whole user journeys in a browser

An installed shop reachable over HTTP

Playwright (core)

The first six layers ran on a local PrestaShop 9.2.0 shop for this page; the last two are the core’s own suites, which are described from the developer documentation and were not run (see What was run and what was not). The official module documentation (modules/testing in the developer guide) was written for PHP 5.6 and PHPUnit 5.7 and still shows them in its examples; the commands below are the current equivalents.

The module under test

<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfitesting\Pricing;

/**
 * Pocket Coffee price rules. No PrestaShop class is used here, so the logic runs in a plain PHPUnit test.
 */
final class ClubPricing
{
    public const ZONE_SPAIN = 'ES';
    public const ZONE_EU = 'EU';

    private const SHIPPING = [
        self::ZONE_SPAIN => 4.90,
        self::ZONE_EU => 9.90,
    ];

    public function __construct(
        private readonly float $freeShippingFrom,
        private readonly float $clubRate = 0.10,
    ) {
    }

    /**
     * Beans are 10% cheaper for members of the Coffee Club.
     */
    public function beansUnitPrice(float $basePrice, bool $isMember): float
    {
        if ($basePrice < 0) {
            throw new \InvalidArgumentException('The price cannot be negative.');
        }

        return round($isMember ? $basePrice * (1 - $this->clubRate) : $basePrice, 2);
    }

    /**
     * Flat rate per zone, free from the configured subtotal in Spain.
     */
    public function shippingCost(string $zone, float $subtotal): float
    {
        if (!isset(self::SHIPPING[$zone])) {
            throw new \InvalidArgumentException(sprintf('Unknown shipping zone "%s".', $zone));
        }

        if ($zone === self::ZONE_SPAIN && $subtotal >= $this->freeShippingFrom) {
            return 0.0;
        }

        return self::SHIPPING[$zone];
    }
}
<?php

declare(strict_types=1);

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

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

use PrestaShop\Module\Pcfitesting\Pricing\ClubPricing;

class Pcfitesting extends Module
{
    public const CONFIG_FREE_SHIPPING_FROM = 'PCFITESTING_FREE_SHIPPING_FROM';

    public function __construct()
    {
        $this->name = 'pcfitesting';
        $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'];
        $this->bootstrap = true;

        parent::__construct();

        $this->displayName = $this->trans('Pocket Coffee club pricing', [], 'Modules.Pcfitesting.Admin');
        $this->description = $this->trans('Coffee Club price and shipping rules used by the testing examples.', [], 'Modules.Pcfitesting.Admin');
    }

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

    public function install(): bool
    {
        return parent::install()
            && Configuration::updateValue(self::CONFIG_FREE_SHIPPING_FROM, '40');
    }

    public function uninstall(): bool
    {
        return Configuration::deleteByName(self::CONFIG_FREE_SHIPPING_FROM) && parent::uninstall();
    }

    public function getClubPricing(): ClubPricing
    {
        return new ClubPricing((float) Configuration::get(self::CONFIG_FREE_SHIPPING_FROM));
    }
}

Every tool below is declared in composer.json as a development dependency and wrapped in a Composer script, so that the same one-word command runs locally, in a container and in CI:

{
  "name": "pocketcoffee/pcfitesting",
  "description": "Pocket Coffee club pricing, used by the testing examples",
  "type": "prestashop-module",
  "license": "AFL-3.0",
  "require": { "php": ">=8.1" },
  "require-dev": {
    "friendsofphp/php-cs-fixer": "^3.95",
    "phpstan/phpstan": "^2.3",
    "phpunit/phpunit": "^10.5",
    "prestashop/php-dev-tools": "^5.0"
  },
  "autoload": { "psr-4": { "PrestaShop\\Module\\Pcfitesting\\": "src/" } },
  "autoload-dev": { "psr-4": { "PrestaShop\\Module\\Pcfitesting\\Tests\\": "tests/" } },
  "config": { "prepend-autoloader": false, "sort-packages": true },
  "scripts": {
    "lint": "find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l -n | (! grep -v 'No syntax errors detected')",
    "cs": "php-cs-fixer fix --dry-run --diff",
    "cs-fix": "php-cs-fixer fix",
    "stan": "phpstan analyse --configuration=tests/phpstan/phpstan.neon --memory-limit=1G",
    "test": "phpunit"
  }
}

prestashop/php-dev-tools is at version 5, released on 2024-05-23. It installs only the command vendor/bin/prestashop-coding-standards (with the sub-commands cs-fixer:init and phpstan:init) and the PHPStan extension files; it does not install PHP CS Fixer or PHPStan itself, which the project requires separately. Its optional dependency prestashop/header-stamp (the tool that writes license headers into every file) could not be installed together with PHP CS Fixer 3.95, because it supports only Symfony Console up to 6.0 while PHP CS Fixer needs 7.x; if you need license headers, run it from a separate Composer project. The module code and the vendor/ folder of the development checkout must stay out of the production archive, see Packaging and distribution.

Syntax check

The first check is the cheapest: ask PHP to parse every file. Run it with the oldest and the newest PHP version the module claims to support, because a module that uses PHP 8.4 syntax will break a shop that still runs 8.1 (PrestaShop 9.x still accepts 8.1 as its minimum). The Composer script wraps the command of the developer documentation:

composer lint
# which runs
find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l -n | (! grep -v 'No syntax errors detected')

The command prints nothing and exits with code 0 when every file parses; the final grep -v inverts the exit status so that any line other than "No syntax errors detected" fails the script. It passed on PHP 8.1.34, 8.3.36 and 8.5.12 (the module code and the test suite were also run on all three, see below).

Coding standard

PrestaShop modules follow the same coding standard as the core, which is distributed as a configuration for PHP CS Fixer by prestashop/php-dev-tools. The initialisation command copies a ready-made configuration file into the module:

composer require --dev prestashop/php-dev-tools friendsofphp/php-cs-fixer
php vendor/bin/prestashop-coding-standards cs-fixer:init
# File ".php-cs-fixer.dist.php" copied to "./.php-cs-fixer.dist.php"

The generated file is small. It creates the PrestaShop configuration object, takes its finder and restricts it to the module; the line that excludes dist is an addition, so that the files staged for the archive are not analysed:

<?php

$config = new PrestaShop\CodingStandards\CsFixer\Config();

/** @var \Symfony\Component\Finder\Finder $finder */
$finder = $config->setUsingCache(true)->getFinder();
$finder->in(__DIR__)->exclude(['vendor', 'dist']);

return $config;

On the first run of composer cs (a dry run that prints a diff), 8 of the 8 files of the module needed changes. The rules that fired were a blank line after the opening PHP tag before declare(strict_types=1) and single-quoted strings:

--- /app/src/Pricing/ClubPricing.php
+++ /app/src/Pricing/ClubPricing.php
@@ -1,4 +1,5 @@
 <?php
+
 declare(strict_types=1);

 namespace PrestaShop\Module\Pcfitesting\Pricing;

--- /app/src/index.php
+++ /app/src/index.php
@@ -1,3 +1,4 @@
 <?php
-header("Location: ../");
+
+header('Location: ../');
 exit;

Found 8 of 8 files that can be fixed in 0.048 seconds, 20.00 MB memory used

composer cs-fix applied the changes ("Fixed 8 of 8 files"), a second composer cs reported "Found 0 of 8 files that can be fixed", and the command exits with a non-zero status whenever a file would change, which makes the dry run suitable for CI. The listings in this page are the files after the fixer. PHP CS Fixer 3.95.27 ran on PHP 8.5.12 without needing its environment override. The 1.7-era documentation names the file .php_cs.dist, the current one is .php-cs-fixer.dist.php.

Static analysis with PHPStan

PHPStan reads the code without running it. For a module its value is that it can analyse the module against the sources of a particular PrestaShop version and report calls to methods that do not exist there, which is the usual way a module breaks between versions. The developer guide’s example is a module that ran on 1.7.0.3 and called a core method that had been removed.

The module’s own classes extend core classes (Module) that PHPStan cannot find in the module folder, so the analysis needs the PrestaShop sources and a bootstrap file that loads the shop’s autoloader. prestashop/php-dev-tools provides both: the extension file vendor/prestashop/php-dev-tools/phpstan/ps-module-extension.neon (bootstrap, stubs for Module and Tab, and the constants that differ per version) and a bootstrap that reads the environment variable _PS_ROOT_DIR_ with the path of the shop. The command phpstan:init copies a template of the configuration into tests/phpstan/, but that template is written for PHPStan 0.12 and does not load in PHPStan 2: it still uses the option excludes_analyse, which was renamed excludePaths, and relative paths are resolved from the folder of the .neon file, so vendor/ pointed to tests/phpstan/vendor. On PHPStan 2.3.3 the following file works:

includes:
  - %currentWorkingDirectory%/vendor/prestashop/php-dev-tools/phpstan/ps-module-extension.neon

parameters:
  # PHPStan 2: "excludes_analyse" became "excludePaths", and relative paths are resolved from this file,
  # so the module root is written with %currentWorkingDirectory%
  paths:
    - %currentWorkingDirectory%
  excludePaths:
    - %currentWorkingDirectory%/vendor
    - %currentWorkingDirectory%/tests
    - %currentWorkingDirectory%/dist
  reportUnmatchedIgnoredErrors: false
  level: 6

The PrestaShop sources can come from a running shop or, better for CI, from the official image of the version to test. The container does not need an installed shop, but the files must be on a named volume (a container without a volume exposes nothing through --volumes-from):

# files of the version under test, on a volume, from an image that is never installed
# (the entrypoint copies the shop to the volume on the first start)
docker run -d --name ps -v ps-files:/var/www/html prestashop/prestashop:9.2.0
until docker exec ps test -f /var/www/html/config/defines.inc.php; do sleep 2; done

# analyse the module against those files
docker run --rm --volumes-from ps -v "$PWD:/module" -w /module \
  -e _PS_ROOT_DIR_=/var/www/html \
  composer:2 php -d memory_limit=1G vendor/bin/phpstan analyse \
  --configuration=tests/phpstan/phpstan.neon --no-progress

Result on PrestaShop 9.2.0 with level 6 (the same run against the image prestashop/prestashop:9.1.5 printed "Detected PS version 9.1.5" and no errors):

Detected PS version 9.2.0

 [OK] No errors

Three findings from running it:

  • At level max the same module reported two errors, both in the constructor: "Property ModuleCore::$displayName (string) does not accept mixed" (and the same for $description), because the core trans() method is typed to return mixed. The developer guide warns that inconsistencies in the core PHPDoc may prevent a module from reaching the highest level; choose a level that the code passes and raise it with a baseline when you can.

  • A deliberate error shows the kind of report PHPStan produces. Replacing the body of getClubPricing() with a call to a method that does not exist and a wrong argument type gave: "Call to an undefined static method Configuration::getValueTwice()" (identifier staticMethod.notFound) and "Parameter #2 $clubRate of class PrestaShop\Module\Pcfitesting\Pricing\ClubPricing constructor expects float, string given" (identifier argument.type). The code was restored afterwards.

  • When the PrestaShop root was mounted read-only (--volumes-from ps:ro) and the file var/cache/prod/class_index.php did not exist, the bootstrap failed with "Cannot rename …​ to /var/www/html/var/cache/prod/class_index.php: Read-only file system", because the legacy autoloader writes its class index there. Either mount the volume read-write, as above, or keep it read-only and mount a temporary file system over the cache folder with --tmpfs /var/www/html/var/cache:rw,mode=1777.

To test several versions, run the job once per image tag (the matrix in the CI workflow below uses 9.2.0 and 9.1.5). The image prestashop/prestashop:nightly (updated daily) tracks the development branch, which was already 9.3.0 at the check date, and is a cheap early warning for removed methods.

Unit tests with PHPUnit

The core runs PHPUnit 10.5 and the developer guide asks module authors to base unit tests on PHPUnit\Framework\TestCase only, and to put the logic that can be isolated in classes that do not use ObjectModels, the context or the Db singleton. That is why ClubPricing is a plain class with its dependencies in the constructor. The test uses PHPUnit 10 attributes for data providers:

<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfitesting\Tests\Unit\Pricing;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use PrestaShop\Module\Pcfitesting\Pricing\ClubPricing;

final class ClubPricingTest extends TestCase
{
    private ClubPricing $pricing;

    protected function setUp(): void
    {
        $this->pricing = new ClubPricing(40.0);
    }

    /**
     * @return iterable<string, array{float, bool, float}>
     */
    public static function beansPrices(): iterable
    {
        yield 'guest pays the list price' => [8.90, false, 8.90];
        yield 'member 250 g' => [8.90, true, 8.01];
        yield 'member 1 kg' => [29.90, true, 26.91];
        yield 'member Ethiopia 250 g' => [9.90, true, 8.91];
    }

    #[DataProvider('beansPrices')]
    public function testBeansUnitPrice(float $base, bool $member, float $expected): void
    {
        self::assertSame($expected, $this->pricing->beansUnitPrice($base, $member));
    }

    public function testNegativePriceIsRejected(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->pricing->beansUnitPrice(-1.0, true);
    }

    /**
     * @return iterable<string, array{string, float, float}>
     */
    public static function shippingCases(): iterable
    {
        yield 'Spain below the threshold' => [ClubPricing::ZONE_SPAIN, 39.99, 4.90];
        yield 'Spain at the threshold' => [ClubPricing::ZONE_SPAIN, 40.0, 0.0];
        yield 'EU is never free' => [ClubPricing::ZONE_EU, 100.0, 9.90];
    }

    #[DataProvider('shippingCases')]
    public function testShippingCost(string $zone, float $subtotal, float $expected): void
    {
        self::assertSame($expected, $this->pricing->shippingCost($zone, $subtotal));
    }

    public function testUnknownZoneIsRejected(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->pricing->shippingCost('US', 10.0);
    }
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         cacheDirectory=".phpunit.cache"
         failOnWarning="true"
         failOnRisky="true">
  <testsuites>
    <testsuite name="unit">
      <directory>tests/Unit</directory>
    </testsuite>
  </testsuites>
  <source>
    <include>
      <directory>src</directory>
    </include>
  </source>
</phpunit>

failOnWarning and failOnRisky turn the two classes of problems that tend to be ignored into failures. The suite runs with composer test:

PHPUnit 10.5.66 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.12
Configuration: /app/phpunit.xml

.........                                                           9 / 9 (100%)

Time: 00:00.011, Memory: 8.00 MB

Club Pricing (PrestaShop\Module\Pcfitesting\Tests\Unit\Pricing\ClubPricing)
 ✔ Beans unit price with data set "guest pays the list price"
 ✔ Beans unit price with data set "member 250 g"
 ✔ Beans unit price with data set "member 1 kg"
 ✔ Beans unit price with data set "member Ethiopia 250 g"
 ✔ Negative price is rejected
 ✔ Shipping cost with data set "Spain below the threshold"
 ✔ Shipping cost with data set "Spain at the threshold"
 ✔ Shipping cost with data set "EU is never free"
 ✔ Unknown zone is rejected

OK (9 tests, 9 assertions)

The same lint and the same nine tests also passed on PHP 8.1.34 and 8.3.36 in php:8.1-cli and php:8.3-cli containers that used the vendor/ folder installed with 8.5, which is what the CI matrix below automates. Code coverage needs a coverage driver that the Composer image does not have; the developer guide for the core runs XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text with Xdebug installed, and the same applies to a module (not run here).

For more on PHPUnit itself, see the PHP section on testing with PHPUnit and Pest, on static analysis and on PSR standards and coding style.

Integration tests inside a shop

Some behaviour only exists inside the shop: configuration values, the module manager, the database. For those tests PHPUnit runs in the shop’s PHP process, after the bootstrap has loaded the shop’s configuration, autoloader and database connection. The module checkout is placed in modules/pcfitesting of an installed shop (as in a real development environment, with the folder mounted or copied), so the shop is two levels above the tests and no second copy of the module exists.

<?php

declare(strict_types=1);

// The module checkout lives in modules/pcfitesting of an installed shop, so the shop is two levels up.
$root = getenv('_PS_ROOT_DIR_') ?: dirname(__DIR__, 4);

if (!is_file($root . '/config/config.inc.php')) {
    fwrite(STDERR, "Run the tests from modules/pcfitesting of an installed shop, or define _PS_ROOT_DIR_.\n");
    exit(1);
}

require_once $root . '/config/config.inc.php';
require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/../../pcfitesting.php';
<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfitesting\Tests\Integration;

use Module;
use PHPUnit\Framework\TestCase;

/**
 * Needs a shop where the module is installed: see phpunit.integration.xml.
 */
final class ModuleTest extends TestCase
{
    private string|false $previous;

    protected function setUp(): void
    {
        $this->previous = \Configuration::get(\Pcfitesting::CONFIG_FREE_SHIPPING_FROM);
    }

    protected function tearDown(): void
    {
        \Configuration::updateValue(\Pcfitesting::CONFIG_FREE_SHIPPING_FROM, $this->previous);
    }

    public function testModuleIsInstalled(): void
    {
        self::assertTrue(\Module::isInstalled('pcfitesting'));
        self::assertInstanceOf(\Pcfitesting::class, \Module::getInstanceByName('pcfitesting'));
    }

    public function testThresholdComesFromTheShopConfiguration(): void
    {
        $module = \Module::getInstanceByName('pcfitesting');
        self::assertInstanceOf(\Pcfitesting::class, $module);

        \Configuration::updateValue(\Pcfitesting::CONFIG_FREE_SHIPPING_FROM, '50');
        self::assertSame(4.90, $module->getClubPricing()->shippingCost('ES', 45.0));

        \Configuration::updateValue(\Pcfitesting::CONFIG_FREE_SHIPPING_FROM, '40');
        self::assertSame(0.0, $module->getClubPricing()->shippingCost('ES', 45.0));
    }
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="tests/Integration/bootstrap.php"
         colors="true"
         cacheDirectory=".phpunit.cache"
         failOnWarning="true">
  <testsuites>
    <testsuite name="integration">
      <directory>tests/Integration</directory>
    </testsuite>
  </testsuites>
</phpunit>

The second tests changes a configuration key of the module and restores it in tearDown(), so the suite leaves the shop as it found it. It ran inside the shop container, as the web server user, with the development dependencies present in the module folder:

docker exec -u www-data -w /var/www/html/modules/pcfitesting ec-prestashop \
  php vendor/bin/phpunit -c phpunit.integration.xml --testdox
Module (PrestaShop\Module\Pcfitesting\Tests\Integration\Module)
 ⚠ Module is installed
 ✔ Threshold comes from the shop configuration

OK, but there were issues!
Tests: 2, Assertions: 5, Deprecations: 14.

The 14 deprecations were reported by PHPUnit for the first test, and --display-deprecations showed that they come from the vendor code of another bundled module (psxmarketingwithgoogle, "Using ${var} in strings is deprecated"), loaded when the module manager scans the modules, not from pcfitesting. Do not set failOnDeprecation in an integration suite that boots a whole shop. Two environment details cost time and are worth knowing:

  • Running PHPUnit with a PHP that has no database extension fails in the bootstrap with "Cannot select any valid SQL engine". The composer:2 image has no mysqli or pdo_mysql, so the integration suite has to run in the shop’s own image or in an image that adds those extensions.

  • Loading the module’s Composer autoloader twice, from a copy of the module outside the shop and from the installed copy, fails with "Cannot redeclare class ComposerAutoloaderInit…​", because both copies have the same hash. Test the module where it is installed.

Smoke tests from the command line

The cheapest integration test needs no PHP test code: use the module commands of the console to install, configure and reset the module and read the result. The developer guide introduced them for continuous integration of modules (from 1.7.2):

bin/console prestashop:module install pcfitesting
bin/console prestashop:config get PCFITESTING_FREE_SHIPPING_FROM
bin/console prestashop:module configure pcfitesting
bin/console prestashop:config get PCFITESTING_FREE_SHIPPING_FROM
bin/console prestashop:module disable pcfitesting
bin/console prestashop:module enable pcfitesting
bin/console prestashop:module reset pcfitesting
bin/console prestashop:config get PCFITESTING_FREE_SHIPPING_FROM
bin/console prestashop:module:list

The configure action applies a self-configuration file. When no file is given, the command looks for a file named self_config.yml in the module folder and its sub-folders. The file of the module sets the threshold to 45 EUR:

# Applied by: bin/console prestashop:module configure pcfitesting
configuration:
  update:
    PCFITESTING_FREE_SHIPPING_FROM: 45

Results on PrestaShop 9.2.0, run in this order after installing the module from the archive built below: install "succeeded"; the key was 40; "Configuration successfully applied" and the key became 45; disable, enable and reset all "succeeded"; and after reset (which uninstalls and installs again) the key was 40 again. prestashop:module:list showed pcfitesting 1.0.0 Enabled. A self-configuration file can also update or delete Configuration keys, run .sql files (paths are relative to the file), copy files or URLs, and execute a PHP class that implements ModuleComplexConfigurationInterface; the actions work per shop with --id_shop and --id_shop_group. The upgrade action, which runs the scripts of the upgrade/ folder, is the subject of the page on module upgrades.

The core test suites: PHPUnit, Behat and UI tests

The core repository has three families of tests in the folders tests/Unit, tests/Integration and tests/UI. They matter to module developers for two reasons: a module can be installed in a checkout of the core and the suites run against it, and the way the core writes tests is the style the community expects. This section is based on the developer guide; it was not run, because it needs a checkout of the core repository and a test database (see What was run and what was not).

Command (from the core checkout) Runs

composer unit-tests

The PHPUnit unit suite, configured by tests/Unit/phpunit.xml

composer integration-tests

The PHPUnit integration suite

composer integration-behaviour-tests

The Behat suite

composer test-all

All PHPUnit suites

composer create-test-db

Creates the database that the integration and Behat suites need

A single class or method can be run with vendor/bin/phpunit -c tests/Unit/phpunit.xml <file> --filter <method>; --debug without --filter lists the names to filter by.

Behat describes behaviour in Gherkin scenarios that business people can read, and maps each step to a method of a feature context class. Scenarios are in tests/Integration/Behaviour/Features/Scenario, contexts in tests/Integration/Behaviour/Features/Context, contexts extend AbstractPrestaShopFeatureContext and are listed in tests/Integration/Behaviour/behat.yml. The run command is php -d date.timezone=UTC ./vendor/bin/behat -c tests/Integration/Behaviour/behat.yml --name="free shipping", where --name selects scenarios. When a step is not defined, Behat offers to generate a snippet. The developer guide’s tutorial builds this scenario for a cart rule with free shipping:

Scenario: With free shipping voucher, there is no shipping fees
  Given I have an empty default cart
  And there is a product in the catalog named "product1" with a price of 50.0 and 1000 items in stock
  And there is a zone named "North America"
  And there is a country named "country1" and iso code "US" in zone "North America"
  Given there is a carrier named "carrier1"
  And carrier "carrier1" applies shipping fees of 5.0 in zone "North America" for price between 0 and 150
  Given there is a cart rule named "free4behat" that applies no discount with priority 4, quantity of 1000 and quantity per user 1000
  And cart rule "free4behat" offers free shipping
  And cart rule "free4behat" has a discount code "free4behat"
  When I add 1 items of product "product1" in my cart
  Then my cart total should be 57.0 tax included
  When I use the discount "free4behat"
  Then my cart total should be 50.0 tax included

UI tests drive a browser as a customer or an employee. The stack is Playwright (automation), Mocha (test framework), Chai (assertions) and Faker (data), and the tests need a working shop. The README in tests/UI of the core explains the setup; one test or folder is selected with TEST_PATH:

TEST_PATH="sanity/02_productsBO/01_filterProducts" URL_FO="https://domain.tld/" npm run test:specific
TEST_PATH="sanity/02_productsBO/*" URL_FO="https://domain.tld/" npm run test:specific

The core’s nightly QA dashboard (https://nightly.prestashop-project.org) runs the campaigns sanity, functional, e2e, regression and autoupgrade in Chromium, Edge and Firefox against the nightly builds. The dashboard listed develop, 9.0.x, 8.2.x, 8.1.x, 8.0.x and the 1.7.8.x and 1.7.7.x branches at the check date, not 9.1 or 9.2. Two documentation inconsistencies to be aware of: the page "How to execute unit tests" still calls the functional suite "Puppeteer tests", while the UI tests chapter and the testing resources page say Playwright; and the module-testing guide says that its section on functional tests will be completed when the tests are available in a dedicated repository, and points to tests/UI of the core in the meantime. For a module’s own browser tests, Playwright is the tool; see the section on Playwright for the tool itself.

Continuous integration

The checks above fit one workflow. This one runs for every push and pull request: a matrix of PHP versions for the syntax check and the unit tests, the coding standard on the newest PHP, PHPStan against two PrestaShop versions using the official images, and the archive build as the last job. It was checked with actionlint (no findings), and the individual commands were run locally as described in the sections above; the workflow itself was not run on GitHub.

The stages and what feeds each one, from a push to a release on the marketplace:

flowchart LR A[Push or pull request] --> B[php -l on PHP 8.1 to 8.5] B --> C[PHP CS Fixer dry run] C --> D[PHPUnit unit tests] A --> E[PHPStan on PrestaShop 9.2.0 and 9.1.5] D --> F[Build archive: composer --no-dev, autoindex, zip] E --> F F --> G[Install in a test shop and run console smoke tests] G --> H[PHPUnit integration tests in the shop] H --> I[Validator report] I --> J[Addons submission or release asset]

The first three jobs and the PHPStan job of the workflow below correspond to the left half of the diagram and the last job builds the archive; the smoke test, the integration suite and the Validator are run by hand or by a self-hosted pipeline that has a shop (the section on packaging shows the commands).

name: Module CI

on:
  push:
  pull_request:

permissions:
  contents: read

jobs:
  php:
    name: Lint, style and unit tests (PHP ${{ matrix.php }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        php: ['8.1', '8.2', '8.3', '8.4', '8.5']
    steps:
      - uses: actions/checkout@v7

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          tools: composer:v2

      - name: Install dependencies
        run: composer update --prefer-dist --no-interaction --no-progress

      - name: Syntax check
        run: composer lint

      - name: Coding standards
        if: matrix.php == '8.5'
        run: composer cs

      - name: Unit tests
        run: composer test

  phpstan:
    name: PHPStan against PrestaShop ${{ matrix.prestashop }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        prestashop: ['9.2.0', '9.1.5']
    steps:
      - uses: actions/checkout@v7

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.5'
          tools: composer:v2

      - name: Install dependencies
        run: composer update --prefer-dist --no-interaction --no-progress

      # The shop does not need to be installed: the image only has to provide its files on a volume.
      - name: Start a PrestaShop container for its sources
        run: |
          docker run -d --name ps -v ps-files:/var/www/html prestashop/prestashop:${{ matrix.prestashop }}
          until docker exec ps test -f /var/www/html/config/defines.inc.php; do sleep 2; done
          sleep 10

      - name: Run PHPStan
        run: |
          docker run --rm --volumes-from ps -v "$PWD:/module" -w /module \
            -e _PS_ROOT_DIR_=/var/www/html \
            composer:2 php -d memory_limit=1G vendor/bin/phpstan analyse \
            --configuration=tests/phpstan/phpstan.neon --no-progress

  package:
    name: Build the module archive
    needs: [php, phpstan]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.1'
          tools: composer:v2

      - name: Build
        run: ./build-zip.sh

      - uses: actions/upload-artifact@v7
        with:
          name: pcfitesting
          path: dist/pcfitesting.zip
          if-no-files-found: error

The developer guide shows a similar pipeline taken from the ps_checkout module and an equivalent one for GitLab. Its examples use the PrestaShop-maintained actions prestashop/github-action-php-lint and prestashopcorp/github-action-php-cs-fixer, old action versions (actions/checkout@v2.0.0) and PHPStan 0.12; when you copy from it, replace the actions by the plain Composer scripts above, which are easier to run locally and do not depend on a third-party action. The pipeline of a production module usually adds the Node build of its assets, the archive build, the Validator run (see below) and, on a tag, the creation of the release asset.

Packaging and distribution

A module is distributed as a ZIP archive whose single top-level folder has the module’s technical name. It contains the production files only. The developer guide’s examples remove tests, .gitlab-ci.yml, composer.phar, .editorconfig, Makefile, docker-compose.yml, node_modules and _dev with shell commands; the script of this module goes the other way round and copies only what ships:

#!/bin/sh
# Builds dist/pcfitesting.zip: production files only, production Composer autoloader, index.php in every folder.
set -eu

NAME=pcfitesting
STAGE="dist/stage/${NAME}"

rm -rf dist
mkdir -p "${STAGE}"

# 1. copy only what ships
cp -a pcfitesting.php config.xml composer.json index.php src config upgrade self_config.yml "${STAGE}/"

# 2. production dependencies and an optimised autoloader (no dev packages)
(cd "${STAGE}" && composer install --no-dev --optimize-autoloader --no-interaction --no-progress)

# 3. protective index.php in every folder, including vendor/
composer global require prestashop/autoindex --no-interaction --no-progress
"$(composer global config bin-dir --absolute --quiet)/autoindex" prestashop:add:index "${STAGE}"

# 4. the archive has the technical name of the module as its only top-level folder
(cd dist/stage && zip -qr "../${NAME}.zip" "${NAME}")
ls -l "dist/${NAME}.zip"

composer install --no-dev --optimize-autoloader leaves no PHPUnit, PHPStan or PHP CS Fixer in the archive, and prestashop/autoindex (installed globally, because it conflicts with the Symfony 7 packages of PHP CS Fixer in the module’s own composer.json) adds a protective index.php to every folder, including vendor/ and the sub-folders of Composer. The script ran in the composer:2 image, which already contains zip, and produced dist/pcfitesting.zip with 32 entries. Its content, with the Composer internals of vendor/composer/ left out:

pcfitesting/
pcfitesting/composer.json
pcfitesting/composer.lock
pcfitesting/config.xml
pcfitesting/config/index.php
pcfitesting/index.php
pcfitesting/pcfitesting.php
pcfitesting/self_config.yml
pcfitesting/src/index.php
pcfitesting/src/Pricing/ClubPricing.php
pcfitesting/src/Pricing/index.php
pcfitesting/upgrade/index.php
pcfitesting/vendor/autoload.php
pcfitesting/vendor/index.php

A check of the extracted archive found an index.php in every folder. The archive was then copied into the modules folder of the shop, installed with bin/console prestashop:module install pcfitesting and put through the smoke test of the previous section. In the back office the same archive is uploaded in Module Manager > Upload a module, and the module can be installed on a shop of version 9.0 or later because ps_versions_compliancy of the main class says so.

The Validator and the Addons review

Modules sold or listed on the Addons marketplace go through a technical validation. Two tools exist. The Validator (https://validator.prestashop.com) analyses an archive or a public GitHub repository and produces a report; the project’s documentation says never to submit a module that has not been through it, and the 9.x developer guide still points modules to it. At the check date the address redirected to a sign-in page. The Marketplace team then applies a checklist of extra points. The checklist exists in the developer documentation only for 1.7 (modules/sell/techvalidation-checklist); the 8 and 9 trees contain no such page, so the rules below may be out of date for 9.x and should be confirmed with the Addons team when submitting. They are also the rules a careful reviewer would apply to any module:

  • the module structure is valid, with an index.php in each folder, one module per archive, files in the right directories, a compatible license, and core tables are not altered;

  • SQL is sanitised and external calls are secured, no risky methods are used (see the page on security), Ajax and cron scripts are secured and implemented as controllers;

  • no PHP errors in debug mode, no debug statements, no commented-out code or empty files, hooks are only used when needed, HTML is in templates, the code is in English, and the module has documentation;

  • payment modules have additional rules: double check id_cart, pass the provider’s amount to validateOrder() when the order is created after approval, and verify the call source with a signature or token.

The 9.x good-practices page adds: prefix everything the module creates (configuration keys, Smarty variables, tables, CSS classes), delete configuration, tables and tabs on uninstall, do not write into the modules folder (use var/modules/<name>/ and var/cache/<env>/modules/<name>/), do not use external ajax files or obfuscate code, and restrict the loading of back-office assets to the module’s own pages. The Marketplace itself (https://addons.prestashop.com) answers 403 to anonymous command-line clients, and an account is needed to submit, so the submission could not be inspected.

Versions, upgrade scripts and automatic updates

A shop shows an "Update" button for a module when a newer version is available, and runs the scripts of the module’s upgrade/ folder in order, starting from the first one whose version is higher than the installed one. Each script is named upgrade-x.y.z.php and defines a function upgrade_module_x_y_z($module) that returns true on success; new hooks and new database columns belong in those scripts. The Update Assistant, described next, also updates the modules of a store while it updates the core: it downloads the newest compatible release of each installed module from local sources, Composer or the Marketplace, uninstalls the modules that are not compatible with the target version and runs their upgrades after the core update. To test that path without publishing, put an archive of the module in <admin-dir>/autoupgrade/modules/<name>.zip; if the archive is invalid the updater falls back to the Marketplace.

Updating a shop with the Update Assistant

The Update Assistant (technical name autoupgrade, formerly "1-Click Upgrade") updates, backs up and restores a store. It is a module with a web interface in the back office and a console application. The commands and screens below are described from the developer guide and from the module’s README at tag 7.6.6; the module was not run for this page, for two reasons: the module is not bundled in the 9.2.0 Docker image (php modules/autoupgrade/bin/console answers "Could not open input file"), and the shop used for the examples had no earlier version to update from and must not be modified in ways that cannot be undone. Treat the output samples below as the documentation’s, not as a transcript of a run.

Requirements and paths

  • Version 7.6.6 of the module is the first with full support for PrestaShop 9.2, and the update to 9.2 starts from 9.0.x or 9.1.x. A store on a beta or release candidate of 9.2 cannot be updated to the final release with the module: reinstall.

  • The module can be downloaded from Addons, from the Marketplace module in the back office, or from the GitHub releases of the module. For the CLI it only has to be present in modules/; it does not need to be installed.

  • Everything the module writes goes below <admin-dir>/autoupgrade/: backup/ (backups), download/ (archives for the local channel), modules/ (module archives for tests), logs/ (files named yyyy-dd-mm-hhmmss-process.txt) and tmp/ (working files, emptied at the end).

  • The updater checks the requirements before it starts. Blocking problems are marked with a red cross, warnings with an orange sign. Typical blockers are PHP safe mode, an outdated module, or the shop not being in maintenance mode (the CLI prints Maintenance mode needs to be enabled. Enable maintenance mode and add your maintenance IP in Shop parameters  General  Maintenance.).

Channels

Channel Meaning

online_recommended

The version that PrestaShop’s API recommends for the store’s PHP version (the default). It is the best tested and may be older than the latest release.

online

The newest PrestaShop version compatible with the store’s PHP version.

local

An archive that you provide: a .zip and its .xml file (the list of files with their MD5 checksums) of the same version, placed in <admin-dir>/autoupgrade/download/ and taken from the GitHub releases of the core.

From the back office

After installing the module and opening its configuration page, choose "Update your store". The path has five steps: Version choice (the three channels above, with requirement checks), Update options, Backup, Update and Post-update. The options of the second step are: deactivate non-native modules (default yes, and ignored for PrestaShop 9 and later according to the CLI help), regenerate email templates (default yes, which overwrites customised templates), and disable all overrides (default no according to the back-office guide). The module’s README lists the defaults for the same option differently (true for PS_DISABLE_OVERRIDES), so set the option explicitly. The backup step offers a complete backup (files, database and images, the default), a partial one without images, or none; always keep a complete backup and one made outside the module. The update then runs unattended and shows a progress log.

From the command line

The CLI is the way to automate updates in a pipeline and to avoid web-server limits (max_execution_time, memory_limit of php-fpm). Every command takes the admin folder name as its last argument:

php modules/autoupgrade/bin/console update:check-new-version <admin-dir>
php modules/autoupgrade/bin/console update:check-requirements <admin-dir>
php modules/autoupgrade/bin/console update:check-modules <admin-dir>
php modules/autoupgrade/bin/console backup:create --include-images=1 <admin-dir>
php modules/autoupgrade/bin/console update:start --config-file-path=/path/to/config.json <admin-dir>
php modules/autoupgrade/bin/console backup:list <admin-dir>
php modules/autoupgrade/bin/console backup:restore --backup=<backup-name> <admin-dir>

The commands of the module (all documented in the developer guide) are backup:create, backup:list, backup:restore, backup:delete, update:check-new-version, update:check-requirements, update:check-modules and update:start, and the usual Symfony Console options apply (-n for no interaction, -v, -vv, -vvv for verbosity). update:check-new-version prints a table of versions with the columns version, channel, type (major, minor, patch) and information. update:check-modules lists the installed modules that need attention, and with -v shows the compatibility details taken from the Marketplace API. backup:restore and backup:delete run in interactive mode when no backup name is given.

The options of update:start are:

CLI option Configuration file key Meaning

--channel, --zip, --xml

channel, archive_zip, archive_xml

Channel and, for local, the archive files in the download folder

--chain / --no-chain

chain

Chain the update steps automatically (default) or stop after each task and log the command for the next one

--disable-non-native-modules

PS_AUTOUP_CUSTOM_MOD_DESACT

Disable modules installed after the shop was created (ignored for 9 and later)

--uninstall-incompatible-modules

PS_AUTOUP_UNINSTALL_NON_COMPAT_MODS

Uninstall modules that are not compatible with the target version (documented in the module’s README)

--regenerate-email-templates

PS_AUTOUP_REGEN_EMAIL

Regenerate email templates (customisations are lost)

--disable-all-overrides

PS_DISABLE_OVERRIDES

Disable all class and controller overrides during the update

--config-file-path

JSON file with the same keys

--action

Advanced: the task to start from (default UpdateInitialization)

{
  "channel": "online_recommended",
  "PS_AUTOUP_CUSTOM_MOD_DESACT": 1,
  "PS_AUTOUP_REGEN_EMAIL": 0,
  "PS_AUTOUP_KEEP_IMAGES": 1,
  "PS_DISABLE_OVERRIDES": 1
}

The example sets the keys of the README’s sample file for Pocket Coffee: update to the recommended version, keep the shop’s customised email templates, keep images in the backups and disable overrides during the run (and re-enable them afterwards after checking them).

What the module does

The README documents the tasks. A backup is three tasks and an update is ten, and a failed update offers a restore, which is the reverse of the backup:

flowchart TD A[UpdateInitialization] -->|channel online| B[Download] A -->|channel local| C[Unzip] B --> C C --> D[DownloadModules] D --> E[UninstallModules] E --> F[UpdateFiles] F --> G[UpdateDatabase] G --> H[UpdateModules] H --> I[CleanDatabase] I --> J[UpdateComplete] K[BackupInitialization] --> L[BackupFiles] --> M[BackupDatabase] --> N[BackupComplete] N -.->|before the update| A J -.->|if something fails| O[RestoreFiles then RestoreDatabase]

UpdateFiles runs several times (the first call builds the list of files, the next ones copy parts of it), UpdateDatabase runs the SQL files of the target version, clears caches and updates languages, UpdateModules updates module files and runs their migrations, and CleanDatabase removes obsolete or inconsistent data that comes from using the shop, not from the update.

Before and after

The update is a production change and deserves a routine. Before: update to the latest patch of the current branch first, move a copy of the shop to staging and run the whole procedure there, check the PHP version against the target (see the page on versions and support), run update:check-modules, make a complete backup with the module and a second one with your own tools (files and database), enable maintenance mode and add your IP. After: refresh the back office and sign in again, open the Module Manager and re-enable modules that were disabled, check the front office (create an account, add a product, place an order, pay), disable maintenance mode, review the modules that were uninstalled because they were incompatible, regenerate thumbnails in Preferences > Images if images are missing, and run the smoke tests of your own modules. If the back office cannot be opened, enable debug mode in config/defines.inc.php with _PS_MODE_DEV_, and restore from the backup in <admin-dir>/autoupgrade/backup through the CLI when needed.

On an immutable image running on several pods the update is a new image plus a one-off upgrade job, not the assistant run from the back office; see Cloud: Releases and Upgrades.

What was run and what was not

Run on a local PrestaShop 9.2.0 shop (the official Docker image with MySQL 8.4) and in composer:2 (PHP 8.5.12), php:8.1-cli and php:8.3-cli containers: php -l on all module files on PHP 8.1, 8.3 and 8.5; PHP CS Fixer with the PrestaShop standard (before and after the fix); PHPStan 2.3.3 at levels 6 and max, with an injected error, against the installed shop and against uninstalled prestashop/prestashop:9.2.0 and 9.1.5 containers on named volumes; the nine unit tests on PHP 8.1, 8.3 and 8.5; the two integration tests inside the shop container; the archive build, its content check and its installation; the console smoke test (install, configure with self_config.yml, disable, enable, reset); actionlint on the workflow; and the exact PHPStan step of the workflow against the volume container.

Not run: the core’s Behat and Playwright suites (they need a checkout of the core repository, a test database and, for the UI tests, Node.js and Playwright browsers); code coverage (no coverage driver in the images used); the CI workflow on GitHub itself; the Validator and the Addons submission (an account is required); the Update Assistant in the back office and from the CLI (see above), so its outputs are the documentation’s; and PrestaShop Flashlight, the alternative Docker image for development and testing mentioned in the developer guide. The test module and the test files were removed from the shop and from the containers afterwards.