Current — 9.1.4

Digital Product Passport on PrestaShop 9.x

PrestaShop 9 rewrote the back-office product page and removed ten hooks that module authors relied on. If you are porting a passport module from 8.x, this is where it breaks — and it breaks silently.

Platform facts

Latest release9.1.4, published 4 June 2026
PHP8.1 minimum, up to 8.5 (8.5 recommended on 9.1)
DatabaseMySQL 5.7 or MariaDB 10.2 minimum
Symfony6.4 LTS
Doctrine ORM2.15
Default themesprestashop/classic ~3.1.0, plus prestashop/hummingbird ^2.0
StatusCurrent line. 9.0 is superseded by 9.1.

A note on 9.0 versus 8.1 as the PHP floor. PrestaShop 9.0 requires PHP 8.1 minimum — the prose in the release notes and composer.json on tag 9.0.3 both say >=8.1. The rendered compatibility chart in the docs is easy to misread as 8.2. It is 8.1.

Where to store passport data

The interesting finding here is that PrestaShop 9's own example module still uses ObjectModel, not Doctrine, for custom per-product data. The official demoproductform module — which declares a minimum compatibility of 9.0.0 — stores its custom product data as final class CustomProduct extends \ObjectModel with a plain $definition and an install.sql creating its own table.

The devdocs are explicit that this is not an accident: while a complete migration to Symfony and Doctrine entities is on the roadmap, ObjectModel will remain present and available for some time.

The recommended shape for passport data

An ObjectModel with a custom table, with 'multilang' => true if any passport values are genuinely language-dependent, and 'multishop' => true if you run multistore. A useful trick from the core example: set public $force_id = true so the row's primary key equals the product ID.

Three conventions that cause real bugs if you miss them:

  • 'multilang' => true creates a _lang table keyed on (id_entity, id_lang). Translatable fields become PHP arrays indexed by language ID.
  • With 'multilang_shop' => true, you must call Shop::setContext(Shop::CONTEXT_SHOP, $id) before save() or the write lands against the wrong shop.
  • Always iterate Language::getLanguages(false) — including inactive languages — when writing _lang rows. Otherwise activating a language later leaves you with missing rows and no obvious symptom.

ObjectModel also fires lifecycle hooks automatically — actionObject<ClassName>AddAfter, UpdateAfter, DeleteAfter and their Before variants — which is a clean place to hang passport revision history.

When Doctrine makes sense

Doctrine has been supported for modules since 1.7.6: put entities in your module's src/Entity and PrestaShop auto-scans installed modules. It is a reasonable choice if the passport data is consumed exclusively from Symfony-side code. The catch is that Doctrine entities are only reachable once the Symfony kernel is booted — which is precisely why the core's own product-page example stays on ObjectModel, since the front office does not boot it the same way.

Editing passport data in the back office

This is the trap. Ten displayAdminProducts*Step* hooks were removed in PrestaShop 9 — but their names are still registered in the hook table (install-dev/data/xml/hook.xml in 9.1.4) while being dispatched from nowhere in the codebase. So registerHook() succeeds, the hook appears as registered in the back office, and your callback is simply never called. No error, no warning, no output. A passport module ported from 8.x will install cleanly and do nothing.

The removed hooks

All ten of these are dead in 9.x: displayAdminProductsCombinationBottom, displayAdminProductsSeoStepBottom, displayAdminProductsShippingStepBottom, displayAdminProductsQuantitiesStepBottom, displayAdminProductsMainStepLeftColumnBottom, displayAdminProductsMainStepLeftColumnMiddle, displayAdminProductsMainStepRightColumnBottom, displayAdminProductsOptionsStepTop, displayAdminProductsOptionsStepBottom and displayAdminProductsPriceStepBottom.

In 8.2.7 they are dispatched from the product page Twig panels. In 9.1.4 those template files do not exist. The legacy controllers/admin/AdminProductsController.php is also gone entirely.

