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.

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 type | Where it usually breaks in PS 9 | What to test first |
|---|---|---|
| Modern Symfony admin controller | Scoped container, service wiring, route attributes, security attributes | Open every controller in debug mode and confirm constructor-injected services actually resolve |
| Legacy AdminController (Bootstrap / jQuery) | Token URLs, AJAX endpoints, bulk actions, list helpers | Run list filters, row actions, modals, AJAX endpoints, export buttons, bulk actions — all of them |
| Front-office / theme module | Classic-only template assumptions, Hummingbird markup differences | Product card, cart, checkout, customer account, and every front hook on Classic and Hummingbird |
| Checkout, payment or shipping | Cart, carrier, discount and order-flow assumptions changed by 9.1 systems | Place real test orders: guest, account, with discounts, multiple carriers |
| Integration / ERP / export | Legacy Webservice vs new AdminAPI; PHP 8.5 strict warnings | Smoke-test on 8.2, 9.0 and 9.1 before changing the integration contract |
| Email / notification | SwiftMailer is gone; Symfony Mailer has different template rendering | Send 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
| Area | What changed | What we retest |
|---|---|---|
| Runtime | 9.0 requires PHP 8.1+ and supports up to 8.4; 9.1 adds and recommends 8.5 | Run the module on the lowest and highest PHP version your composer.json claims |
| Symfony stack | Symfony 4.4 → 6.4 LTS — Symfony 5 and 6 breaking changes apply | Controller inheritance, annotations, events, service definitions, removed framework packages |
| Hooks and CLI | 9.1 adds lifecycle hooks, a config-update hook, and CLI commands for thumbnails / search / translation export | Install, enable, disable, upgrade, configuration save, thumbnail regen, search reindex, translation export |
| Hummingbird | Default theme on fresh 9.1 installs; upgraded shops keep their theme | Front-office output on both Classic and Hummingbird — every product, cart, checkout, account template |
| Discounts | 9.1 introduces a redesigned discount UI and architecture on top of cart rules | Anything that reads, creates, combines, validates or displays cart rules, vouchers and promotions |
| Database / orders | 9.1 has schema changes for multi-shipment and the new discount system | Order export, invoice, fulfillment, carrier and ERP code against real order fixtures |
| Security patches | 9.1.3 updated Symfony and Twig dependencies | Fast 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,@DemoRestrictedall convert to PHP 8 attribute syntax.sensio/framework-extra-bundleis 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:
FrameworkBundleAdminControlleris deprecated. UsePrestaShopAdminController.- 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.
| Removed | Replacement | Migration effort |
|---|---|---|
guzzlehttp/guzzle | symfony/http-client | Medium — different API shape |
swiftmailer/swiftmailer | symfony/mailer | Medium — new Email object API |
league/tactician-bundle | symfony/messenger | High — different command bus model |
pear/archive_tar | Bundle in your 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);
}
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.

actionAdminLoginControllerBeforeactionAdminLoginControllerLoginBefore/actionAdminLoginControllerLoginAfteractionAdminLoginControllerForgotBefore/actionAdminLoginControllerForgotAfteractionAdminLoginControllerResetBefore/actionAdminLoginControllerResetAfter
For login form customisation, the new hooks are:
actionBackOfficeLoginForm— modify the login form builderactionEmployeeRequestPasswordResetForm— 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 system —
hookDisplayHeader(),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_compliancyare 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 API —
Configuration::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."
| Target | Why it matters | What "pass" looks like |
|---|---|---|
| PrestaShop 8.2 on PHP 8.1 / 8.2 | A lot of production shops will stay on PS 8 while testing 9 | Existing stable build still installs, upgrades and runs without deprecation noise that blocks normal work |
| PrestaShop 9.0 on PHP 8.1–8.4 | This proves the major-version migration: Symfony 6.4, PHP 8.1 minimum, removed libraries | Every controller, hook, cron job, AJAX endpoint and email path runs on a clean 9.0 |
| PrestaShop 9.1 branch | The practical current target — what merchants are actually installing | Module survives Hummingbird default, discount/order changes, latest patch releases |
| Classic and Hummingbird themes | Fresh 9.1 installs use Hummingbird, upgraded shops keep Classic-derived themes | Front-office hooks render without broken layout, missing selectors, duplicate content, or JS errors |
| Fresh install and upgrade from an older module version | Upgrade SQL and config defaults fail differently from fresh installs | Both paths produce the same final tables, configuration values, tabs, hooks and permissions |
| Shop with debug mode on | Warnings hidden in prod become support tickets later | No 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.
- PHP version — test on 8.1+. Remove PHP 7.x compatibility shims.
- Annotations to attributes — find-replace
Security\Annotation\withSecurity\Attribute\. Convert@Routeto#[Route]. Grep for survivors. - Controller base class —
FrameworkBundleAdminControllertoPrestaShopAdminController. - Replace
$this->get()— move to constructor or method injection. - Removed libraries — Guzzle, SwiftMailer, Tactician, pear/archive_tar. Bundle or replace.
- Service definitions — confirm autowiring works.
bin/console debug:container MyService --app-id=admin. - Template variables — test front-office template overrides against the new Presenter shape.
- Removed hooks — grep for the deprecated login and product hooks.
- Standalone PHP endpoints — move any direct AJAX or callback files to
ModuleFrontController. - Update compliancy — set
ps_versions_compliancyto 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.
Comments (8)
Leave a comment
Share a question, an installation detail, or feedback that could help another reader.