What actually breaks when you take a module to PrestaShop 9

We've been porting our catalogue of 151 modules to PrestaShop 9 since the 9.0 release in June 2025, and the honest answer is: more than the changelog suggests. Some modules took an afternoon. A few took most of a week, because the admin controllers had been written against the global service container and that pattern is gone.

PHP developer coding PrestaShop module upgrades for version 9 compatibility

This isn't a marketing post. It's the document we built internally while migrating, with every breaking change we actually hit, the before-and-after code, and the parts where we're still not 100% confident the migration is complete. If you maintain modules (for clients, for Addons, or for your own shops) read it before you tell anyone your module is "PS 9 compatible." We've learned the hard way that "it installs without a fatal error" is not the same thing.

Sort your module by risk before you touch any code

The biggest mistake we made on our first three migrations was rewriting the easy parts first and then discovering the hard parts the day before release. Don't do that. Look at what the module is and where the change surface is largest.

Module typeWhere it usually breaks in PS 9What to test first
Modern Symfony admin controllerScoped container, service wiring, route attributes, security attributesOpen every controller in debug mode and confirm constructor-injected services actually resolve
Legacy AdminController (Bootstrap / jQuery)Token URLs, AJAX endpoints, bulk actions, list helpersRun list filters, row actions, modals, AJAX endpoints, export buttons, bulk actions — all of them
Front-office / theme moduleClassic-only template assumptions, Hummingbird markup differencesProduct card, cart, checkout, customer account, and every front hook on Classic and Hummingbird
Checkout, payment or shippingCart, carrier, discount and order-flow assumptions changed by 9.1 systemsPlace real test orders: guest, account, with discounts, multiple carriers
Integration / ERP / exportLegacy Webservice vs new AdminAPI; PHP 8.5 strict warningsSmoke-test on 8.2, 9.0 and 9.1 before changing the integration contract
Email / notificationSwiftMailer is gone; Symfony Mailer has different template renderingSend every transactional email type and inspect headers, variables, attachments

Our checkout-flow modules took the longest. Anything that touched the admin order page or carrier selection had to be reviewed line by line. The "small utility module that just adds a banner" took half a morning.

May 2026 update: 9.1 is the real target now, not 9.0

When we started this migration the answer was "compatible with 9.0." A year later, 9.1 is what merchants are installing, and 9.0 is a stepping stone people upgrade away from. If your CI matrix still pins to 9.0, you're testing yesterday's PrestaShop.

9.1 is supposed to be backward compatible with 9.0 and mostly is. But it adds PHP 8.5 support, ships Hummingbird as the default theme for fresh installs, introduces new lifecycle and configuration hooks, adds CLI commands for thumbnails / search / translations, and brings feature-flagged multi-shipment and discount systems. Any of those can quietly break a module that worked fine on 9.0.

The 9.1.3 maintenance release in May 2026 pulled in Symfony and Twig dependency updates without core code changes — the kind of patch release that "shouldn't affect modules" and then does, the first time someone passes a non-string into a Twig filter. Test on the maintained 9.x branch, not only on the original 9.0 tag.

What changed between 9.0 and 9.1 — what we retest

AreaWhat changedWhat we retest
Runtime9.0 requires PHP 8.1+ and supports up to 8.4; 9.1 adds and recommends 8.5Run the module on the lowest and highest PHP version your composer.json claims
Symfony stackSymfony 4.4 → 6.4 LTS — Symfony 5 and 6 breaking changes applyController inheritance, annotations, events, service definitions, removed framework packages
Hooks and CLI9.1 adds lifecycle hooks, a config-update hook, and CLI commands for thumbnails / search / translation exportInstall, enable, disable, upgrade, configuration save, thumbnail regen, search reindex, translation export
HummingbirdDefault theme on fresh 9.1 installs; upgraded shops keep their themeFront-office output on both Classic and Hummingbird — every product, cart, checkout, account template
Discounts9.1 introduces a redesigned discount UI and architecture on top of cart rulesAnything that reads, creates, combines, validates or displays cart rules, vouchers and promotions
Database / orders9.1 has schema changes for multi-shipment and the new discount systemOrder export, invoice, fulfillment, carrier and ERP code against real order fixtures
Security patches9.1.3 updated Symfony and Twig dependenciesFast regression pass after every patch — especially if you touch YAML, XML, Twig or email recipients

