---
title: Admin Tab + CRUD completo — ecom_faqs
section: examples
slug: admin-tab-crud
description: Ejemplo real de modulo con Tab en el menu admin, ObjectModel, HelperList con filtros y HelperForm con validacion. Sistema de FAQs completo.
keywords: prestashop admin tab crud objectmodel helperlist helperform faq modulo ejemplo
last_updated: 2025-01-15
source_url: "https://ayudaprestashop.es/examples/admin-tab-crud"
---

# Admin Tab + CRUD completo — ecom_faqs

> Ejemplo real de modulo con Tab en el menu admin, ObjectModel, HelperList con filtros y HelperForm con validacion. Sistema de FAQs completo.

> **[TIP] Patron clasico de admin**
>
> Este es el patron mas usado en modulos PS 1.6-1.7: ObjectModel + AdminController + Tab. Funciona en PS 8.x tambien. Para PS 9.x se recomienda migrar a Symfony controllers, pero este patron legacy sigue soportado.

## Que construimos

Un sistema de FAQs administrable desde el Back Office. El admin puede crear, editar, ordenar y eliminar preguntas frecuentes. Las FAQs se muestran en el front via hook. Incluye: tabla custom, ObjectModel multilang, AdminController con list+form, y hook de display.

## ObjectModel — FaqItem

*classes/FaqItem.php — ObjectModel con multilang*

```php
<?php
class FaqItem extends ObjectModel
{
    /** @var int */
    public $id_faq_item;

    /** @var string Pregunta (multilang) */
    public $question;

    /** @var string Respuesta (multilang) */
    public $answer;

    /** @var int Categoria de FAQ */
    public $id_faq_category;

    /** @var int Posicion para ordenar */
    public $position;

    /** @var bool Activo/inactivo */
    public $active;

    /** @var string */
    public $date_add;

    /** @var string */
    public $date_upd;

    /**
     * Definicion del ObjectModel
     * - 'lang' => true para campos multilang
     * - 'validate' => tipo de validacion automatica
     * - 'required' => true para campos obligatorios
     */
    public static $definition = [
        'table'     => 'ecom_faq_item',
        'primary'   => 'id_faq_item',
        'multilang' => true,
        'fields'    => [
            'id_faq_category' => ['type' => self::TYPE_INT,    'validate' => 'isUnsignedId'],
            'position'        => ['type' => self::TYPE_INT,    'validate' => 'isUnsignedInt'],
            'active'          => ['type' => self::TYPE_BOOL,   'validate' => 'isBool'],
            'date_add'        => ['type' => self::TYPE_DATE,   'validate' => 'isDate'],
            'date_upd'        => ['type' => self::TYPE_DATE,   'validate' => 'isDate'],
            // Campos multilang
            'question'        => ['type' => self::TYPE_STRING, 'validate' => 'isCleanHtml', 'lang' => true, 'required' => true, 'size' => 500],
            'answer'          => ['type' => self::TYPE_HTML,   'validate' => 'isCleanHtml', 'lang' => true, 'required' => true, 'size' => 65535],
        ],
    ];

    /**
     * Obtener FAQs activas para el front
     */
    public static function getActiveFaqs(int $idLang, int $idCategory = 0, int $limit = 50): array
    {
        $sql = (new DbQuery())
            ->select('f.*, fl.question, fl.answer')
            ->from('ecom_faq_item', 'f')
            ->innerJoin('ecom_faq_item_lang', 'fl',
                'f.id_faq_item = fl.id_faq_item AND fl.id_lang = ' . (int) $idLang)
            ->where('f.active = 1')
            ->orderBy('f.position ASC')
            ->limit($limit);

        if ($idCategory > 0) {
            $sql->where('f.id_faq_category = ' . (int) $idCategory);
        }

        return Db::getInstance(_PS_USE_SQL_SLAVE_)->executeS($sql->build()) ?: [];
    }
}
```

## Registrar el Tab en install()

*install() y uninstall() con Tab*

