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.

PHP developer coding PrestaShop module upgrades for version 9 compatibility

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, @DemoRestricted all 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:

  • FrameworkBundleAdminController is deprecated — use PrestaShopAdminController
  • 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 LibraryReplacementMigration Effort
guzzlehttp/guzzlesymfony/http-clientMedium — API is different
swiftmailer/swiftmailersymfony/mailerMedium — new Email object API
league/tactician-bundlesymfony/messengerHigh — different command bus pattern
pear/archive_tarBundle in module or use ext-zipLow

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:

Code editor showing Symfony framework used in PrestaShop 9 architecture

  • actionAdminLoginControllerBefore
  • actionAdminLoginControllerLoginBefore / actionAdminLoginControllerLoginAfter
  • actionAdminLoginControllerForgotBefore / actionAdminLoginControllerForgotAfter
  • actionAdminLoginControllerResetBefore / actionAdminLoginControllerResetAfter

Replacements for form customization:

  • actionBackOfficeLoginForm — Modify the login form builder
  • actionEmployeeRequestPasswordResetForm — 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 systemhookDisplayHeader(), 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_compliancy work 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 APIConfiguration::get() and Configuration::updateValue() are unchanged.

Migration Checklist

Here's the checklist I use for every module migration. Print it, tape it to your monitor:

  1. PHP version — Test on PHP 8.1+. Remove any PHP 7.x compatibility hacks.
  2. Annotations to attributes — Find-replace Security\Annotation\ to Security\Attribute\. Convert @Route to #[Route].
  3. Controller base classFrameworkBundleAdminController to PrestaShopAdminController.
  4. Replace $this->get() — Move to constructor/method injection.
  5. Check removed libraries — Guzzle, SwiftMailer, Tactician. Bundle or replace.
  6. Service definitions — Verify autowiring works. Test with bin/console debug:container MyService --app-id=admin.
  7. Template variables — Test front-office template overrides for changed data structures.
  8. Removed hooks — Check if your module uses any removed login/product hooks.
  9. Root PHP files — Move any direct AJAX endpoints to ModuleFrontController.
  10. Update compliancy — Set ps_versions_compliancy to 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.

  • 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
Share this post:
David Miller

David Miller

Over a decade of hands-on PrestaShop expertise. David builds high-performance e-commerce modules focused on SEO, checkout optimization, and store management. Passionate about clean code and...

Enjoyed this article?

Get our latest tips, guides and module updates delivered to your inbox.

Comments (8)

T
Thomas Mueller 02/14/2026
We just finished migrating our checkout module to PS9. The process was smoother than expected - most hooks work identically. The main pain point was updating our admin controllers from annotations to attributes.
Reply
E
Elena Rodriguez 02/14/2026
Does anyone know if the hook system changes affect actionProductUpdate? We rely heavily on that hook for our inventory sync module.
Reply
D
David Miller 02/14/2026
Elena, actionProductUpdate works the same way in PS9. The hook system is fully backwards-compatible. Your inventory sync module should be fine without changes.
J
Jakub Kowalski 02/14/2026
The migration checklist is super helpful. One thing I would add: test your cron jobs and CLI commands too. We found that some of our module console commands broke because of the Symfony 6.4 DI container changes.
Reply
S
Sophie Laurent 02/14/2026
Great point about CLI commands Jakub! We had a similar issue with our import cron. The fix was updating the service injection in our console command class.
S
Sophie Laurent 02/14/2026
Finally someone explains the API Platform integration clearly. We have been struggling with the old webservice for years. The OAuth2 scoped tokens alone make the upgrade worth it.
Reply
M
Marcus Weber 02/14/2026
Great overview of the PS9 changes! The Doctrine ORM migration is definitely the biggest challenge for us. We have about 15 modules that use ObjectModel extensively. Any tips on a gradual migration path?
Reply
D
David Miller 02/14/2026
Thanks Marcus! For a gradual migration, I recommend starting with read-only Doctrine queries alongside your existing ObjectModel code. That way you can test without risk. Replace ObjectModel writes only after thorough testing.

Leave a comment

Loading...
Back to top