The Symfony 6.4 jump — why so much had to change

Symfony 4.4 went end-of-life in November 2023. PrestaShop 8.x was already shipping on an unsupported framework version, which was uncomfortable for anyone reading their PHP error logs. Jumping straight to 6.4 LTS (supported until November 2027) was the right call, but it skipped two major framework versions in one release — and every breaking change in Symfony 5 and 6 lands at once.

The practical implications:

  • PHP 8.1 minimum — any PHP 7.x compatibility shims in your module need to come out. 8.2 to 8.4 are supported on 9.0; 9.1 adds 8.5.
  • Controllers are real services — the "global container, fetch whatever you need" pattern is dead. Constructor injection is mandatory and the container passed to your controller is scoped.
  • Attributes, not annotations@Route, @AdminSecurity, @DemoRestricted all convert to PHP 8 attribute syntax. sensio/framework-extra-bundle is gone.
  • Bundled libraries removed — Guzzle, SwiftMailer, Tactician. If you used them, you either bundle them yourself or migrate.
  • Node 20 minimum for modules with a build step. 9.1 builds against Node 20.19.5, so pin your tooling instead of crossing your fingers with whatever Node is on the dev machine.

Breaking change #1: controllers — global container is gone

This is the single change that ate the most of our time. In PS 8 we could grab any service from any controller with $this->get('service.name'). In PS 9 the container handed to your controller is scoped — it only contains services you explicitly declared, and the bare $this->get() call either throws or returns null depending on the service.

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,
        ];
    }
}

The headline changes:

  • FrameworkBundleAdminController is deprecated. Use PrestaShopAdminController.
  • Constructor injection replaces $this->get() for anything the controller always needs.
  • Method injection handles action-specific dependencies.
  • getSubscribedServices() is the escape hatch for a half-migrated controller. Use it to keep the lights on while you refactor — don't ship it as the final state.

One thing that bit us: if your service decorates a core service, autowiring on the decorated version stops working the moment a third module in the chain doesn't autowire correctly. We hit this on a module that decorates the cart calculator and spent half a day staring at a perfectly correct services.yml before realising another installed module was the problem. Service decoration in PS 9 is unforgiving about order.

Breaking change #2: annotations become PHP 8 attributes

The sensio/framework-extra-bundle dependency is gone. Routing and security annotations all need to convert to native attributes. Mostly mechanical, but there is one trap.

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

// 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

The namespace shifts from Security\Annotation\ to Security\Attribute\. Find-and-replace handles most of it. If you miss it, your route runs without security restrictions — which is worse than a crash, because the module appears to work and quietly exposes an admin action without authorisation. Grep your codebase for Security\Annotation after the migration to confirm none survived.

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 still works, PHP is cleaner

YAML services keep working in PS 9 and we kept ours on the modules where the YAML was already correct. New work goes to PHP config with autowiring, because the constraints Symfony 6.4 puts on the service container are easier to satisfy when the config is in the same language as the services.

Before (PS 8 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 (PS 9 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%');
};

Once autowiring is on, you only write explicit definitions for scalar parameters, interface bindings where multiple implementations exist, and third-party classes that can't be autowired. If you've been hand-writing service definitions for ten years out of habit, this part feels suspicious. It works. Trust it.

Breaking change #4: libraries that aren't in the box any more

PrestaShop 9 stopped bundling four libraries we'd seen in dozens of older modules. If your composer.json requires them as a top-level dependency you're fine — they install for your module. If you assumed they were always available because PS 8 shipped them, you have homework.

RemovedReplacementMigration effort
guzzlehttp/guzzlesymfony/http-clientMedium — different API shape
swiftmailer/swiftmailersymfony/mailerMedium — new Email object API
league/tactician-bundlesymfony/messengerHigh — different command bus model
pear/archive_tarBundle in your 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);
}

One thing the documentation doesn't shout loudly enough: SSL encryption for email is gone in PS 9. Only TLS or no encryption remain. If a client's shop was configured with SSL on their SMTP relay, outgoing email silently stops working after the upgrade. We've watched a 9.0 upgrade go through cleanly and then customers stopped receiving order confirmations for two days before anyone noticed. Check the mail configuration on every shop you upgrade.

Breaking change #5: typed Context services replace the singleton

