diff --git a/demoextrafield/README.md b/demoextrafield/README.md
new file mode 100644
index 0000000..5e5290d
--- /dev/null
+++ b/demoextrafield/README.md
@@ -0,0 +1,108 @@
+# Demo extra fields
+
+## About
+
+This module demonstrates how to use **native extra fields** (custom fields) in PrestaShop (9.2+ ?).
+
+It focuses on:
+
+- Registering extra fields on multiple entities (Product, Category, Customer)
+- Covering multiple **scopes** (`common`, `lang`, `shop`) and **types** (bool, date, money, html, json, url, …)
+- Unregistering extra fields on uninstall (including dropping the SQL storage columns)
+- Rendering the stored values on the Front Office using hooks
+- Making Back Office translation strings visible in the translation interface
+
+## What it registers
+
+### Product (`product`)
+
+- `is_dangerous` (scope: `common`, type: bool)
+- `video_link` (scope: `lang`, type: string/url)
+- `custom_date` (scope: `shop`, type: date)
+
+### Category (`category`)
+
+- `theme_color` (scope: `common`, type: string/color)
+- `marketing_note` (scope: `common`, type: html)
+- `id_supplier` (scope: `common`, type: int / supplier selector)
+
+### Customer (`customer`)
+
+- `credit_limit` (scope: `common`, type: float / money)
+- `extra_json` (scope: `common`, type: json)
+
+## How to test
+
+This module impacts both Back Office and Front Office.
+
+### Product
+
+**Back Office grid**
+
+- Adds a **"Dangerous product"** field displayed after **"Quantity"**.
+- Adds a **"Custom date"** field displayed at the end of the grid.
+- Toggling **"Dangerous product"** persists the value.
+
+**Back Office form**
+
+- Extra fields are grouped into a dedicated **"Extra fields"** tab.
+- Except **"Dangerous product"**, which is displayed at the end of the **"Options"** tab.
+
+**Front Office hooks**
+
+- Product page: `displayProductAdditionalInfo`
+- Cart: `displayCartExtraProductInfo`
+
+### Category
+
+**Back Office grid**
+
+- Adds **Theme color** and **Marketing note** at the end of the grid.
+
+**Back Office form**
+
+- Adds **Theme color** and **Marketing note** to the form.
+
+**Front Office hooks**
+
+- Category listing page: `displayHeaderCategory`
+
+### Customer
+
+**Back Office grid**
+
+- Adds **Credit limit** in the grid.
+
+**Back Office form**
+
+- Adds **Credit limit** and **Metadata JSON** to the form.
+
+**Front Office hooks**
+
+- My account page: `displayCustomerAccountTop`
+
+### Where to find values in FO templates
+
+On the Front Office, the module displays **only the values stored for this module**, under `extraProperties['demoextrafield']`.
+
+## Translation note (Back Office)
+
+Each extra field has a **title** and a **description** meant to be displayed in Back Office.
+The system stores the source wording and its translation domain (for the default language), then translations are managed through PrestaShop Back Office.
+
+To make those strings appear in the Back Office translation interface, two conditions must be met:
+
+1. The strings must be declared in PHP via `$this->trans(...)` (see `demoextrafield::registerTranslationWordings()`).
+2. The same source strings must exist at least once in an XLF file shipped by the module (see `translations/fr-FR/ModulesDemoextrafieldAdmin.fr-FR.xlf`).
+
+## Supported PrestaShop versions
+
+Compatible with 9.2 ? and above versions.
+
+## How to install
+
+1. Download or clone the module into the `modules` directory of your PrestaShop installation
+2. Install the module:
+ - from Back Office in Module Manager
+ - or using the command `php ./bin/console prestashop:module install demoextrafield`
+
diff --git a/demoextrafield/config.xml b/demoextrafield/config.xml
new file mode 100644
index 0000000..7e62df5
--- /dev/null
+++ b/demoextrafield/config.xml
@@ -0,0 +1,11 @@
+
+
+ demoextrafield
+
+
+
+
+
+ 0
+ 0
+
\ No newline at end of file
diff --git a/demoextrafield/config_fr.xml b/demoextrafield/config_fr.xml
new file mode 100644
index 0000000..7e62df5
--- /dev/null
+++ b/demoextrafield/config_fr.xml
@@ -0,0 +1,11 @@
+
+
+ demoextrafield
+
+
+
+
+
+ 0
+ 0
+
\ No newline at end of file
diff --git a/demoextrafield/demoextrafield.php b/demoextrafield/demoextrafield.php
new file mode 100644
index 0000000..7990179
--- /dev/null
+++ b/demoextrafield/demoextrafield.php
@@ -0,0 +1,749 @@
+name = 'demoextrafield';
+ $this->tab = 'administration';
+ $this->version = '1.0.0';
+ $this->author = 'PrestaShop';
+ $this->need_instance = 0;
+ $this->ps_versions_compliancy = ['min' => '9.2.0', 'max' => '9.9.99'];
+
+ parent::__construct();
+
+ $this->displayName = 'Demo native extra fields';
+ $this->description = 'Example module showing how to register and display native extra fields.';
+ }
+
+ /**
+ * Install:
+ * - registers extra fields for product/category/customer,
+ * - registers a few FO hooks to display values.
+ */
+ public function install(): bool
+ {
+ if (!parent::install()) {
+ return false;
+ }
+
+ /**
+ * PRODUCT extra fields
+ */
+
+ // Product (common) : is_dangerous
+ $productDangerousRegistered = $this->registerExtraProperty(
+ new ExtraPropertyDefinition(
+ entityName: 'product',
+ propertyName: 'is_dangerous',
+ type: ExtraPropertyType::BOOL,
+ scope: ExtraPropertyScope::COMMON,
+ defaultValue: 0,
+ nullable: false,
+ displayApi: true,
+ associatedForms: ['product.options.extra_properties'],
+ associatedGrids: ['product.reference'],
+ formFieldType: CheckboxType::class,
+ validator: 'isBool',
+ labelWording: $this->trans('Dangerous product', [], 'Modules.Demoextrafield.Admin', 'en'),
+ labelDomain: self::TRANSLATION_DOMAIN,
+ descriptionWording: $this->trans('Indicates whether the product is dangerous', [], 'Modules.Demoextrafield.Admin', 'en'),
+ descriptionDomain: self::TRANSLATION_DOMAIN,
+ )
+ );
+ if (!$productDangerousRegistered) {
+ $this->_errors[] = $this->trans('Failed to register Product extra field "is_dangerous" (scope: common).', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ // Product (lang) : video_link
+ $productVideoLinkRegistered = $this->registerExtraProperty(
+ new ExtraPropertyDefinition(
+ entityName: 'product',
+ propertyName: 'video_link',
+ type: ExtraPropertyType::STRING,
+ scope: ExtraPropertyScope::LANG,
+ nullable: true,
+ sqlIndex: ExtraPropertySqlIndex::UNIQUE,
+ displayApi: true,
+ associatedForms: ['product'],
+ formFieldType: UrlType::class,
+ validator: 'isUrl',
+ labelWording: $this->trans('Video link', [], 'Modules.Demoextrafield.Admin', 'en'),
+ labelDomain: self::TRANSLATION_DOMAIN,
+ descriptionWording: $this->trans('Video URL per language', [], 'Modules.Demoextrafield.Admin', 'en'),
+ descriptionDomain: self::TRANSLATION_DOMAIN,
+ )
+ );
+ if (!$productVideoLinkRegistered) {
+ $this->_errors[] = $this->trans('Failed to register Product extra field "video_link" (scope: lang).', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ // Product (shop) : custom_date
+ $productCustomDateRegistered = $this->registerExtraProperty(
+ new ExtraPropertyDefinition(
+ entityName: 'product',
+ propertyName: 'custom_date',
+ type: ExtraPropertyType::DATE,
+ scope: ExtraPropertyScope::SHOP,
+ nullable: true,
+ sqlIndex: ExtraPropertySqlIndex::KEY,
+ displayApi: true,
+ associatedForms: ['product'],
+ associatedGrids: ['product.final_price_tax_excluded:before'],
+ formFieldType: DatePickerType::class,
+ validator: 'isDate',
+ labelWording: $this->trans('Custom date', [], 'Modules.Demoextrafield.Admin', 'en'),
+ labelDomain: self::TRANSLATION_DOMAIN,
+ descriptionWording: $this->trans('Custom date per shop', [], 'Modules.Demoextrafield.Admin', 'en'),
+ descriptionDomain: self::TRANSLATION_DOMAIN,
+ )
+ );
+ if (!$productCustomDateRegistered) {
+ $this->_errors[] = $this->trans('Failed to register Product extra field "custom_date" (scope: shop).', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ // Product (common) : date_last_seen
+ // Auto-updated on each FO product page view (hookDisplayFooterProduct).
+ // displayForm: false → read-only for merchants; visible in the product grid and via API.
+ $productDateLastSeenRegistered = $this->registerExtraProperty(
+ new ExtraPropertyDefinition(
+ entityName: 'product',
+ propertyName: 'date_last_seen',
+ type: ExtraPropertyType::DATE,
+ scope: ExtraPropertyScope::COMMON,
+ nullable: true,
+ displayApi: true,
+ associatedGrids: ['product'],
+ labelWording: $this->trans('Date last seen', [], 'Modules.Demoextrafield.Admin', 'en'),
+ labelDomain: self::TRANSLATION_DOMAIN,
+ descriptionWording: $this->trans('Last time this product page was viewed', [], 'Modules.Demoextrafield.Admin', 'en'),
+ descriptionDomain: self::TRANSLATION_DOMAIN,
+ )
+ );
+ if (!$productDateLastSeenRegistered) {
+ $this->_errors[] = $this->trans('Failed to register Product extra field "date_last_seen" (scope: common).', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ // Product (common) : packaging_type
+ // Demonstrates: CHOICE type, enumValues, formOptions (dropdown choices).
+ // enumValues constrains the allowed DB values; formOptions drives the Symfony ChoiceType widget.
+ // formRequired: false + nullable: true + placeholder → the "—" option represents "no selection";
+ // the field can be left empty and the empty value passes server-side validation.
+ // (For a truly required field, omit the placeholder and set formRequired: true — NotBlank is added automatically.)
+ $productPackagingTypeRegistered = $this->registerExtraProperty(
+ new ExtraPropertyDefinition(
+ entityName: 'product',
+ propertyName: 'packaging_type',
+ type: ExtraPropertyType::CHOICE,
+ scope: ExtraPropertyScope::COMMON,
+ enumValues: ['standard', 'gift', 'bulk'],
+ defaultValue: null,
+ nullable: true,
+ formRequired: false,
+ displayApi: true,
+ displayFront: true,
+ associatedForms: ['product'],
+ associatedGrids: ['product'],
+ formFieldType: ChoiceType::class,
+ formOptions: [
+ 'choices' => [
+ 'Standard' => 'standard',
+ 'Gift box' => 'gift',
+ 'Bulk' => 'bulk',
+ ],
+ 'placeholder' => '—',
+ ],
+ labelWording: $this->trans('Packaging type', [], 'Modules.Demoextrafield.Admin', 'en'),
+ labelDomain: self::TRANSLATION_DOMAIN,
+ descriptionWording: $this->trans('Selectable packaging type for this product', [], 'Modules.Demoextrafield.Admin', 'en'),
+ descriptionDomain: self::TRANSLATION_DOMAIN,
+ )
+ );
+ if (!$productPackagingTypeRegistered) {
+ $this->_errors[] = $this->trans('Failed to register Product extra field "packaging_type" (scope: common).', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ /**
+ * CATEGORY extra fields
+ */
+
+ // Category (common) : theme_color
+ // Demonstrates: formRequired: true → the form modifier automatically adds a NotBlank
+ // constraint at build time (server-side enforcement, not just the HTML required attribute).
+ // No need to put constraints in formOptions — formOptions is persisted as JSON and cannot
+ // hold Constraint objects.
+ $categoryThemeColorRegistered = $this->registerExtraProperty(
+ new ExtraPropertyDefinition(
+ entityName: 'category',
+ propertyName: 'theme_color',
+ type: ExtraPropertyType::STRING,
+ scope: ExtraPropertyScope::COMMON,
+ nullable: true,
+ formRequired: true,
+ displayApi: true,
+ associatedForms: ['category', 'root_category'],
+ associatedGrids: ['category'],
+ formFieldType: ColorType::class,
+ validator: 'isColor',
+ labelWording: $this->trans('Theme color', [], 'Modules.Demoextrafield.Admin', 'en'),
+ labelDomain: self::TRANSLATION_DOMAIN,
+ descriptionWording: $this->trans('Color associated with the category (required)', [], 'Modules.Demoextrafield.Admin', 'en'),
+ descriptionDomain: self::TRANSLATION_DOMAIN
+ )
+ );
+ if (!$categoryThemeColorRegistered) {
+ $this->_errors[] = $this->trans('Failed to register Category extra field "theme_color" (scope: common).', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ // Category (common) : marketing_note
+ // Demonstrates: displayFront: false on an entity rendered through a presenter LazyArray
+ // (CategoryLazyArray) — validates that the forFrontOffice filtering works on that path
+ // too (the customer's internal_note covers the native ObjectModel path).
+ $categoryMarketingNoteRegistered = $this->registerExtraProperty(
+ new ExtraPropertyDefinition(
+ entityName: 'category',
+ propertyName: 'marketing_note',
+ type: ExtraPropertyType::HTML,
+ scope: ExtraPropertyScope::COMMON,
+ nullable: true,
+ displayApi: true,
+ displayFront: false,
+ associatedForms: ['category'],
+ formFieldType: FormattedTextareaType::class,
+ validator: 'isCleanHtml',
+ labelWording: $this->trans('Marketing note', [], 'Modules.Demoextrafield.Admin', 'en'),
+ labelDomain: self::TRANSLATION_DOMAIN,
+ descriptionWording: $this->trans('Merchant-only note displayed in BO and API — never on the front office', [], 'Modules.Demoextrafield.Admin', 'en'),
+ descriptionDomain: self::TRANSLATION_DOMAIN,
+ )
+ );
+ if (!$categoryMarketingNoteRegistered) {
+ $this->_errors[] = $this->trans('Failed to register Category extra field "marketing_note" (scope: common).', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ // Category (common) : id_supplier
+ $categorySupplierRegistered = $this->registerExtraProperty(
+ new ExtraPropertyDefinition(
+ entityName: 'category',
+ propertyName: 'id_supplier',
+ type: ExtraPropertyType::INT,
+ scope: ExtraPropertyScope::COMMON,
+ nullable: true,
+ displayApi: true,
+ associatedForms: ['category'],
+ associatedGrids: ['category'],
+ formFieldType: DiscountSupplierType::class,
+ formOptions: [
+ 'label_tag_name' => null,
+ ],
+ // This prevents using a h3 tag for label
+ validator: 'isUnsignedId',
+ labelWording: $this->trans('Default supplier', [], 'Modules.Demoextrafield.Admin', 'en'),
+ labelDomain: self::TRANSLATION_DOMAIN,
+ descriptionWording: $this->trans('Select a PrestaShop supplier', [], 'Modules.Demoextrafield.Admin', 'en'),
+ descriptionDomain: self::TRANSLATION_DOMAIN
+ )
+ );
+ if (!$categorySupplierRegistered) {
+ $this->_errors[] = $this->trans('Failed to register Category extra field "id_supplier" (scope: common).', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ /**
+ * CUSTOMER extra fields
+ */
+
+ // Customer (common) : credit_limit
+ $customerCreditLimitRegistered = $this->registerExtraProperty(
+ new ExtraPropertyDefinition(
+ entityName: 'customer',
+ propertyName: 'credit_limit',
+ type: ExtraPropertyType::FLOAT,
+ scope: ExtraPropertyScope::COMMON,
+ nullable: true,
+ displayApi: true,
+ associatedForms: ['customer'],
+ associatedGrids: ['customer'],
+ formFieldType: MoneyType::class,
+ validator: 'isPrice',
+ labelWording: $this->trans('Credit limit', [], 'Modules.Demoextrafield.Admin', 'en'),
+ labelDomain: self::TRANSLATION_DOMAIN,
+ descriptionWording: $this->trans('Maximum customer credit amount', [], 'Modules.Demoextrafield.Admin', 'en'),
+ descriptionDomain: self::TRANSLATION_DOMAIN
+ )
+ );
+ if (!$customerCreditLimitRegistered) {
+ $this->_errors[] = $this->trans('Failed to register Customer extra field "credit_limit" (scope: common).', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ // Customer (common) : extra_json
+ $customerExtraJsonRegistered = $this->registerExtraProperty(
+ new ExtraPropertyDefinition(
+ entityName: 'customer',
+ propertyName: 'extra_json',
+ type: ExtraPropertyType::JSON,
+ scope: ExtraPropertyScope::COMMON,
+ nullable: true,
+ displayApi: true,
+ associatedForms: ['customer'],
+ formFieldType: TextareaType::class,
+ validator: 'isJson',
+ labelWording: $this->trans('Metadata JSON', [], 'Modules.Demoextrafield.Admin', 'en'),
+ labelDomain: self::TRANSLATION_DOMAIN,
+ descriptionWording: $this->trans('Free JSON for customer metadata', [], 'Modules.Demoextrafield.Admin', 'en'),
+ descriptionDomain: self::TRANSLATION_DOMAIN,
+ )
+ );
+ if (!$customerExtraJsonRegistered) {
+ $this->_errors[] = $this->trans('Failed to register Customer extra field "extra_json" (scope: common).', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ // Customer (common) : internal_note
+ // Demonstrates: displayFront: false — the field appears in BO form and API but is
+ // never readable on the front office: presenter lazy arrays are built with
+ // forFrontOffice: true, and native ObjectModel bags detect the FO controller
+ // context automatically, so non-displayFront definitions are never even read.
+ $customerInternalNoteRegistered = $this->registerExtraProperty(
+ new ExtraPropertyDefinition(
+ entityName: 'customer',
+ propertyName: 'internal_note',
+ type: ExtraPropertyType::STRING,
+ scope: ExtraPropertyScope::COMMON,
+ nullable: true,
+ displayApi: true,
+ displayFront: false,
+ associatedForms: ['customer'],
+ formFieldType: TextareaType::class,
+ labelWording: $this->trans('Internal note', [], 'Modules.Demoextrafield.Admin', 'en'),
+ labelDomain: self::TRANSLATION_DOMAIN,
+ descriptionWording: $this->trans('Merchant-only note — never exposed on the front office', [], 'Modules.Demoextrafield.Admin', 'en'),
+ descriptionDomain: self::TRANSLATION_DOMAIN,
+ )
+ );
+ if (!$customerInternalNoteRegistered) {
+ $this->_errors[] = $this->trans('Failed to register Customer extra field "internal_note" (scope: common).', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ /**
+ * ADDRESS extra fields
+ *
+ * Demo case: gridId ('manufacturer_address') differs from entity name ('address').
+ * This validates that getDefinitionCollectionByGridId() correctly decouples
+ * the grid identifier from the entity table name.
+ *
+ * Note on displayForm:
+ * The form modifier resolves extra fields using the form type's block prefix as the entity
+ * name. ManufacturerAddressType has block_prefix='manufacturer_address', but the entity
+ * table is 'address'. Because block_prefix ≠ entity_name, the form modifier cannot find
+ * definitions registered for 'address' when building the 'manufacturer_address' form.
+ * displayForm is therefore set to false: the field is intentionally grid-only here.
+ * (Forms where block_prefix == entity_name — e.g. product, customer, category — work
+ * correctly without this constraint.)
+ */
+
+ // Address (common) : delivery_note
+ // Shows in the manufacturer address grid (Catalog > Brands > Addresses) after 'city'.
+ $addressDeliveryNoteRegistered = $this->registerExtraProperty(
+ new ExtraPropertyDefinition(
+ entityName: 'address',
+ propertyName: 'delivery_note',
+ type: ExtraPropertyType::STRING,
+ scope: ExtraPropertyScope::COMMON,
+ nullable: true,
+ size: 255,
+ displayApi: true,
+ associatedGrids: ['manufacturer_address.city'],
+ formFieldType: TextareaType::class,
+ validator: 'isGenericName',
+ labelWording: $this->trans('Delivery note', [], 'Modules.Demoextrafield.Admin', 'en'),
+ labelDomain: self::TRANSLATION_DOMAIN,
+ descriptionWording: $this->trans('Free delivery note attached to this address', [], 'Modules.Demoextrafield.Admin', 'en'),
+ // gridId 'manufacturer_address' ≠ entity 'address' — decoupling test.
+ descriptionDomain: self::TRANSLATION_DOMAIN,
+ )
+ );
+ if (!$addressDeliveryNoteRegistered) {
+ $this->_errors[] = $this->trans('Failed to register Address extra field "delivery_note" (scope: common).', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ /**
+ * CMS extra fields — MANUAL form integration (no associatedForms)
+ *
+ * Demo case: the module integrates its fields into the migrated CMS page form
+ * itself via the generic form hooks (actionCmsPageFormBuilderModifier,
+ * actionCmsPageFormDataProviderData, actionAfterCreate/UpdateCmsPageFormHandler)
+ * and persists them natively through the ObjectModel:
+ * $cms->extra_properties['demoextrafield']['promo_banner'] = [id_lang => value];
+ * $cms->update();
+ * promo_banner is LANG-scoped to validate the native multilang round-trip
+ * (no langId in the constructor → all languages read/modified/saved at once).
+ */
+
+ // CMS (lang) : promo_banner
+ $cmsPromoBannerRegistered = $this->registerExtraProperty(
+ new ExtraPropertyDefinition(
+ entityName: 'cms',
+ propertyName: 'promo_banner',
+ type: ExtraPropertyType::STRING,
+ scope: ExtraPropertyScope::LANG,
+ nullable: true,
+ displayApi: true,
+ validator: 'isGenericName',
+ labelWording: $this->trans('Promo banner', [], 'Modules.Demoextrafield.Admin', 'en'),
+ labelDomain: self::TRANSLATION_DOMAIN,
+ descriptionWording: $this->trans('Translated promotional text displayed on the CMS page', [], 'Modules.Demoextrafield.Admin', 'en'),
+ descriptionDomain: self::TRANSLATION_DOMAIN,
+ )
+ );
+ if (!$cmsPromoBannerRegistered) {
+ $this->_errors[] = $this->trans('Failed to register CMS extra field "promo_banner" (scope: lang).', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ // CMS (common) : revision_code
+ $cmsRevisionCodeRegistered = $this->registerExtraProperty(
+ new ExtraPropertyDefinition(
+ entityName: 'cms',
+ propertyName: 'revision_code',
+ type: ExtraPropertyType::STRING,
+ scope: ExtraPropertyScope::COMMON,
+ nullable: true,
+ displayApi: true,
+ validator: 'isGenericName',
+ labelWording: $this->trans('Revision code', [], 'Modules.Demoextrafield.Admin', 'en'),
+ labelDomain: self::TRANSLATION_DOMAIN,
+ descriptionWording: $this->trans('Internal revision code displayed on the CMS page', [], 'Modules.Demoextrafield.Admin', 'en'),
+ descriptionDomain: self::TRANSLATION_DOMAIN,
+ )
+ );
+ if (!$cmsRevisionCodeRegistered) {
+ $this->_errors[] = $this->trans('Failed to register CMS extra field "revision_code" (scope: common).', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ $hooksRegistered = $this->registerHook('displayProductAdditionalInfo')
+ && $this->registerHook('displayCartExtraProductInfo')
+ && $this->registerHook('displayHeaderCategory')
+ && $this->registerHook('displayCustomerAccountTop')
+ && $this->registerHook('displayFooterProduct')
+ && $this->registerHook('actionCmsPageFormBuilderModifier')
+ && $this->registerHook('actionCmsPageFormDataProviderData')
+ && $this->registerHook('actionAfterCreateCmsPageFormHandler')
+ && $this->registerHook('actionAfterUpdateCmsPageFormHandler')
+ && $this->registerHook('displayCMSDisputeInformation');
+ if (!$hooksRegistered) {
+ $this->_errors[] = $this->trans('Failed to register one or more hooks.', [], 'Modules.Demoextrafield.Admin');
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Uninstall:
+ * - unregisters all extra fields,
+ * - drops SQL storage columns,
+ * - unregisters all hooks.
+ */
+ public function uninstall(): bool
+ {
+ // false = keep columns in DB after uninstall
+ $dropColumn = false;
+
+ return
+ $this->unregisterExtraProperty(new ExtraPropertyDefinition('product', 'video_link', scope: ExtraPropertyScope::LANG), $dropColumn)
+ && $this->unregisterExtraProperty(new ExtraPropertyDefinition('product', 'is_dangerous'), $dropColumn)
+ && $this->unregisterExtraProperty(new ExtraPropertyDefinition('product', 'custom_date', scope: ExtraPropertyScope::SHOP), $dropColumn)
+ && $this->unregisterExtraProperty(new ExtraPropertyDefinition('product', 'date_last_seen'), $dropColumn)
+ && $this->unregisterExtraProperty(new ExtraPropertyDefinition('product', 'packaging_type'), $dropColumn)
+
+ && $this->unregisterExtraProperty(new ExtraPropertyDefinition('category', 'theme_color'), $dropColumn)
+ && $this->unregisterExtraProperty(new ExtraPropertyDefinition('category', 'marketing_note'), $dropColumn)
+ && $this->unregisterExtraProperty(new ExtraPropertyDefinition('category', 'id_supplier'), $dropColumn)
+
+ && $this->unregisterExtraProperty(new ExtraPropertyDefinition('customer', 'credit_limit'), $dropColumn)
+ && $this->unregisterExtraProperty(new ExtraPropertyDefinition('customer', 'extra_json'), $dropColumn)
+ && $this->unregisterExtraProperty(new ExtraPropertyDefinition('customer', 'internal_note'), $dropColumn)
+
+ && $this->unregisterExtraProperty(new ExtraPropertyDefinition('address', 'delivery_note'), $dropColumn)
+
+ && $this->unregisterExtraProperty(new ExtraPropertyDefinition('cms', 'promo_banner', scope: ExtraPropertyScope::LANG), $dropColumn)
+ && $this->unregisterExtraProperty(new ExtraPropertyDefinition('cms', 'revision_code'), $dropColumn)
+
+ && $this->unregisterHook('displayProductAdditionalInfo')
+ && $this->unregisterHook('displayCartExtraProductInfo')
+ && $this->unregisterHook('displayHeaderCategory')
+ && $this->unregisterHook('displayCustomerAccountTop')
+ && $this->unregisterHook('displayFooterProduct')
+ && $this->unregisterHook('actionCmsPageFormBuilderModifier')
+ && $this->unregisterHook('actionCmsPageFormDataProviderData')
+ && $this->unregisterHook('actionAfterCreateCmsPageFormHandler')
+ && $this->unregisterHook('actionAfterUpdateCmsPageFormHandler')
+ && $this->unregisterHook('displayCMSDisputeInformation')
+
+ && parent::uninstall();
+ }
+
+ /**
+ * Front Office hook (product page).
+ * Displays this module extra fields from the product LazyArray.
+ */
+ public function hookDisplayProductAdditionalInfo(array $params): string
+ {
+ return $this->display(__FILE__, 'views/templates/hook/product_additional_info.tpl');
+ }
+
+ /**
+ * Front Office hook (product page footer).
+ *
+ * Demo: reads date_last_seen from the Product ObjectModel, displays it, then updates it.
+ *
+ * Access is grouped by module: $product->extra_properties['module']['field']
+ */
+ public function hookDisplayFooterProduct(array $params): string
+ {
+ $productId = (int) ($params['product']['id_product'] ?? 0);
+ if ($productId <= 0) {
+ return '';
+ }
+
+ $product = new Product($productId);
+ if (!Validate::isLoadedObject($product)) {
+ return '';
+ }
+
+ $now = date('Y-m-d H:i:s');
+
+ $dateLastSeen = $product->extra_properties['demoextrafield']['date_last_seen'];
+ $product->extra_properties['demoextrafield']['date_last_seen'] = $now;
+ $product->update();
+
+ $this->context->smarty->assign([
+ 'dateLastSeen' => $dateLastSeen,
+ 'dateLastSeenUpdated' => $now,
+ ]);
+
+ return $this->display(__FILE__, 'views/templates/hook/product_footer.tpl');
+ }
+
+ /**
+ * Front Office hook (cart).
+ * Displays this module extra fields for products in cart.
+ *
+ * $params['product'] is the product LazyArray passed by the cart template.
+ */
+ public function hookDisplayCartExtraProductInfo(array $params): string
+ {
+ $this->context->smarty->assign('product', $params['product'] ?? []);
+
+ return $this->display(__FILE__, 'views/templates/hook/cart_extra_product_info.tpl');
+ }
+
+ /**
+ * Front Office hook (category listing page).
+ * Displays this module extra fields from the category LazyArray.
+ */
+ public function hookDisplayHeaderCategory(): string
+ {
+ return $this->display(__FILE__, 'views/templates/hook/category_header.tpl');
+ }
+
+ /**
+ * Front Office hook (customer my-account page).
+ *
+ * Demonstrates that an ObjectModel instance can be handed to Smarty as-is: the template
+ * reads the lazy ExtraPropertiesBag through object syntax
+ * ({$customerObjectModel->extra_properties.demoextrafield.field_name}) — no presenter,
+ * no array conversion. The first hop uses `->` (ObjectModel is not ArrayAccess); the bag
+ * levels then support dot syntax and iteration.
+ *
+ * (The presented `$customer` Smarty global would work too — ObjectPresenter populates its
+ * `extra_properties` key — but passing the ObjectModel directly is the point of this demo.)
+ *
+ * No manual filtering is needed: since this hook runs in a front-office controller, the
+ * native bag is built with forFrontOffice: true automatically, so fields like
+ * 'internal_note' (displayFront: false) are never read nor exposed.
+ */
+ public function hookDisplayCustomerAccountTop(): string
+ {
+ $customer = $this->context->customer;
+ if (null === $customer || (int) $customer->id <= 0) {
+ return '';
+ }
+
+ $this->context->smarty->assign('customerObjectModel', $customer);
+
+ return $this->display(__FILE__, 'views/templates/hook/customer_account_top.tpl');
+ }
+
+ /**
+ * Back Office hook (CMS page form, Design > Pages) — MANUAL form integration, step 1/3.
+ *
+ * Adds the two CMS extra fields to the migrated Symfony form. They are NOT registered
+ * with associatedForms, so the native ExtraPropertiesFormBuilderModifier ignores them;
+ * the module owns the whole integration (same pattern as the devdocs sample
+ * "extending a Symfony form", but persistence goes through the ObjectModel natively —
+ * no custom table, no Doctrine entity).
+ *
+ * TranslatableType submits [id_lang => value] — exactly the shape the ExtraPropertiesBag
+ * uses for lang-scoped fields.
+ */
+ public function hookActionCmsPageFormBuilderModifier(array $params): void
+ {
+ $params['form_builder']
+ ->add('demoextrafield_promo_banner', TranslatableType::class, [
+ 'type' => TextType::class,
+ 'label' => $this->trans('Promo banner (demoextrafield)', [], 'Modules.Demoextrafield.Admin'),
+ 'required' => false,
+ ])
+ ->add('demoextrafield_revision_code', TextType::class, [
+ 'label' => $this->trans('Revision code (demoextrafield)', [], 'Modules.Demoextrafield.Admin'),
+ 'required' => false,
+ ]);
+ }
+
+ /**
+ * Back Office hook (CMS page edit form) — MANUAL form integration, step 2/3 (prefill).
+ *
+ * $params['data'] is passed by reference BEFORE the form is created, so values set here
+ * pre-populate the fields added in hookActionCmsPageFormBuilderModifier.
+ *
+ * The CMS ObjectModel is instantiated WITHOUT a langId: promo_banner comes back as a
+ * [id_lang => value] array — the exact data shape TranslatableType expects.
+ */
+ public function hookActionCmsPageFormDataProviderData(array $params): void
+ {
+ $cmsId = (int) ($params['id'] ?? 0);
+ if ($cmsId <= 0) {
+ return;
+ }
+
+ $cms = new CMS($cmsId);
+ $params['data']['demoextrafield_promo_banner'] = (array) ($cms->extra_properties['demoextrafield']['promo_banner'] ?? []);
+ $params['data']['demoextrafield_revision_code'] = (string) ($cms->extra_properties['demoextrafield']['revision_code'] ?? '');
+ }
+
+ /**
+ * Back Office hook (CMS page form submit, creation) — MANUAL form integration, step 3/3.
+ */
+ public function hookActionAfterCreateCmsPageFormHandler(array $params): void
+ {
+ $this->saveCmsExtraProperties($params);
+ }
+
+ /**
+ * Back Office hook (CMS page form submit, update) — MANUAL form integration, step 3/3.
+ */
+ public function hookActionAfterUpdateCmsPageFormHandler(array $params): void
+ {
+ $this->saveCmsExtraProperties($params);
+ }
+
+ /**
+ * Persists the two CMS extra fields natively through the ObjectModel.
+ *
+ * This is the native multilang round-trip: the CMS is instantiated WITHOUT a langId,
+ * so assigning the full [id_lang => value] array to the lang-scoped field updates ALL
+ * languages in one save — persistExtraProperties() validates each language and issues
+ * one UPSERT per language into cms_extra_lang (plus one into cms_extra for the common field).
+ */
+ private function saveCmsExtraProperties(array $params): void
+ {
+ $cmsId = (int) ($params['id'] ?? 0);
+ $formData = (array) ($params['form_data'] ?? []);
+ if ($cmsId <= 0 || [] === $formData) {
+ return;
+ }
+
+ $cms = new CMS($cmsId);
+ if (!Validate::isLoadedObject($cms)) {
+ return;
+ }
+
+ $cms->extra_properties['demoextrafield']['promo_banner'] = (array) ($formData['demoextrafield_promo_banner'] ?? []);
+ $cms->extra_properties['demoextrafield']['revision_code'] = (string) ($formData['demoextrafield_revision_code'] ?? '');
+ $cms->update();
+ }
+
+ /**
+ * Front Office hook (CMS page, e.g. /content/4-about-us).
+ *
+ * The CMS ObjectModel is instantiated WITH the context langId: lang-scoped fields come
+ * back as scalars for the current language, and the bag is FO-filtered automatically
+ * (front-office controller context).
+ */
+ public function hookDisplayCMSDisputeInformation(array $params): string
+ {
+ $cmsId = (int) Tools::getValue('id_cms');
+ if ($cmsId <= 0) {
+ return '';
+ }
+
+ $this->context->smarty->assign('cmsObjectModel', new CMS($cmsId, (int) $this->context->language->id));
+
+ return $this->display(__FILE__, 'views/templates/hook/cms_page_extra.tpl');
+ }
+}
diff --git a/demoextrafield/index.php b/demoextrafield/index.php
new file mode 100644
index 0000000..a9295d4
--- /dev/null
+++ b/demoextrafield/index.php
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Color associated with the category (required)
+ Couleur associée à la catégorie (obligatoire)
+
+
+ Credit limit
+ Limite de crédit
+
+
+ Custom date
+ Date personnalisée
+
+
+ Custom date per shop
+ Date personnalisée par boutique
+
+
+ Dangerous product
+ Produit dangereux
+
+
+ Date last seen
+ Date de dernière consultation
+
+
+ Date last seen (extra field demo)
+ Date de dernière consultation (démo champ supplémentaire)
+
+
+ Default supplier
+ Fournisseur par défaut
+
+
+ Delivery note
+ Note de livraison
+
+
+ Failed to register Address extra field "delivery_note" (scope: common).
+ Échec de l’enregistrement du champ supplémentaire Adresse "delivery_note" (portée : common).
+
+
+ Failed to register Category extra field "id_supplier" (scope: common).
+ Échec de l’enregistrement du champ supplémentaire Catégorie "id_supplier" (portée : common).
+
+
+ Failed to register Category extra field "marketing_note" (scope: common).
+ Échec de l’enregistrement du champ supplémentaire Catégorie "marketing_note" (portée : common).
+
+
+ Failed to register Category extra field "theme_color" (scope: common).
+ Échec de l’enregistrement du champ supplémentaire Catégorie "theme_color" (portée : common).
+
+
+ Failed to register Customer extra field "credit_limit" (scope: common).
+ Échec de l’enregistrement du champ supplémentaire Client "credit_limit" (portée : common).
+
+
+ Failed to register Customer extra field "extra_json" (scope: common).
+ Échec de l’enregistrement du champ supplémentaire Client "extra_json" (portée : common).
+
+
+ Failed to register Customer extra field "internal_note" (scope: common).
+ Échec de l’enregistrement du champ supplémentaire Client "internal_note" (portée : common).
+
+
+ Failed to register Product extra field "custom_date" (scope: shop).
+ Échec de l’enregistrement du champ supplémentaire Produit "custom_date" (portée : shop).
+
+
+ Failed to register Product extra field "date_last_seen" (scope: common).
+ Échec de l’enregistrement du champ supplémentaire Produit "date_last_seen" (portée : shop).
+
+
+ Failed to register Product extra field "is_dangerous" (scope: common).
+ Échec de l’enregistrement du champ supplémentaire Produit "is_dangerous" (portée : shop).
+
+
+ Failed to register Product extra field "packaging_type" (scope: common).
+ Échec de l’enregistrement du champ supplémentaire Produit "packaging_type" (portée : shop).
+
+
+ Failed to register Product extra field "video_link" (scope: lang).
+ Échec de l’enregistrement du champ supplémentaire Produit "video_link" (portée : lang).
+
+
+ Failed to register one or more hooks.
+ Échec de l’enregistrement d’un ou plusieurs hooks.
+
+
+ Free JSON for customer metadata
+ JSON libre pour les métadonnées client
+
+
+ Free delivery note attached to this address
+ Note de livraison libre associée à cette adresse
+
+
+ Free note displayed in BO, API and FO
+ Note libre affichée dans le BO, l’API et le FO
+
+
+ Indicates whether the product is dangerous
+ Indique si le produit est dangereux
+
+
+ Internal note
+ Note interne
+
+
+ Last time this product page was viewed
+ Dernière consultation de cette fiche produit
+
+
+ Marketing note
+ Note marketing
+
+
+ Maximum customer credit amount
+ Montant maximal de crédit client
+
+
+ Merchant-only note — never exposed on the front office
+ Note réservée au marchand — jamais affichée sur le front-office
+
+
+ Metadata JSON
+ JSON des métadonnées
+
+
+ Never seen before
+ Jamais consulté auparavant
+
+
+ Packaging type
+ Type d’emballage
+
+
+ Previous value
+ Valeur précédente
+
+
+ Select a PrestaShop supplier
+ Sélectionnez un fournisseur PrestaShop
+
+
+ Selectable packaging type for this product
+ Type d’emballage sélectionnable pour ce produit
+
+
+ Theme color
+ Couleur du thème
+
+
+ Updated to
+ Mis à jour vers
+
+
+ Video URL per language
+ URL de la vidéo par langue
+
+
+ Video link
+ Lien vidéo
+
+
+ ⚠ This product is marked as dangerous.
+ ⚠ Ce produit est marqué comme dangereux.
+
+
+
+
diff --git a/demoextrafield/translations/fr-FR/ModulesDemoextrafieldMain.fr-FR.xlf b/demoextrafield/translations/fr-FR/ModulesDemoextrafieldMain.fr-FR.xlf
new file mode 100644
index 0000000..f647c11
--- /dev/null
+++ b/demoextrafield/translations/fr-FR/ModulesDemoextrafieldMain.fr-FR.xlf
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Extra fields (demoextrafield)
+ Champs extra (demoextrafield)
+
+
+ No extra fields found for this module.
+ Aucun champ extra pour ce module.
+
+
+
+
diff --git a/demoextrafield/views/templates/hook/_extra_properties.tpl b/demoextrafield/views/templates/hook/_extra_properties.tpl
new file mode 100644
index 0000000..a3dcad6
--- /dev/null
+++ b/demoextrafield/views/templates/hook/_extra_properties.tpl
@@ -0,0 +1,28 @@
+{*
+ Displays all extra fields registered by this module for a given entity.
+
+ Usage: {include file='./_extra_properties.tpl' objectModel=$product}
+ where $objectModel is a LazyArray or a raw ObjectModel exposing extra_properties.{moduleName}.{fieldName}.
+ The first hop uses `->` (works on both: LazyArray and ObjectModel resolve it via __get;
+ ObjectModel is not ArrayAccess so dot syntax would fail on it). `extra_properties` is an
+ ExtraPropertiesBag and each module entry a ModuleFieldsBag — both support dot syntax and iteration.
+
+ Note on lang-scoped fields (scope="lang"):
+ The ExtraPropertyReader translates the per-language array into a single scalar value for the
+ current storefront language before returning it. No special handling is needed here.
+
+ Note on displayFront=false fields:
+ Filtering is native — they never reach this template. Presenter lazy arrays ($product,
+ $category…) are built with forFrontOffice: true, and ObjectModel bags ($customer->extra_properties)
+ detect the front-office controller context automatically.
+*}
+
+ {foreach from=$objectModel->extra_properties.demoextrafield key=fieldName item=fieldValue}
+
+ {$fieldName|escape:'htmlall':'UTF-8'}:
+ {$fieldValue|escape:'htmlall':'UTF-8'}
+
+ {foreachelse}
+ {l s='No extra fields found for this module.' d='Modules.Demoextrafield.Main'}
+ {/foreach}
+
diff --git a/demoextrafield/views/templates/hook/cart_extra_product_info.tpl b/demoextrafield/views/templates/hook/cart_extra_product_info.tpl
new file mode 100644
index 0000000..b25a251
--- /dev/null
+++ b/demoextrafield/views/templates/hook/cart_extra_product_info.tpl
@@ -0,0 +1,6 @@
+
+
diff --git a/demoextrafield/views/templates/hook/category_header.tpl b/demoextrafield/views/templates/hook/category_header.tpl
new file mode 100644
index 0000000..e47e9a1
--- /dev/null
+++ b/demoextrafield/views/templates/hook/category_header.tpl
@@ -0,0 +1,5 @@
+
diff --git a/demoextrafield/views/templates/hook/cms_page_extra.tpl b/demoextrafield/views/templates/hook/cms_page_extra.tpl
new file mode 100644
index 0000000..2e60af0
--- /dev/null
+++ b/demoextrafield/views/templates/hook/cms_page_extra.tpl
@@ -0,0 +1,17 @@
+
diff --git a/demoextrafield/views/templates/hook/customer_account_top.tpl b/demoextrafield/views/templates/hook/customer_account_top.tpl
new file mode 100644
index 0000000..517095d
--- /dev/null
+++ b/demoextrafield/views/templates/hook/customer_account_top.tpl
@@ -0,0 +1,8 @@
+
diff --git a/demoextrafield/views/templates/hook/product_additional_info.tpl b/demoextrafield/views/templates/hook/product_additional_info.tpl
new file mode 100644
index 0000000..9bca1e4
--- /dev/null
+++ b/demoextrafield/views/templates/hook/product_additional_info.tpl
@@ -0,0 +1,14 @@
+
diff --git a/demoextrafield/views/templates/hook/product_footer.tpl b/demoextrafield/views/templates/hook/product_footer.tpl
new file mode 100644
index 0000000..beba2c8
--- /dev/null
+++ b/demoextrafield/views/templates/hook/product_footer.tpl
@@ -0,0 +1,17 @@
+