PrestaShop 9: What Changed Under the Hood and How to Prepare Your Modules
PrestaShop 9 Is Here — And It Breaks More Than You Think
PrestaShop 9.0 shipped in late 2025 with a Symfony 6.4 LTS foundation — jumping from Symfony 4.4, skipping two major framework versions in one release. I've migrated 14 production modules to PS9 compatibility so far. Some went smoothly. Others required rewriting entire admin controllers.

This isn't a changelog summary. This is a working developer's guide to what actually breaks, with before-and-after code for every major change. If you maintain PrestaShop modules — whether for the Addons marketplace, for clients, or for your own stores — this is the migration reference I wish had existed when I started.
The Symfony 6.4 Jump: Why It Matters
Symfony 4.4 reached end-of-life in November 2023. PrestaShop 8.x was already running on an unsupported framework version. The jump to 6.4 LTS (supported until November 2027 for security fixes) was necessary but painful.
The key implications for module developers:
- PHP 8.1 minimum — PHP 7.x code won't run. PHP 8.2 or 8.3 recommended for the best balance of features and stability.
- Controllers as proper services — The "global container" pattern is dead. Dependency injection is now mandatory.
- Annotations replaced by PHP 8 attributes —
@Route,@AdminSecurity,@DemoRestrictedall convert to attribute syntax. - Several major libraries removed — Guzzle, SwiftMailer, and Tactician are gone from core.
- Node.js 20 minimum — For modules with build steps.
Breaking Change #1: Controllers — From Global Container to Dependency Injection
This is the change that trips up the most developers. In PS8, you could call $this->get('service_name') in any admin controller to fetch any service from Symfony's global container. In PS9, the container passed to controllers is a scoped container that only includes services you've explicitly declared.
Before (PrestaShop 8.x)
<?php
// modules/mymodule/src/Controller/Admin/MyController.php
use PrestaShopBundle\Controller\Admin\FrameworkBundleAdminController;
class MyController extends FrameworkBundleAdminController
{
public function indexAction()
{
// Grab any service from the global container
$productRepository = $this->get('prestashop.core.product.repository');
$translator = $this->get('translator');
$configService = $this->get('my_module.config_service');
return $this->render('@Modules/mymodule/views/templates/admin/index.html.twig', [
'products' => $productRepository->findAll(),
]);
}
}
After (PrestaShop 9.x)
<?php
// modules/mymodule/src/Controller/Admin/MyController.php
use PrestaShopBundle\Controller\Admin\PrestaShopAdminController;
use MyModule\Service\ConfigService;
use Symfony\Component\HttpFoundation\Response;
class MyController extends PrestaShopAdminController
{
// Constructor injection for services you always need
public function __construct(
private readonly ConfigService $configService,
) {
}
// Method injection for request-specific services
public function indexAction(ProductRepository $productRepository): Response
{
return $this->render('@Modules/mymodule/views/templates/admin/index.html.twig', [
'products' => $productRepository->findAll(),
'config' => $this->configService->getAll(),
]);
}
// If you MUST use the service locator pattern (migration bridge)
public static function getSubscribedServices(): array
{
return parent::getSubscribedServices() + [
ConfigService::class => ConfigService::class,
];
}
}
Key changes:
FrameworkBundleAdminControlleris deprecated — usePrestaShopAdminController- Constructor injection replaces
$this->get()calls - Method injection works for action-specific dependencies
getSubscribedServices()is a migration bridge — use it temporarily while refactoring, but move to constructor/method injection
Breaking Change #2: Annotations to PHP 8 Attributes
The sensio/framework-extra-bundle dependency has been removed. All routing and security annotations must convert to native PHP 8 attributes.
Routing
// BEFORE (PS8) — Annotation syntax
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
/**
* @Route("/mymodule/settings", name="mymodule_settings")
*/
public function settingsAction()
// AFTER (PS9) — PHP 8 Attribute syntax
use Symfony\Component\Routing\Annotation\Route;
#[Route('/mymodule/settings', name: 'mymodule_settings', methods: ['GET'])]
public function settingsAction(): Response
Security Annotations
// BEFORE (PS8)
use PrestaShopBundle\Security\Annotation\AdminSecurity;
use PrestaShopBundle\Security\Annotation\DemoRestricted;
/**
* @AdminSecurity("is_granted('read', request.get('_legacy_controller'))")
* @DemoRestricted(redirectRoute="mymodule_settings")
*/
public function settingsAction()
// AFTER (PS9)
use PrestaShopBundle\Security\Attribute\AdminSecurity;
use PrestaShopBundle\Security\Attribute\DemoRestricted;
#[AdminSecurity("is_granted('read', request.get('_legacy_controller'))")]
#[DemoRestricted(redirectRoute: 'mymodule_settings')]
public function settingsAction(): Response
Notice the namespace change: Security\Annotation\ becomes Security\Attribute\. This is an easy find-and-replace, but if you miss it, your routes will have no security restrictions — which is worse than a crash.
Event Listeners
// BEFORE (PS8) — YAML service definition + interface
# config/services.yml
services:
mymodule.event_listener:
class: MyModule\EventListener\ProductListener
tags:
- { name: kernel.event_listener, event: kernel.request }
// AFTER (PS9) — PHP attribute, no YAML needed
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: 'kernel.request')]
class ProductListener
{
public function __invoke(RequestEvent $event): void
{
// ...
}
}
Breaking Change #3: Service Definitions — YAML to PHP Configuration
While YAML service definitions still work in PS9, the recommended approach is PHP-based configuration with autowiring. More importantly, your services must be properly configured for Symfony 6.4's stricter dependency injection.
Before (PS8 YAML)
# modules/mymodule/config/services.yml
services:
mymodule.config_service:
class: MyModule\Service\ConfigService
arguments:
- '@prestashop.adapter.legacy.configuration'
public: true
mymodule.admin_controller:
class: MyModule\Controller\Admin\MyController
arguments:
- '@mymodule.config_service'
tags:
- { name: controller.service_arguments }
After (PS9 PHP config with autowiring)
<?php
// modules/mymodule/config/services.php
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $container): void {
$services = $container->services();
$services->defaults()
->autowire()
->autoconfigure()
->public(false);
// Register all classes in src/ as services
$services->load('MyModule\\', '../src/')
->exclude('../src/{Entity}');
// Explicit service definition only when needed
$services->set(MyModule\Service\ConfigService::class)
->arg('$shopId', '%prestashop.shop_id%');
};
With autowiring, Symfony resolves constructor dependencies automatically by type-hint. You only need explicit definitions for:
- Scalar parameters (
$shopId,$apiKey, etc.) - Interface bindings (when multiple implementations exist)
- Third-party classes that can't be autowired
Breaking Change #4: Removed Libraries
PrestaShop 9 removed several bundled libraries. If your module used them, you must either bundle them yourself or migrate to the replacement.
| Removed Library | Replacement | Migration Effort |
|---|---|---|
guzzlehttp/guzzle | symfony/http-client | Medium — API is different |
swiftmailer/swiftmailer | symfony/mailer | Medium — new Email object API |
league/tactician-bundle | symfony/messenger | High — different command bus pattern |
pear/archive_tar | Bundle in module or use ext-zip | Low |
Guzzle to Symfony HttpClient
// BEFORE — Guzzle
use GuzzleHttp\Client;
$client = new Client(['base_uri' => 'https://api.example.com']);
$response = $client->request('GET', '/products', [
'query' => ['status' => 'active'],
'headers' => ['Authorization' => 'Bearer ' . $token],
]);
$data = json_decode($response->getBody()->getContents(), true);
// AFTER — Symfony HttpClient
use Symfony\Contracts\HttpClient\HttpClientInterface;
// Inject via constructor
public function __construct(
private readonly HttpClientInterface $httpClient,
) {}
public function getProducts(string $token): array
{
$response = $this->httpClient->request('GET', 'https://api.example.com/products', [
'query' => ['status' => 'active'],
'auth_bearer' => $token,
]);
return $response->toArray(); // Auto-decodes JSON
}
SwiftMailer to Symfony Mailer
// BEFORE — SwiftMailer
$message = (new \Swift_Message('Subject'))
->setFrom('shop@example.com')
->setTo('customer@example.com')
->setBody($htmlContent, 'text/html');
$this->get('mailer')->send($message);
// AFTER — Symfony Mailer
use Symfony\Component\Mime\Email;
use Symfony\Component\Mailer\MailerInterface;
public function __construct(
private readonly MailerInterface $mailer,
) {}
public function sendNotification(): void
{
$email = (new Email())
->from('shop@example.com')
->to('customer@example.com')
->subject('Subject')
->html($htmlContent);
$this->mailer->send($email);
}
Important: SSL encryption for email is removed in PS9. Only TLS or no encryption remain. Update your email configuration if you were using SSL.
Breaking Change #5: Context Refactorization
The legacy Context singleton is being replaced by typed context services. While Context::getContext() still works in PS9, it's deprecated and the new approach is more reliable — especially in CLI commands and async contexts where the legacy Context wasn't properly initialized.
// BEFORE — Legacy Context singleton
$employee = Context::getContext()->employee;
$shop = Context::getContext()->shop;
$language = Context::getContext()->language;
$currency = Context::getContext()->currency;
// AFTER — Typed Context services (inject via constructor)
use PrestaShop\PrestaShop\Core\Context\EmployeeContext;
use PrestaShop\PrestaShop\Core\Context\ShopContext;
use PrestaShop\PrestaShop\Core\Context\LanguageContext;
use PrestaShop\PrestaShop\Core\Context\CurrencyContext;
public function __construct(
private readonly EmployeeContext $employeeContext,
private readonly ShopContext $shopContext,
private readonly LanguageContext $languageContext,
private readonly CurrencyContext $currencyContext,
) {}
public function someMethod(): void
{
$employee = $this->employeeContext->getEmployee();
$shopId = $this->shopContext->getId();
$langId = $this->languageContext->getId();
$currency = $this->currencyContext->getCurrency();
}
Available context services in PS9: EmployeeContext, ShopContext, LanguageContext, CurrencyContext, CountryContext, ApiClientContext, and LegacyControllerContext.
Breaking Change #6: Authentication and Session Management
The back office login is now fully Symfony-based. If your module reads employee data from Context::$cookie, that's unreliable in PS9.
// BEFORE — Cookie-based auth (unreliable in PS9)
$employeeId = Context::getContext()->cookie->id_employee;
$profile = Context::getContext()->cookie->profile;
// AFTER — Session-based approach
use Symfony\Component\HttpFoundation\RequestStack;
use PrestaShop\PrestaShop\Core\Context\EmployeeContext;
public function __construct(
private readonly RequestStack $requestStack,
private readonly EmployeeContext $employeeContext,
) {}
public function getEmployeeData(): array
{
// For employee identity
$employee = $this->employeeContext->getEmployee();
// For custom session data your module stores
$session = $this->requestStack->getCurrentRequest()?->getSession();
$myData = $session?->get('my_module_custom_data');
return [
'employee_id' => $employee->getId(),
'profile_id' => $employee->getProfileId(),
'custom_data' => $myData,
];
}
Breaking Change #7: Removed Hooks
Several hooks related to the admin login flow have been removed with no direct replacement:

actionAdminLoginControllerBeforeactionAdminLoginControllerLoginBefore/actionAdminLoginControllerLoginAfteractionAdminLoginControllerForgotBefore/actionAdminLoginControllerForgotAfteractionAdminLoginControllerResetBefore/actionAdminLoginControllerResetAfter
Replacements for form customization:
actionBackOfficeLoginForm— Modify the login form builderactionEmployeeRequestPasswordResetForm— Modify the password reset form
All legacy product page hooks (actionAdminProductsController*) are also removed since the legacy product page no longer exists.
Breaking Change #8: Front Office Template Data Changes
Several front-end pages now use Presenter classes, changing the data structure available in Smarty templates. If your module overrides or extends these templates, you'll need to update variable references.
{* BEFORE (PS8) — Manufacturer page *}
<img src="{$manufacturer.image}" alt="{$manufacturer.name}">
{$manufacturer.nb_products} products
{* AFTER (PS9) — Presenter-based data structure *}
<img src="{$manufacturer.image.medium.url}" alt="{$manufacturer.name}">
{l s='%number% product' sprintf=['%number%' => $manufacturer.nb_products] d='Shop.Theme.Catalog'}
Affected pages: Category, Manufacturer, Supplier, and Store. Each has a corresponding actionPresent* hook that lets modules modify the presented data.
Breaking Change #9: Module Security — No Root PHP Files
Modules can no longer contain directly executable PHP files at the root level. This affects modules that use AJAX endpoints as standalone PHP files.
// BEFORE — Direct PHP file (modules/mymodule/ajax.php)
require_once dirname(__FILE__) . '/../../config/config.inc.php';
// Process AJAX request...
// AFTER — ModuleFrontController
// modules/mymodule/controllers/front/ajax.php
class MyModuleAjaxModuleFrontController extends ModuleFrontController
{
public function displayAjaxProcess()
{
$result = $this->module->processAjaxRequest();
$this->ajaxRender(json_encode($result));
}
}
// URL: index.php?fc=module&module=mymodule&controller=ajax&action=process
Breaking Change #10: Console Commands and Multi-Kernel
PS9 introduces separate application kernels for admin, admin-api, and front. Console commands now accept an --app-id parameter to target the correct kernel:
# Clear admin cache only
php bin/console cache:clear --env=prod --app-id=admin
# Debug front-office event listeners
php bin/console debug:event-dispatcher kernel.request --app-id=front
# List admin-api routes
php bin/console debug:router --app-id=admin-api
This matters for module console commands — ensure your command is registered in the correct kernel context.
What Stays the Same (The Good News)
Not everything breaks. These patterns continue working in PS9:
- The hook system —
hookDisplayHeader(),hookActionProductUpdate(), etc. still work exactly as before. - Smarty templates — Front office Smarty templates remain supported. The Twig transition is admin-only.
- Module descriptor —
$this->name,$this->version,$this->ps_versions_compliancywork the same way. Db::getInstance()— Direct database queries for module-specific tables remain fully functional.- ObjectModel for module tables — Your custom ObjectModel subclasses for module-specific tables still work. The deprecation is only for core entity ObjectModels.
- Configuration API —
Configuration::get()andConfiguration::updateValue()are unchanged.
Migration Checklist
Here's the checklist I use for every module migration. Print it, tape it to your monitor:
- PHP version — Test on PHP 8.1+. Remove any PHP 7.x compatibility hacks.
- Annotations to attributes — Find-replace
Security\Annotation\toSecurity\Attribute\. Convert@Routeto#[Route]. - Controller base class —
FrameworkBundleAdminControllertoPrestaShopAdminController. - Replace
$this->get()— Move to constructor/method injection. - Check removed libraries — Guzzle, SwiftMailer, Tactician. Bundle or replace.
- Service definitions — Verify autowiring works. Test with
bin/console debug:container MyService --app-id=admin. - Template variables — Test front-office template overrides for changed data structures.
- Removed hooks — Check if your module uses any removed login/product hooks.
- Root PHP files — Move any direct AJAX endpoints to ModuleFrontController.
- Update compliancy — Set
ps_versions_compliancyto include 9.x:
$this->ps_versions_compliancy = [
'min' => '8.0.0',
'max' => '9.99.99',
];
Dual Compatibility: Supporting PS8 and PS9
Most module developers need to support both versions during the transition. Here's the pattern I use:
// Check PrestaShop version at runtime
if (version_compare(_PS_VERSION_, '9.0.0', '>=')) {
// PS9 path — use new Context services
} else {
// PS8 path — use legacy Context singleton
}
// For controller base class, use a compatibility layer:
if (class_exists('PrestaShopBundle\Controller\Admin\PrestaShopAdminController')) {
class MyModuleAdminControllerBase extends PrestaShopAdminController {}
} else {
class MyModuleAdminControllerBase extends FrameworkBundleAdminController {}
}
class MyActualController extends MyModuleAdminControllerBase
{
// Your controller code
}
For service definitions, you can maintain both services.yml (PS8) and services.php (PS9) — PrestaShop loads the PHP config if present, falling back to YAML.
The Bottom Line
PrestaShop 9 is a better platform with a modern framework foundation. The migration cost is real — I'd estimate 2-8 hours per module depending on complexity — but the result is more maintainable code with proper dependency injection, type safety, and access to Symfony 6.4's full ecosystem.
Start with the official PS9 changes documentation for the complete deprecation list, and test thoroughly on a staging environment before deploying to production.
All of our modules at mypresta.rocks have been updated for PS9 compatibility. If you're migrating your own modules and hit something not covered here, drop us a line — chances are we've already solved it.
David Miller has been building PrestaShop modules since the PS 1.6 era. He maintains over 30 production modules at mypresta.rocks.
Related Articles
- What PrestaShop 9 Changed and Why Module Compatibility Matters
- PrestaShop 1.7 vs 8 vs 9: Which Version Should You Be Running?
- Building Modules for Six PrestaShop Versions: The Challenge of Compatibility
- Two-Factor Auth, Password Policies and Admin Security for PrestaShop
Comments (8)
Leave a comment