The legacy Context::getContext() singleton still works in PS 9 — it's deprecated, not deleted. The new pattern is typed Context services injected by the container, and the new pattern works in places where the singleton never worked properly (CLI commands, async workers, anywhere the legacy Context wasn't initialised by the front controller).

// 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();
}

The full set of context services in PS 9: EmployeeContext, ShopContext, LanguageContext, CurrencyContext, CountryContext, ApiClientContext, LegacyControllerContext. The legacy singleton stays for now and your existing code keeps running. We're migrating module by module, not in one big sweep, and that's been fine.

Breaking change #6: admin auth is now fully Symfony

The back office login flow moved to Symfony Security. If your module reads employee data from Context::$cookie (and we've seen plenty of older modules that do) that path is unreliable in PS 9.

// 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 admin-login-flow hooks are gone with no direct replacement.

Code editor showing Symfony framework used in PrestaShop 9 architecture

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

For login form customisation, the new hooks are:

  • actionBackOfficeLoginForm — modify the login form builder
  • actionEmployeeRequestPasswordResetForm — modify the password reset form

All actionAdminProductsController* hooks are removed too, because the legacy product page no longer exists. If your module attached extra fields to the old product page through one of those hooks, you need to rewrite that part against the Symfony product page — which is a much larger job than swapping a hook name.

Breaking change #8: front-office templates now use Presenter data

Several front pages (category, manufacturer, supplier, store) moved to Presenter classes. The Smarty variables they expose have a different shape. If your module overrides or extends one of these templates, the variable references need to change.

{* 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'}

Each affected page has a corresponding actionPresent* hook so you can modify the presented data instead of overriding the template. Use that hook where you can — it survives theme changes and Hummingbird template rewrites, and we've spent enough time fixing template overrides after Hummingbird template revisions to know we'd rather not own them.

Breaking change #9: no more standalone root-level PHP endpoints

You still need the main module PHP file. The pattern that's now a problem is a directly-callable ajax.php or callback.php sitting at the module root and including config/config.inc.php by hand. Those endpoints bypass routing, tokens, SSL detection, customer context and the rest of the normal request lifecycle, and PS 9's security tooling treats them as a smell.

// 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 the multi-kernel split

PS 9 splits the application into three Symfony kernels: admin, admin-api, and front. Console commands accept --app-id to target a specific 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

If your module ships a console command, make sure it's registered in the right kernel. We had a CLI command for reindexing search that was registered without specifying a kernel — it ran fine on PS 8, refused to be discovered at all on PS 9 until we added the proper tag.

What didn't change (the parts we didn't have to rewrite)

Not everything broke. These patterns survive into PS 9 untouched.

  • The hook systemhookDisplayHeader(), hookActionProductUpdate(), all the rest work exactly as before. See our hooks reference for the patterns that survived the version jump.
  • Smarty front templates — the Twig migration is admin-only. Front-office Smarty stays.
  • Module descriptor$this->name, $this->version, $this->ps_versions_compliancy are unchanged.
  • Db::getInstance() — direct DB queries for your own module tables keep working.
  • ObjectModel for module tables — your custom ObjectModel subclasses for module-specific tables still work. The deprecation hits core entity ObjectModels, not yours.
  • Configuration APIConfiguration::get() / Configuration::updateValue() are unchanged.

The test matrix we actually run

One green install on PS 9 is not the same as compatibility. We learned this on a module that passed our smoke test, shipped to two client shops, and then crashed when the merchant tried to use the bulk-action button. The list below is the matrix we run on every commercial module before stamping it "9.x compatible."

TargetWhy it mattersWhat "pass" looks like
PrestaShop 8.2 on PHP 8.1 / 8.2A lot of production shops will stay on PS 8 while testing 9Existing stable build still installs, upgrades and runs without deprecation noise that blocks normal work
PrestaShop 9.0 on PHP 8.1–8.4This proves the major-version migration: Symfony 6.4, PHP 8.1 minimum, removed librariesEvery controller, hook, cron job, AJAX endpoint and email path runs on a clean 9.0
PrestaShop 9.1 branchThe practical current target — what merchants are actually installingModule survives Hummingbird default, discount/order changes, latest patch releases
Classic and Hummingbird themesFresh 9.1 installs use Hummingbird, upgraded shops keep Classic-derived themesFront-office hooks render without broken layout, missing selectors, duplicate content, or JS errors
Fresh install and upgrade from an older module versionUpgrade SQL and config defaults fail differently from fresh installsBoth paths produce the same final tables, configuration values, tabs, hooks and permissions
Shop with debug mode onWarnings hidden in prod become support tickets laterNo undefined indexes, dynamic property warnings, deprecations, or container exceptions during normal use

The "fresh install and upgrade" row is non-negotiable. Most of the worst bugs we shipped over the years were on the upgrade path — fresh install always worked because we tested fresh install. The shop that broke was an existing customer's, whose database had values our upgrade SQL didn't account for.

Our migration checklist

We keep this in a markdown file in every module repo. Print it, paste it, do what you like with it.

  1. PHP version — test on 8.1+. Remove PHP 7.x compatibility shims.
  2. Annotations to attributes — find-replace Security\Annotation\ with Security\Attribute\. Convert @Route to #[Route]. Grep for survivors.
  3. Controller base classFrameworkBundleAdminController to PrestaShopAdminController.
  4. Replace $this->get() — move to constructor or method injection.
  5. Removed libraries — Guzzle, SwiftMailer, Tactician, pear/archive_tar. Bundle or replace.
  6. Service definitions — confirm autowiring works. bin/console debug:container MyService --app-id=admin.
  7. Template variables — test front-office template overrides against the new Presenter shape.
  8. Removed hooks — grep for the deprecated login and product hooks.
  9. Standalone PHP endpoints — move any direct AJAX or callback files 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',
];