displayAdminProductsExtra does survive, dispatched from ExtraModulesType.php — but the class docblock in 9.1.4 says plainly that this is no longer the recommended way to integrate and is kept for backward compatibility only.

The modern way: actionProductFormBuilderModifier

Hook into the Symfony form builder and add your fields directly:

public function install()
{
    return parent::install()
        && $this->registerHook(['actionProductFormBuilderModifier']);
}

public function hookActionProductFormBuilderModifier(array $params): void
{
    $modifier = $this->get(PassportFormModifier::class);
    $modifier->modify((int) $params['id'], $params['form_builder']);
}

The hook name is derived at runtime as 'action' . camelize($formType->getBlockPrefix()) . 'FormBuilderModifier', and EditProductFormType::getBlockPrefix() returns 'product'. The $params array carries form_builder, data and options (both by reference) and id.

Inside the modifier, inject PrestaShopBundle\Form\FormBuilderModifier (service @form.form_builder_modifier) and use addAfter($tabBuilder, 'existing_field', 'passport_data', TextType::class, [...]).

For a passport panel you probably want your own tab rather than fields squeezed into an existing one. Custom tabs are possible from 8.1 onwards via NavigationTabType; the official demoproductform module has a working CustomTabType example.

Saving your fields

Two supported routes. The form handler hooks — actionAfterUpdateProductFormHandler and actionAfterCreateProductFormHandler, plus Before variants — receive form_data and id. Or implement ProductCommandsBuilderInterface and tag your service core.product_command_builder, which the core's ProductCommandsBuilder discovers by tag.

actionProductSave also still fires in 9.x, from classes/Product.php. It is the most portable save hook across versions and — usefully — it also fires during CSV import.

One caveat on data-provider hooks. actionProductFormDataProviderData and actionProductFormDataProviderDefaultData are dispatched by FormBuilder.php but are not declared in hook.xml in either 8.2.7 or 9.1.4, unlike their Create/Image/Shops siblings. They work via registerHook(), but the asymmetry looks like a core oversight — do not be surprised if it changes.

Showing the passport on the storefront

Good news: the front-office product page hooks are unchanged from 1.7 through 9.1. Nothing you write here needs version branching.

displayProductExtraContent — and the mistake everyone makes

This is the right hook for a passport panel, because it renders as its own tab in the product page's tabbed region. The detail that catches people: it must return an array of ProductExtraContent objects, not a string and not a bare object.

public function hookDisplayProductExtraContent($params)
{
    return [
        (new PrestaShop\PrestaShop\Core\Product\ProductExtraContent())
            ->setTitle('Digital Product Passport')
            ->setContent($html)
            ->setAttr(['id' => 'dpp', 'class' => 'passport-panel']),
    ];
}

Why it matters: ProductExtraContentFinder iterates the returned value and does if (!is_array($moduleContents)) continue;. Return a bare object and it is silently dropped — no error, no panel. Return a non-object inside the array and it throws "The module X did not return expected type". Silent failure and loud failure, a few characters apart.

Note the class lives at src/Core/Product/ProductExtraContent.php, not in classes/. Its API is setTitle(), setContent(), setAttr(array), addAttr(array) and toArray().

A documentation bug worth knowing. The devdocs front-matter for this hook says array_return: false, which contradicts the code. The code is right — it calls HookManager::exec() with the array-return flag set. Trust the source.

Other useful product page hooks

All present and not deprecated in 9.x: displayProductAdditionalInfo (inline, near the add-to-cart button — good for a compact passport summary), displayAfterProductThumbs, displayFooterProduct, displayProductActions and displayReassurance.

Register the canonical names, not aliases. displayProductAdditionalInfo has the aliases productActions and displayProductButtons, and since PrestaShop 8.0 using a hook alias triggers a deprecation notice.

The public passport URL

Front-office routing was not migrated to Symfony in PrestaShop 9. The module front controller pattern is unchanged from 1.7.

  • File: modules/yourmodule/controllers/front/passport.php
  • Class: YourModulePassportModuleFrontController extends ModuleFrontController
  • Implement initContent() and call setTemplate('module:yourmodule/views/templates/front/passport.tpl')
  • Link with Context::getContext()->link->getModuleLink('yourmodule', 'passport', ['id' => 1])

