PrestaShop Module Development
When to use
Use this skill for PrestaShop module development tasks such as:
- Creating new modules with modern architecture (Symfony controllers, services, entities)
- Refactoring legacy modules to use modern PrestaShop patterns
- Implementing hooks, actions, and event listeners
- Adding configuration pages (modern Symfony-form approach)
- Creating front office features and widgets
- Setting up database entities and migrations
- Implementing security measures (CSRF, input validation, SQL injection prevention)
- Adding multilingual support and translations
- Converting legacy code patterns (HelperForm, jQuery UI sortable, ObjectModel) to modern equivalents
- Building list pages with the PrestaShop Grid system (filters, pagination, toggle, drag-and-drop position)
- Creating and managing PrestaShop cart rules (vouchers, discount codes, promotional codes)
- Implementing Symfony console commands for background tasks and batch operations
Inputs required
- PrestaShop version (target 8.x/9.x for modern development)
- Module scope and functionality requirements
- Existing module path (if updating legacy code)
- Database schema requirements (if applicable)
- Front office integration needs (hooks, widgets, pages)
- Configuration requirements (settings, admin interface)
- Multilingual requirements and supported languages
Procedure
0) Project structure & namespace naming
Read: references/module-structure.md
Key rules:
- Derive PSR-4 namespace from the module name prefix — never use
PrestaShop\Module\ - Use the PrestaShop Module Generator to scaffold new modules
1) Main module class & installer
Read: references/module-class-and-installer.md
MANDATORY patterns:
- install() method: ALWAYS
if (!parent::install()) { return false; }FIRST, THEN delegate to installer - uninstall() method: ALWAYS installer FIRST, THEN
parent::uninstall()with&&chaining:return $installer->uninstall() && parent::uninstall(); - Hooks: Define as class property array
private array $hooks = [...]in Installer, use$module->registerHook($this->hooks)to register - Tabs: Define as class property array
private array $tabs = [...]with structured data:name,class_name,label,parent_class_name,visible - Parent group tab: Auto-check if exists, if not, prepend to tabs array with
array_unshift($this->tabs, $parentTab) - No getTabs(): NEVER add
getTabs()method to module class — all tab management in Installer only
Key rules:
- Always
require_once __DIR__ . '/vendor/autoload.php';after the_PS_VERSION_guard - Never put hook registration, DB queries, or
Configuration::calls directly ininstall()— delegate tosrc/Install/Installer.php getContent()must only redirect to the Symfony route using the modern Symfony router:$router = $this->get('router'); $route = $router->generate('route_name'); Tools::redirectAdmin($route);— NEVER use$this->context->link->getAdminLink()- No SQL in the main module class — all database access (including in hooks like
hookActionShopDataDuplicationand widget methods likegetWidgetVariables) must be delegated to the Repository or Manager class via$this->get('service.id') - Service access: use
$this->has()+ null check in admin context; use plain$this->get()+ null check in front-office context. NEVER useContainerFinder— it is unnecessary. Seereferences/module-class-and-installer.md→ Guard patterns section.
1b) Configuration variable management
Read: references/configuration-management.md
MANDATORY pattern: YAML-based configuration with ConfigurationManager service
Key rules:
- NEVER use
Configuration::updateValue()orConfiguration::get()directly in business logic - ALWAYS create
/config_data/module.ymlto define all configuration keys and default values (single source of truth) - ALWAYS use
ConfigurationInstallerservice for install/uninstall operations - ALWAYS use
ConfigurationManagerservice to read configuration (from database or YAML fallback) - Structure:
config_data/module.yml— all keys + defaultssrc/Install/ConfigurationInstaller.php— install/uninstall config valuessrc/Configuration/ConfigurationManager.php— read config (DB or YAML)config/components/configuration/configuration_manager.yml— register service
- Benefits: Single source of truth, type-safe access, no hardcoded keys, easy to test and maintain
2) Modern configuration pages
Read: references/configuration-page.md
MANDATORY pattern: FormHandler with constructor dependency injection
Key rules:
- Do NOT use
HelperForm— use Symfony form components +FrameworkBundleAdminController - Do NOT access services via
$this->get()in controllers — Controllers have a limited service locator. Use constructor dependency injection with FormHandler instead - Five classes:
DataConfiguration,FormDataProvider,FormType,FormHandler(PrestaShop Core),Controller(with DI) - Wire everything in
config/components/configuration/services.yml(imported byconfig/admin/services.yml) andconfig/routes.yml - FormHandler: Use
PrestaShop\PrestaShop\Core\Form\Handlerservice that wraps form creation, validation, and saving - Controller registration: Must be registered as
public: trueservice withcontroller.service_argumentstag - FormType registration: Must have
parent: 'form.type.translatable.aware'andtags: [{ name: form.type }] - Twig template form wrapping: ALWAYS use
{% form_theme configurationForm '@PrestaShop/Admin/TwigTemplateForm/prestashop_ui_kit.html.twig' %}before{% extends %}; place{{ form_start() }}BEFORE<div class="card">and{{ form_end() }}AFTER</div>— form tags must wrap the entire card structure at the same nesting level to prevent broken HTML - Controller pattern:
private FormHandlerInterface $formHandler; public function __construct(FormHandlerInterface $formHandler) { $this->formHandler = $formHandler; } public function indexAction(Request $request): Response { $form = $this->formHandler->getForm(); // ... handle request and save via $this->formHandler->save() } - CategoryChoiceTreeType: PrestaShop's form type for hierarchical category selection trees. Requires specific setup:
- Valid form options:
'multiple' => truefor multi-selection;'required' => falsefor optional. DO NOT use'expanded'option — it does not exist for this type - Template requirements: Add
{% form_theme configurationForm '@PrestaShop/Admin/TwigTemplateForm/prestashop_ui_kit.html.twig' %}before{% extends %}directive; include JS bundle in{% block javascripts %} - JavaScript initialization: Create
views/js/mymodule.form.bundle.jswithwindow.prestashop.component.initComponents(['ChoiceTree'])andnew window.prestashop.component.ChoiceTree('#form_categories').enableAutoCheckChildren()— selector must match the HTMLidattribute (typicallyform_<field_name>) - Form data handling: Returns array of category IDs; store as comma-separated string via
implode(',', $data['categories']); retrieve and convert back witharray_filter(array_map('intval', explode(',', $storedValue))) - Common issues: Tree renders as plain checkboxes → missing form theme; expand/collapse doesn't work → missing JS initialization; "ChoiceTree is not defined" → use
window.prestashop.component.ChoiceTreenot plainChoiceTree
- Valid form options:
3) Database operations & entities
Read: references/database-and-entities.md
For translatable entities with Grid: read references/entity-doctrine.md
- Always use Doctrine ORM (Entity + LangEntity + Repository + Manager) for any entity that has a Grid list page or translatable fields
- ObjectModel is legacy — do not use in new or modernised modules
- Entity class name = table name without
_DB_PREFIX_— PS adds the prefix globally; use@ORM\Table()with noname=parameter - Prefix table names with a consistent company or module prefix (e.g.
prefix_mymodule_items,prefix_mymodule_items_lang) — to group tables together and avoid conflicts with PS core tables - Lang entity property types must match the DB column nullability: columns declared
NOT NULL DEFAULT ''usestring $field = ''; columns declaredNULLuse?string $field = null TranslatableTypereturnsnullfor languages the user did not fill in — in the Manager, always coerce null to''forNOT NULLstring fields:$row->setName((string) ($value ?? '')). Never pass the raw form value directly to a setter on aNOT NULLcolumn- Do NOT create
MetadataListeneror Doctrine event listeners for table naming - Always sanitize raw DBAL SQL: cast IDs with
(int), use bound parameters - No raw SQL (
Db::getInstance(),pSQL(),_DB_PREFIX_string concatenation) outside Repository and Manager classes. This applies everywhere: main module class, Installer, FixturesInstaller, hooks, widget methods. The only exception isInstallerSQL schema queries (CREATE TABLE,DROP TABLE) which have no Repository equivalent. - FixturesInstaller must use
Db::getInstance()raw SQL —SymfonyContainer::getInstance()returnsnullin thepr:moSymfony console context (global $kernelis never set). All Doctrine ORM calls silently do nothing at install time. Seereferences/module-class-and-installer.md→ FixturesInstaller section. Db::getValue()appendsLIMIT 1internally — never writeLIMIT 1in the SQL string passed to it; causes a MariaDB syntax error.
Services split & components architecture
Read: references/services-split.md
Key rules:
- Do NOT create
config/services.yml— onlyconfig/admin/services.ymlandconfig/front/services.ymlare needed; a root-levelconfig/services.ymlis never required and should not exist config/admin/services.ymlis loaded by admin kernel only — import../common.yml+ admin components (nevercommon.ymlwithout the../prefix)- Repository services only in
config/common.ymlcomponents (Doctrine-level, noPrestaShopBundledeps) - All
PrestaShopBundle-dependent services go inconfig/admin/services.yml - Always split into component sub-folders under
config/components/— never one flatservices.yml - Only services accessed via
$this->get('service.id')needpublic: true— declare it explicitly on each such service; all other services remain private (omitpublic:). Avoid_defaults: public: trueas a blanket: it is against Symfony best practices and causes a fatal error in PrestaShop's Symfony version when combined withparent:services - Services that use
parent:and need to be public must declarepublic: trueexplicitly on the service definition —publiccannot be inherited from_defaultswhenparentis set (PrestaShop's Symfony version enforces this) - Never point
config/services.ymlatadmin/services.yml— this loads admin-only services into the front kernel, breaking container compilation and making all$this->get()calls fail silently
4) Security (mandatory)
Read: references/security.md
- CSRF: handled by Symfony forms automatically; validate manually for raw AJAX endpoints
- SQL injection:
(int)+pSQL()on every value, or useDbQuerybuilder - File uploads: pass full
$_FILES-compatible array (includingtype,size,error) toImageManager::validateUpload()
5) Hooks & front office integration
Read: references/hooks-and-front-office.md
- CRITICAL: For front-office CSS/JS registration, ALWAYS use
actionFrontControllerSetMediahook, NEVER useheaderordisplayHeader— use$this->context->controller->registerStylesheet()and->registerJavascript()in that hook - Register hooks in
Installer, not ininstall()directly - When implementing
WidgetInterface, DO NOT define explicit hook methods likehookDisplayHome()that just callrenderWidget()— PrestaShop automatically callsrenderWidget()for all registered display hooks - For front-office Smarty templates: use
{l s='Text' d='Modules.Modulename.Front'}directly in template, NEVER pass pre-translated strings as Smarty variables fromgetWidgetVariables()— only pass dynamic data (URLs, IDs, etc.), not static translatable UI text
5b) Theme template injection (widget call on install)
Read: references/theme-template-injection.md
- PS8 does not support theme overrides from modules — use marker-based file patching instead
- Two-class design:
ThemeTemplateInjector(service, reusable) +ThemeTemplateInstaller(install orchestrator) - Never use
Theme::getThemes()in install context — legacy class, not autoloaded in Symfony console; usescandir(_PS_ALL_THEMES_DIR_)instead - Wrap install/uninstall calls in try/catch returning
true— theme injection must never block module install
6) Translations
Read: references/translations.md
FrameworkBundleAdminController::trans()signature istrans($id, $domain, $parameters = [])— NOT Symfony's order — always call as$this->trans('Text', 'Modules.Mymodule.Admin')(never$this->trans('Text', [], 'Domain')— passing[]as domain and a string as$parameterscauses a fatal type error)- Use
'Text'|trans({}, 'Modules.Mymodule.Admin')in Twig - Declare
isUsingNewTranslationSystem(): truein the module class
7) Legacy code conversion
Read: references/legacy-conversion.md
Common conversions: HelperForm → Symfony form, jQuery UI sortable → Grid PositionColumn, ModuleAdminController → FrameworkBundleAdminController.
8) Services & dependency injection
Read: references/services-and-di.md
CRITICAL: Never use legacy static calls in services/controllers:
- ❌
Context::getContext()— inject$context: "@=service('prestashop.adapter.legacy.context').getContext()"instead - ❌
Configuration::get()/updateValue()— inject@prestashop.adapter.legacy.configurationinstead - ❌
Context::getContext()->getTranslator()— inject@translatorinstead
Key rules:
- Define services in
config/components/sub-folders (imported byconfig/admin/services.yml) - Use
$this->get('service.id')in Symfony controllers - Use Expression Language (
@=) for computed constructor arguments (context, language ID, shop ID) - Always inject dependencies via constructor, never use static accessors
Symfony Console Commands
Read: references/services-and-di.md → Symfony Console Commands section
CRITICAL: Commands must be registered in config/admin/services.yml (NOT common.yml or front/services.yml):
# config/admin/services.yml
mymodule.command.my_command:
class: Vendor\MyModule\Command\MyCommand
arguments: ["@mymodule.service.my_service"]
tags:
- { name: console.command, command: modulename:action }
Key rules:
- Command naming:
modulename:actionformat (e.g.,wsautocartrules:create,ws_keepdblight:cleandb) - Return codes:
0for success,1for failure (NOTCommand::SUCCESS/FAILUREconstants) - Use
SymfonyStylefor rich console output (tables, progress bars, styled messages) - Add
--limitoptions for batch operations to enable testing with small datasets - Always inject services via constructor, never use static calls or
$this->get()
9) Cart Rules & Vouchers
Read: references/cart-rules.md
Complete guide for creating and managing PrestaShop cart rules (discount vouchers) programmatically:
Key patterns:
- Code generation: Use character set
123456789ABCDEFGHIJKLMNPQRSTUVWXYZ(no O/0 to avoid confusion, same as PrestaShop admin.js) - Uniqueness check: Validate with
CartRule::getIdByCode($code)before creation - Multilingual names: Set
namefor all active languages viaLanguage::getLanguages(true) - Customer restriction: Use
$cartRule->id_customerfor customer-specific vouchers - Date range: Both
date_fromanddate_torequired (format:Y-m-d H:i:s) - Reduction types:
reduction_percent,reduction_amount,free_shipping, orgift_product - Essential fields:
quantity,quantity_per_user,priority,highlight,partial_use,active - Restrictions: All default to
false(no restriction); set specific restrictions as needed
Common use cases documented:
- Customer-specific vouchers with percentage discount
- Auto-apply cart rules (no code required)
- Limited-time flash sales with highlight
- Free shipping vouchers
- Gift product vouchers
10) Grid system (list pages with drag-and-drop position)
Read: references/grid-system.md
Full pattern for building CRUD list pages with the PS Grid system:
GridDefinitionFactory— columns (PositionColumn,ToggleColumn,ActionColumn), filters, row actionsQueryBuilder— Doctrine DBAL query with sorting, pagination, and filtersFilters— default sort/limit settings- 5 service definitions in
config/components/grid/(factory, query, data, grid, position) + 1 Twig FilesystemLoader - Twig FilesystemLoader path is
%kernel.project_dir%/modules/mymodule/views—%kernel.project_dir%is the PS root (parent ofapp/), somodules/is a direct child. Never use%kernel.project_dir%/../modules/ - 4 routes in
routes.yml(index, search, toggle, update-position) - 4 controller actions (
indexAction,searchAction,toggleStatus,updatePositionAction) - Pre-built JS bundle (copied from
ws-entity-grid-skeleton, grid ID replaced viased)
Verification
- Module installs without PHP errors:
php bin/console pr:mo install mymodule - Configuration saves correctly with proper validation
- Front office features display and function properly
- Translations work in all configured languages
Validation
AI agent rule — NEVER SKIP EITHER STEP. Read
references/validation.mdfor full instructions.
Step 1 — lotr (run from the module root)
vendor/websenso/prestashop-module-devtools/bin/lotr
Expected: 🎉 All commands completed successfully! Executed: 6/6
Step 2 — Install test (run from the PS root)
php bin/console pr:mo install mymodule
Expected: L'action Install sur le module … a réussi.
Failure modes / debugging
Read: references/debugging.md
Common failure areas:
references/debugging.md— all symptom/cause/fix tables (install, config page, Grid, InputBag, ImageManager, lotr steps)
PrestaShop 9 Core Documentation
The following files are bundled with this skill in the ps9-core-ai/ directory.
Read them for deep understanding of PS9 core architecture, conventions, and patterns.
ps9-core-ai/CONTEXT.md— Root AI context for the PS9 codebase: project-wide coding standards, architecture layers (Core/Adapter/Bundle/Legacy), CQRS pattern, branching policy, and the full index of domain and component contexts.ps9-core-ai/STRUCTURE.md— Architecture of the.ai/folder itself: how contexts, skills, and pointer files are organized and how AI tools discover them.
Note for skill maintainers: These files are static snapshots from the official PrestaShop repository. To update them, skill maintainers can run
lotr --installfrom a module directory. This is not a runtime operation — the files are pre-bundled and version-controlled with the skill.
Steering
If your project or organisation defines a steering layer (layered context rules for coding standards, architecture conventions, and project-specific overrides), load the steering files before starting any task.
Finding the resolver — search in this order:
steering/resolver.mdat the module root (custom installation)vendor/websenso/prestashop-module-devtools/steering/resolver.md— canonical path when the devtools are installed as a Composer package (same package that providesvendor/websenso/prestashop-module-devtools/bin/lotr)- Any
vendor/*/*/steering/resolver.md— scan all vendor subfolders for asteering/resolver.mdfile (use the first match found)
If none exists, skip steering silently and apply only the skill defaults.
Typical steering structure (paths relative to wherever the resolver is found):
steering/resolver.md ← load order and conflict rules
steering/company/ ← organisation-wide standards
steering/languages/php/coding-standards.md ← PHP conventions
steering/frameworks/prestashop/ ← PrestaShop-specific rules
Load steering files from lowest to highest priority (company → language → framework → project). Later layers override earlier ones.
Escalation
- PrestaShop 9 Module Creation
- Module Good Practices
- Official Example Modules
- demosymfonyform — canonical Symfony form config page
- Module Validator
- PrestaShop Developer Slack