```php
<?php
public function install()
{
    return parent::install()
        && $this->executeSqlFile('install')
        && $this->installTab()
        && $this->registerHook('displayFooter');
}

public function uninstall()
{
    return $this->uninstallTab()
        && $this->executeSqlFile('uninstall')
        && parent::uninstall();
}

/**
 * Crear tab en el menu del BO
 * class_name DEBE coincidir con el nombre del AdminController
 */
protected function installTab(): bool
{
    $tab = new Tab();
    $tab->active = 1;
    $tab->class_name = 'AdminEcomFaqs';   // → controllers/admin/AdminEcomFaqsController.php
    $tab->module = $this->name;
    $tab->id_parent = (int) Tab::getIdFromClassName('AdminCatalog'); // Bajo "Catalogo"
    $tab->icon = 'help_outline'; // Material icon (PS 1.7+)

    // Nombre del tab en cada idioma
    foreach (Language::getLanguages(true) as $lang) {
        $tab->name[$lang['id_lang']] = 'FAQs';
    }

    return $tab->add();
}

protected function uninstallTab(): bool
{
    $idTab = (int) Tab::getIdFromClassName('AdminEcomFaqs');
    if ($idTab) {
        $tab = new Tab($idTab);
        return $tab->delete();
    }
    return true;
}

protected function executeSqlFile(string $name): bool
{
    $file = dirname(__FILE__) . '/sql/' . $name . '.sql';
    if (!file_exists($file)) {
        return false;
    }
    $sql = str_replace(
        ['PREFIX_', 'ENGINE_TYPE'],
        [_DB_PREFIX_, _MYSQL_ENGINE_],
        file_get_contents($file)
    );
    // Ejecutar multiples queries separadas por ;
    foreach (array_filter(array_map('trim', explode(';', $sql))) as $query) {
        if (!Db::getInstance()->execute($query)) {
            return false;
        }
    }
    return true;
}
```

*sql/install.sql*

```sql
CREATE TABLE IF NOT EXISTS `PREFIX_ecom_faq_item` (
    `id_faq_item` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
    `id_faq_category` INT(10) UNSIGNED NOT NULL DEFAULT 0,
    `position` INT(10) UNSIGNED NOT NULL DEFAULT 0,
    `active` TINYINT(1) NOT NULL DEFAULT 1,
    `date_add` DATETIME NOT NULL,
    `date_upd` DATETIME NOT NULL,
    PRIMARY KEY (`id_faq_item`),
    KEY `active_position` (`active`, `position`)
) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `PREFIX_ecom_faq_item_lang` (
    `id_faq_item` INT(10) UNSIGNED NOT NULL,
    `id_lang` INT(10) UNSIGNED NOT NULL,
    `question` VARCHAR(500) NOT NULL,
    `answer` TEXT NOT NULL,
    PRIMARY KEY (`id_faq_item`, `id_lang`)
) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8mb4;
```

## AdminController completo

*controllers/admin/AdminEcomFaqsController.php*