For a clean passport URL, use the moduleRoutes hook — still live in 9.1.4, dispatched from classes/Dispatcher.php. Return an array keyed by route name with rule, keywords, controller and params. A rule such as 'passport/{uid}' gives you the short, permanent URL a QR code wants.

PrestaShop 9 change that breaks older modules. Sensitive files in the modules directory are now forbidden, and PHP files must go through a ModuleFrontController. If your passport page was a loose .php entry point hit directly — a pattern that was common for simple public endpoints — it will stop working. Route it properly.

There is an experimental FrontKernel DI container in 9.0 (app/config/front), described by the core team as a first stone for a future front office migration. It is not routing, and it is not something to build on yet.

Version-specific gotchas

$this->get() no longer works in Symfony controllers

Symfony 6.4 replaced the global container with a per-controller optimised container and removed get(). FrameworkBundleAdminController has a compatibility shim, but the docs describe it as already deprecated and due for removal in PrestaShop 10. Migrate to PrestaShopAdminController with constructor injection now rather than twice.

trans() no longer escapes

This is a security-relevant change. In 9.x, trans() does not escape output, and neither does Smarty's {l}. You must apply htmlspecialchars() yourself. Passport data frequently contains supplier-provided strings, which makes this exactly the wrong place to inherit an escaping assumption from 8.x.

Removed dependencies

Guzzle, SwiftMailer and TacticianBundle were removed from core, replaced by Symfony HTTP Client, Symfony Mailer and Symfony Messenger. If your module used any of them implicitly through core, declare them yourself.

Native CSV import cannot be extended

Verified in 9.1.4: src/Core/Import/Entity.php is a closed list of nine entity constants, there is no import-related hook in hook.xml, and no hook dispatch anywhere in src/Core/Import. You cannot register a new importable entity type.

Three ways round it, in the order worth trying:

  1. Piggyback on actionProductSave. Import products natively, and have your module write passport rows from a staging source when each product saves. The native importer triggers it, and this is portable across 1.7, 8 and 9.
  2. Use product features. The native product importer exposes a features column in the format Feature (Name:Value:Position:Customized). Zero code, but it flattens structured passport data into name/value strings.
  3. Ship your own importer as an admin controller with its own upload and parser. Most work, most control. You can reuse the core's ImporterInterface internally, you just cannot register a new entity in the native UI.

Theme default changed in 9.1

Theme::getDefaultTheme() no longer returns a hardcoded "classic" — it returns the configured default, which may be hummingbird. Code that assumed classic will misbehave.

Questions

We ported our passport module from 8.x and it installs but shows nothing in the back office. Why?
Almost certainly the removed displayAdminProducts*Step* hooks. Their names are still in PrestaShop 9's hook table, so registerHook() succeeds and the back office lists them as registered — but nothing dispatches them, so your callbacks never fire. There is no error to find in the logs. Move to actionProductFormBuilderModifier.
Should we use Doctrine or ObjectModel for passport data on PrestaShop 9?
ObjectModel, unless the data is consumed only from Symfony-side code. PrestaShop 9's own official example module for extending the product form uses ObjectModel with a custom table, and the docs confirm ObjectModel is staying for the foreseeable future. Doctrine entities also need the Symfony kernel booted, which complicates front-office use.
Is our passport panel code different on 9 versus 8?
The front-office half is identical — displayProductExtraContent and the other product page hooks are unchanged from 1.7 through 9.1. The back-office half is where you need version branching, because the product form was rewritten.
Can we still use actionProductSave?
Yes, and it is the most portable hook available. It fires from classes/Product.php in 1.7.8.11, 8.2.7 and 9.1.4 alike, on both add and update, and it also fires during native CSV import — which makes it the practical foundation for bulk passport data loading.

Sources