---
title: Tabs extra en el formulario de producto (BO) — Patron Direct Injection
section: trucos
slug: product-extra-tabs
description: "El patron definitivo para añadir tabs con campos multilingue al formulario de producto en PS 8/9: Direct Injection en FormBuilderModifier y lectura directa de $_POST para guardar."
keywords: prestashop producto formulario tab extra multilingue TinyMCE FormBuilderModifier Direct Injection REPLACE INTO PS8 PS9
last_updated: 2024-12-01
source_url: "https://ayudaprestashop.es/trucos/product-extra-tabs"
---

# Tabs extra en el formulario de producto (BO) — Patron Direct Injection

> El patron definitivo para añadir tabs con campos multilingue al formulario de producto en PS 8/9: Direct Injection en FormBuilderModifier y lectura directa de $_POST para guardar.

Añadir tabs multilingue con TinyMCE al formulario de producto de PS 8 es complejo por el sistema de Data Providers de Symfony. El patron **Direct Injection** evita los problemas mas comunes (campos vacios al guardar, datos que no se muestran) inyectando los datos directamente en el FormBuilder y leyendo `$_POST` directamente al guardar.

> **[!] Por que NO usar DataProvider hook para tabs propios**
>
> El hook actionProductFormDataProviderData es ideal para modificar campos EXISTENTES del core. Para campos propios en tabs nuevos, usar Direct Injection en hookActionProductFormBuilderModifier es mas fiable y evita problemas de indexacion de idiomas.

## El patron Direct Injection

| Paso | Hook / Metodo | Tecnica |
| --- | --- | --- |
| Mostrar datos | hookActionProductFormBuilderModifier | SQL directo + inyeccion en 'data' del create() |
| Guardar datos | hookActionAfterUpdateProductFormHandler | Leer $_POST directamente, REPLACE INTO |
| DataProvider hook | hookActionProductFormDataProviderData | DEJAR VACIO — no usarlo para tabs propios |
| Indexacion de idiomas | Doble clave ISO + ID | content['es'] Y content[1] simultaneamente |

## Estructura de base de datos

*Tabla con clave compuesta id_product + id_shop + id_lang*

```sql
CREATE TABLE IF NOT EXISTS `{prefix}mymodule_product_content` (
    `id_product`     int(11) UNSIGNED NOT NULL,
    `id_shop`        int(11) UNSIGNED NOT NULL,
    `id_lang`        int(11) UNSIGNED NOT NULL,
    `extra_content`  TEXT,
    `extra_title`    VARCHAR(255) DEFAULT '',
    PRIMARY KEY (`id_product`, `id_shop`, `id_lang`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

## Form Builder (carga de datos)

*hookActionProductFormBuilderModifier — Direct Injection con doble indexacion*

```php
<?php

use Symfony\Component\Form\Extension\Core\Type\FormType;
use PrestaShopBundle\Form\Admin\Type\TranslateType;
use PrestaShopBundle\Form\Admin\Type\FormattedTextareaType;

public function install(): bool
{
    return parent::install()
        && $this->registerHook('actionProductFormBuilderModifier')
        && $this->registerHook('actionAfterUpdateProductFormHandler')
        && $this->registerHook('actionAfterCreateProductFormHandler');
}

