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 release | 9.1.4, published 4 June 2026 |
| PHP | 8.1 minimum, up to 8.5 (8.5 recommended on 9.1) |
| Database | MySQL 5.7 or MariaDB 10.2 minimum |
| Symfony | 6.4 LTS |
| Doctrine ORM | 2.15 |
| Default themes | prestashop/classic ~3.1.0, plus prestashop/hummingbird ^2.0 |
| Status | Current 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' => truecreates a_langtable keyed on(id_entity, id_lang). Translatable fields become PHP arrays indexed by language ID.- With
'multilang_shop' => true, you must callShop::setContext(Shop::CONTEXT_SHOP, $id)beforesave()or the write lands against the wrong shop. - Always iterate
Language::getLanguages(false)— including inactive languages — when writing_langrows. 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 callsetTemplate('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:
- 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. - Use product features. The native product importer exposes a
featurescolumn in the formatFeature (Name:Value:Position:Customized). Zero code, but it flattens structured passport data into name/value strings. - Ship your own importer as an admin controller with its own upload and parser.
Most work, most control. You can reuse the core's
ImporterInterfaceinternally, 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?
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?
Is our passport panel code different on 9 versus 8?
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?
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.