PrestaShop Custom Module Development
A module is the primary unit of PrestaShop extension. Through modules, any custom logic is implemented: from custom order fields to external API integrations. A module can register hooks, add controllers, extend database schema, add widgets to back-office through Symfony DI.
Module Structure
modules/mymodule/
├── mymodule.php # Main module class
├── config.xml # Metadata (optional)
├── composer.json # Dependencies (optional)
├── logo.png # Icon 32x32
├── controllers/
│ └── front/
│ └── callback.php # FrontController: /module/mymodule/callback
├── src/ # PSR-4 namespace classes
│ ├── Entity/
│ ├── Repository/
│ └── Service/
├── views/
│ └── templates/
│ ├── admin/
│ │ └── configure.html.twig
│ └── hook/
│ └── display_banner.tpl
├── translations/ # Module translations
└── upgrade/ # SQL migrations on update
└── upgrade-1.1.0.php
Main Class and Lifecycle
<?php
declare(strict_types=1);
use PrestaShop\PrestaShop\Adapter\SymfonyContainer;
if (!defined('_PS_VERSION_')) {
exit;
}
class MyModule extends Module
{
public function __construct()
{
$this->name = 'mymodule';
$this->tab = 'other';
$this->version = '1.2.0';
$this->author = 'Company Name';
$this->need_instance = 0;
$this->bootstrap = true;
$this->ps_versions_compliancy = ['min' => '8.0.0', 'max' => _PS_VERSION_];
parent::__construct();
$this->displayName = $this->trans('My Module', [], 'Modules.Mymodule.Admin');
$this->description = $this->trans('Module description', [], 'Modules.Mymodule.Admin');
}
public function install(): bool
{
return parent::install()
&& $this->installDb()
&& $this->registerHook('actionOrderStatusPostUpdate')
&& $this->registerHook('displayCustomerAccount')
&& $this->registerHook('displayHeader');
}
public function uninstall(): bool
{
return parent::uninstall() && $this->uninstallDb();
}
private function installDb(): bool
{
$sql = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'mymodule_data` (
`id_mymodule` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`id_order` INT UNSIGNED NOT NULL,
`payload` TEXT,
`status` TINYINT(1) NOT NULL DEFAULT 0,
`date_add` DATETIME NOT NULL,
PRIMARY KEY (`id_mymodule`),
KEY `id_order` (`id_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;';
return Db::getInstance()->execute($sql);
}
}
Working with Hooks
Hooks come in two types: action (modify behavior, return data) and display (return HTML). Important to distinguish:
// Display hook — returns HTML string
public function hookDisplayCustomerAccount(array $params): string
{
$customerId = (int) $this->context->customer->id;
$data = $this->getCustomerData($customerId);
if (empty($data)) {
return '';
}
$this->smarty->assign([
'mymodule_data' => $data,
'moduleUrl' => $this->context->link->getModuleLink('mymodule', 'account'),
]);
return $this->display(__FILE__, 'views/templates/hook/customer_account.tpl');
}
// Action hook — modifies data or performs side-effect
public function hookActionOrderStatusPostUpdate(array $params): void
{
/** @var Order $order */
$order = $params['order'];
$newStatus = $params['newOrderStatus'];
if ((int) $newStatus->id === (int) Configuration::get('PS_OS_PAYMENT')) {
$this->notifyExternalSystem($order);
}
}
Symfony DI and Modern Back-office
PrestaShop 8.x allows Symfony DI in modules via config/services.yml:
# modules/mymodule/config/services.yml
services:
_defaults:
autowire: true
autoconfigure: true
public: true
MyModule\Repository\OrderDataRepository:
arguments:
$connection: '@doctrine.dbal.default_connection'
MyModule\Service\ExternalApiService:
arguments:
$apiKey: '%env(MYMODULE_API_KEY)%'
$logger: '@logger'
Usage in back-office controller (Symfony-style):
// src/Controller/Admin/OrderDataController.php
namespace MyModule\Controller\Admin;
use PrestaShopBundle\Controller\Admin\FrameworkBundleAdminController;
use Symfony\Component\HttpFoundation\JsonResponse;
use MyModule\Repository\OrderDataRepository;
class OrderDataController extends FrameworkBundleAdminController
{
public function __construct(
private readonly OrderDataRepository $repository
) {}
public function indexAction(int $orderId): JsonResponse
{
$data = $this->repository->findByOrderId($orderId);
return $this->json([
'success' => true,
'data' => $data,
]);
}
}
Database Schema Migrations
Use upgrade scripts on module update:
// modules/mymodule/upgrade/upgrade-1.2.0.php
function upgrade_module_1_2_0(Module $module): bool
{
$result = Db::getInstance()->execute(
'ALTER TABLE `' . _DB_PREFIX_ . 'mymodule_data`
ADD COLUMN `external_id` VARCHAR(128) NULL AFTER `id_order`,
ADD INDEX `external_id` (`external_id`)'
);
if ($result) {
// Data migration
Db::getInstance()->execute(
'UPDATE `' . _DB_PREFIX_ . 'mymodule_data` SET `status` = 1 WHERE `status` = 0 AND `date_add` < NOW()'
);
}
return (bool) $result;
}
Module Localization
PrestaShop uses Symfony Translation for back-office and native mechanism for front-office:
// Back-office (Symfony Translation)
$this->trans('Order processed', [], 'Modules.Mymodule.Admin');
// Front-office (Smarty)
// In .tpl file:
// {l s='Order processed' mod='mymodule'}
// Generate translation files
php bin/console prestashop:generate:translations --module=mymodule
Module Testing
// tests/Unit/Service/ExternalApiServiceTest.php
use PHPUnit\Framework\TestCase;
use MyModule\Service\ExternalApiService;
class ExternalApiServiceTest extends TestCase
{
public function testNotifyOrder(): void
{
$httpClient = $this->createMock(HttpClientInterface::class);
$httpClient->expects($this->once())
->method('request')
->with('POST', 'https://api.example.com/orders')
->willReturn(new MockResponse('{"status":"ok"}', ['http_code' => 200]));
$service = new ExternalApiService($httpClient, 'test-api-key');
$result = $service->notifyOrderPaid(12345);
$this->assertTrue($result);
}
}
Development Timeline
- Simple module: hooks, configuration, data output: 2–4 days
- Integration with external API (payment system, CRM): 5–10 days
- Module with custom entities, CRUD in back-office, migrations: 7–14 days
- Complex module (custom checkout, loyalty program): 3–6 weeks