public function hookActionProductFormBuilderModifier(array $params): void
{
    $formBuilder = $params['form_builder'];
    $idProduct   = (int) ($params['id'] ?? 0);
    $idShop      = (int) Context::getContext()->shop->id;
    $languages   = Language::getLanguages(true);

    // PASO 1: Fetch SQL directo
    $rows = [];
    if ($idProduct > 0) {
        $sql = 'SELECT * FROM `' . _DB_PREFIX_ . 'mymodule_product_content`
                WHERE `id_product` = ' . $idProduct . '
                AND `id_shop` = ' . $idShop;
        $rows = Db::getInstance()->executeS($sql) ?: [];
    }

    // Indexar resultados por id_lang para lookup rapido
    $byLang = [];
    foreach ($rows as $row) {
        $byLang[(int) $row['id_lang']] = $row;
    }

    // PASO 2: Preparar array con DOBLE indexacion (ISO + ID)
    $content = [];
    $title   = [];
    foreach ($languages as $lang) {
        $idLang = (int) $lang['id_lang'];
        $iso    = $lang['iso_code'];
        $val    = $byLang[$idLang]['extra_content'] ?? '';
        $tit    = $byLang[$idLang]['extra_title']   ?? '';

        $content[$iso]    = $val;  // Por ISO (es, en, fr...)
        $content[$idLang] = $val;  // Por ID  (1, 2, 3...)
        $title[$iso]      = $tit;
        $title[$idLang]   = $tit;
    }

    // PASO 3: Crear el tab e inyectar datos directamente
    $tabBuilder = $formBuilder->create('mymodule_extra_tab', FormType::class, [
        'label'        => $this->trans('Contenido Extra', [], 'Modules.Mymodule.Admin'),
        'inherit_data' => false,
        'mapped'       => false,
        'required'     => false,
        'data'         => ['content' => $content, 'title' => $title], // <-- CLAVE
    ]);

    $tabBuilder->add('content', TranslateType::class, [
        'type'     => FormattedTextareaType::class,  // TinyMCE
        'label'    => $this->trans('Contenido HTML', [], 'Modules.Mymodule.Admin'),
        'locales'  => $languages,
        'required' => false,
    ]);

    $formBuilder->add($tabBuilder);
}
```

## Hook de guardado (Raw POST)

*hookActionAfterUpdateProductFormHandler — lectura directa de $_POST*

```php
<?php

public function hookActionAfterUpdateProductFormHandler(array $params): void
{
    $this->saveProductContent($params);
}

public function hookActionAfterCreateProductFormHandler(array $params): void
{
    $this->saveProductContent($params);
}

private function saveProductContent(array $params): void
{
    // LECTURA DIRECTA DE $_POST (no usar $params['form_data'] para tabs custom)
    // Estructura: product[mymodule_extra_tab][content][1] = 'contenido en id_lang 1'
    $moduleData = $_POST['product']['mymodule_extra_tab'] ?? null;

    if (!$moduleData) {
        return;
    }

    $idProduct = (int) $params['id'];
    $idShop    = (int) Context::getContext()->shop->id;
    $languages = Language::getLanguages(true);

    foreach ($languages as $lang) {
        $idLang = (int) $lang['id_lang'];
        $iso    = $lang['iso_code'];

        // Prioridad: buscar por ID, fallback a ISO
        $content = $moduleData['content'][$idLang]
                ?? $moduleData['content'][$iso]
                ?? '';

        // REPLACE INTO para insert o update atomico
        Db::getInstance()->execute(
            'REPLACE INTO `' . _DB_PREFIX_ . 'mymodule_product_content`
             (`id_product`, `id_shop`, `id_lang`, `extra_content`)
             VALUES (' . $idProduct . ', ' . $idShop . ', ' . $idLang . ',
             "' . pSQL($content, true) . '")'
        );
    }
}
```

## Re-inicializar en el Front Office

*Obligatorio: re-inicializar JS al cambiar combinacion en el Front*

```javascript
// SIEMPRE incluir esto si tu modulo tiene logica JS en el front
// El evento 'updatedProduct' se dispara al cambiar combinacion (color, talla)

(function() {
  function initMyModule() {
    // Re-enlazar eventos, inicializar componentes, etc.
  }

  document.addEventListener('DOMContentLoaded', initMyModule);

  // OBLIGATORIO: sin esto el JS muere al cambiar combinacion
  if (typeof prestashop !== 'undefined') {
    prestashop.on('updatedProduct', function(event) {
      initMyModule();
    });
  }
}());
```

## Checklist de implementacion

| Punto | Descripcion | Estado |
| --- | --- | --- |
| Tabla DB | id_product + id_shop + id_lang como clave compuesta | Requerido |
| FormBuilder | SQL directo + 'data' => array en create() | Requerido |
| Doble indexacion | content['es'] Y content[1] simultaneamente | Requerido |
| DataProvider hook | Dejar VACIO para tabs propios | Requerido |
| Save hook | Leer $_POST['product']['tab_name'] directamente | Requerido |
| REPLACE INTO | Operacion atomica insert-or-update | Requerido |
| Front JS | prestashop.on('updatedProduct', ...) para re-inicializar | Si usas JS |


---

*Fuente: [https://ayudaprestashop.es/trucos/product-extra-tabs](https://ayudaprestashop.es/trucos/product-extra-tabs). Version Markdown generada automaticamente para consumo por LLMs.*