One honest caveat on the last point: we only set the upper bound to 9.99.99 on modules where we've run the full test matrix against the latest 9.1 release. Setting it on a module you've only smoke-tested is the lie that breaks customer shops.

Supporting PS 8 and PS 9 from the same codebase

For commercial modules we can't ship two versions and expect customers to pick the right one. The pattern that's worked for us is a runtime version check with two code paths, and a compatibility base class.

// 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 services, you can keep both services.yml (PS 8) and services.php (PS 9) in the same module. PrestaShop loads the PHP file if it exists and falls back to YAML.

This dual-codebase pattern lives in our prestashop-compat shared package and we extend it as we hit new differences. Every method that's deprecated or removed in a future PS version gets wrapped, and the wrapper detects the version at runtime. Tools::displayPrice() is the canonical example — gone in 9.0, replaced by the locale-aware formatter, wrapped in \MyPrestaRocks\Compat\PriceFormatter::format() so we only fix it in one place.

Composer constraints we actually ship

Resolve dependencies against the lowest PHP version you claim to support, then test against the highest one separately. Otherwise composer happily picks libraries that need 8.4 and your "8.1+" claim is fiction.

{
  "require": {
    "php": ">=8.1 <8.6",
    "symfony/http-client": "^6.4"
  },
  "config": {
    "platform": {
      "php": "8.1.0"
    }
  }
}
matrix:
  include:
    - prestashop: "8.2"
      php: "8.1"
    - prestashop: "9.0"
      php: "8.4"
    - prestashop: "9.1"
      php: "8.5"
      theme: "hummingbird"

What we'd tell a developer starting their first PS 9 migration

PrestaShop 9 is a better platform than 8. Symfony 6.4 is current, the typed context services are nicer to work with than the singleton, autowiring removes a class of bugs we used to have, and the admin-api alongside the legacy Webservice gives integrators a path forward. The cost is real — somewhere between half a day for a small utility module and a week for anything that touches the admin product page or checkout flow.

Two things we'd insist on, having done this fourteen times now. Don't claim 9.x compatibility on hope. Run the full test matrix or mark the module as "9.0 tested, 9.1 in progress" until you've actually run it on 9.1 with Hummingbird. We've watched modules in the category "should be fine, it doesn't touch the admin" turn out to fire a hook that no longer exists. And don't try to do everything at once. Pick one module, do the migration end-to-end, write down what you learned, and the second module will take half the time. The fifth one is routine.

Start with the official 9.0 changes documentation and the 9.1 developer notes for the canonical module-impact list. Test on staging before you go anywhere near a customer shop. And if you're using one of our modules and hit something not covered here, drop us a line — we've probably already solved it on one of the other 147.

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 measurable results.

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.
Loading...
Back to top