```php
<?php
require_once _PS_MODULE_DIR_ . 'ecom_faqs/classes/FaqItem.php';

class AdminEcomFaqsController extends ModuleAdminController
{
    public function __construct()
    {
        $this->table = 'ecom_faq_item';          // Tabla sin prefijo
        $this->className = 'FaqItem';             // Nombre del ObjectModel
        $this->identifier = 'id_faq_item';        // Primary key
        $this->lang = true;                       // Formulario multilang
        $this->bootstrap = true;                  // Usar Bootstrap en templates
        $this->position_identifier = 'id_faq_item'; // Para drag&drop
        $this->_defaultOrderBy = 'position';      // Orden por defecto
        $this->_defaultOrderWay = 'ASC';

        parent::__construct();

        $this->meta_title = 'Gestionar FAQs';
        $this->toolbar_title = 'Preguntas Frecuentes';

        // Definir columnas del listado
        $this->fields_list = $this->getFieldsList();

        // Bulk actions
        $this->bulk_actions = [
            'delete' => [
                'text'    => $this->trans('Delete selected', [], 'Admin.Global'),
                'icon'    => 'icon-trash',
                'confirm' => $this->trans('Delete selected items?', [], 'Admin.Global'),
            ],
            'enableSelection' => [
                'text' => $this->trans('Enable selection', [], 'Admin.Global'),
                'icon' => 'icon-power-off text-success',
            ],
            'disableSelection' => [
                'text' => $this->trans('Disable selection', [], 'Admin.Global'),
                'icon' => 'icon-power-off text-danger',
            ],
        ];
    }

    /**
     * Columnas del HelperList
     */
    protected function getFieldsList(): array
    {
        return [
            'id_faq_item' => [
                'title' => 'ID',
                'align' => 'center',
                'class' => 'fixed-width-xs',
            ],
            'question' => [
                'title'  => 'Pregunta',
                'filter_key' => 'b!question', // b = tabla lang alias
            ],
            'position' => [
                'title'    => 'Posicion',
                'filter_key' => 'a!position',
                'position' => 'position',
                'align'    => 'center',
                'class'    => 'fixed-width-sm',
            ],
            'active' => [
                'title'   => 'Activo',
                'active'  => 'status',       // Genera toggle automatico
                'type'    => 'bool',
                'align'   => 'center',
                'class'   => 'fixed-width-sm',
                'orderby' => false,
            ],
            'date_add' => [
                'title' => 'Creado',
                'type'  => 'datetime',
                'align' => 'center',
            ],
        ];
    }

    /**
     * Formulario de creacion/edicion
     */
    public function renderForm()
    {
        $this->fields_form = [
            'legend' => [
                'title' => 'FAQ',
                'icon'  => 'icon-question-circle',
            ],
            'input' => [
                [
                    'type'     => 'text',
                    'label'    => 'Pregunta',
                    'name'     => 'question',
                    'lang'     => true,
                    'required' => true,
                    'hint'     => 'Maximo 500 caracteres',
                ],
                [
                    'type'         => 'textarea',
                    'label'        => 'Respuesta',
                    'name'         => 'answer',
                    'lang'         => true,
                    'required'     => true,
                    'autoload_rte' => true,  // Editor TinyMCE
                    'hint'         => 'Puedes usar HTML',
                ],
                [
                    'type'     => 'switch',
                    'label'    => 'Activo',
                    'name'     => 'active',
                    'is_bool'  => true,
                    'values'   => [
                        ['id' => 'active_on',  'value' => 1, 'label' => 'Si'],
                        ['id' => 'active_off', 'value' => 0, 'label' => 'No'],
                    ],
                ],
            ],
            'submit' => [
                'title' => $this->trans('Save', [], 'Admin.Global'),
            ],
        ];

        return parent::renderForm();
    }
}
```

## Hook displayFooter — mostrar FAQs en FO

*Mostrar FAQs en el front*

```php
<?php
public function hookDisplayFooter($params)
{
    $faqs = FaqItem::getActiveFaqs(
        (int) $this->context->language->id,
        0,  // todas las categorias
        10  // limite
    );

    if (empty($faqs)) {
        return '';
    }

    $this->context->smarty->assign([
        'faqs'     => $faqs,
        'module_dir' => $this->_path,
    ]);

    return $this->display(__FILE__, 'views/templates/hook/footer-faqs.tpl');
}
```

*views/templates/hook/footer-faqs.tpl*

```smarty
{* Schema.org FAQPage para SEO *}
<section class="ecom-faqs" itemscope itemtype="https://schema.org/FAQPage">
  <h2 class="ecom-faqs__title">{l s='Preguntas Frecuentes' mod='ecom_faqs'}</h2>
  <div class="ecom-faqs__list">
    {foreach from=$faqs item=faq}
    <details class="ecom-faqs__item" itemscope itemprop="mainEntity" itemtype="https://schema.org/Question">
      <summary class="ecom-faqs__question" itemprop="name">
        {$faq.question|escape:'html':'UTF-8'}
      </summary>
      <div class="ecom-faqs__answer" itemscope itemprop="acceptedAnswer" itemtype="https://schema.org/Answer">
        <div itemprop="text">{$faq.answer nofilter}</div>
      </div>
    </details>
    {/foreach}
  </div>
</section>
```


---

*Fuente: [https://ayudaprestashop.es/examples/admin-tab-crud](https://ayudaprestashop.es/examples/admin-tab-crud). Version Markdown generada automaticamente para consumo por LLMs.*
