Architecture

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

PrestaShop is two applications living in one repository. One is the historical PHP application, with global classes in classes/, ObjectModel active records, Db and Context singletons, Smarty templates and legacy controllers. The other is a Symfony application that has been growing around it since 1.7 and now renders the whole back office layout, serves the Admin API and holds the domain logic. Understanding which part answers which question, and how the two talk to each other, is the difference between extending PrestaShop with the grain and fighting it. This page maps the layers, the three kernels, the template engines, the context services and the feature flags of 9.2, with code that was run on a local installation, and ends with guidance on where a module’s code belongs.

The page was written against PrestaShop 9.2.0 (released 2026-09-30, Symfony 6.4.46, Smarty 4.5.7, Twig 3.27.1, Doctrine ORM 2.20.0, PHP 8.5.12 in the official Docker image, Hummingbird 2.1.2) and checked on 2026-10-27. Every number, route, command output and class name below was read from a running 9.2.0 installation or from the 9.2.0 sources. The running example is Pocket Coffee: its "roast date badge" and its "order placed" log entry cross several of these layers, and the last sections show where each piece of that feature belongs.

Two applications in one repository

The front office and the legacy part of the back office are the older application: a request enters through a legacy controller (FrontController or AdminController subclasses in controllers/ and classes/controller/), which reads and writes data through ObjectModel classes in classes/, and renders a Smarty template. The newer application is a Symfony 6.4 project: the back office pages that were migrated to Symfony, the layout around every back-office page since 9.0, the console, the Admin API and the domain layer are all Symfony code, written with controllers as services, forms, Twig and the dependency injection container. The two share the database, the configuration and the modules, and the code between them is the subject of the next sections.

The official architecture overview describes the same split as four subsystems of the core business stack: legacy code in classes/ and controllers/, the Core in src/Core, the Adapter in src/Adapter and the Symfony bundle in src/PrestaShopBundle. On the checked 9.2.0 installation the three namespaces under src/ hold 3,512 (Core), 1,018 (Adapter) and 1,034 (PrestaShopBundle) PHP files, classes/ holds 103 top-level classes and 97 of the files under it extend ObjectModel, controllers/admin still holds 27 legacy admin controllers and controllers/front the front controllers. The Symfony side is large and growing, but the legacy side is still what makes the shop run.

PrestaShop 9 code layers: three entry points, PrestaShopBundle, Core, Adapter, legacy classes and the database, with modules plugging into every layer

Read the figure from top to bottom for a request and from bottom to top for a dependency. The three entry points differ in the kernel that serves them, the Symfony bundle is the application layer, the Core describes what the shop can do, the Adapter implements the Core’s interfaces on top of the legacy classes, and the legacy layer talks to the database. The front office reaches the legacy controllers directly. Modules sit beside all of it: they attach to hooks, register services, add controllers and, as a last resort, override classes.

The four subsystems

Subsystem Location and namespace Contents and rules May use

Legacy

classes/, controllers/; no namespace

Global classes without namespaces: ObjectModel entities (Product, Category, Tax), Db, Hook, Tools, Validate, Configuration, Context, legacy controllers and the Smarty glue. Static, stateful and everywhere.

Everything

Core

src/Core, PrestaShop\PrestaShop\Core

The default home of new business code: the domain (commands, queries, value objects), interfaces, services without a framework dependency. Must not depend on legacy classes, legacy constants or PrestaShopBundle; should follow SOLID, depend on Adapter interfaces rather than classes and avoid depending directly on Symfony.

Core

Adapter

src/Adapter, PrestaShop\PrestaShop\Adapter

The bridge: classes that wrap legacy code behind a Core interface (command and query handlers, repositories, validators, the LegacyContext). Should implement a Core interface, should only be a bridge, and is a copy of nothing.

Core, Adapter, legacy

PrestaShopBundle

src/PrestaShopBundle, PrestaShopBundle

The Symfony application layer: controllers, form types, grids, Twig components and extensions, security, the translation system, API Platform integration and the console commands. Must not use legacy classes or constants.

Core, Adapter

The rules are written as MUST and SHOULD in the architecture documentation, and the code does not follow them perfectly: on the checked installation 76 of the files under src/Core import a class of the Adapter namespace and 119 mention the PrestaShopBundle namespace (typically the module and shop repositories that the Core cannot yet replace). The rules are the direction of travel and the rule of thumb for new code, not an invariant that tooling enforces.

The Adapter exists because most legacy classes are static, so they cannot be injected into Core classes. The pattern is to let the Core declare an interface, to implement it in the Adapter by calling the legacy class, and to inject the interface. When the legacy class is eventually replaced, only the Adapter changes. The command handlers of the CQRS page are the largest example, and the architecture documentation calls the whole arrangement a transition phase that is expected to last for the whole lifetime of PrestaShop 9.

The legacy stack, in code

The unit of the legacy stack is the ObjectModel: an active record whose static $definition array declares the table, the primary key, the fields, their types and validators, and which fields are translated per language. The 9.2.0 Tax class is a complete, small example.

<?php
class TaxCore extends ObjectModel
{
    public $name;
    public $rate;
    public $active;
    public $deleted = false;

    public static $definition = [
        'table' => 'tax',
        'primary' => 'id_tax',
        'multilang' => true,
        'fields' => [
            'rate' => ['type' => self::TYPE_FLOAT, 'validate' => 'isFloat', 'required' => true],
            'active' => ['type' => self::TYPE_BOOL],
            'deleted' => ['type' => self::TYPE_BOOL],
            /* Lang fields */
            'name' => ['type' => self::TYPE_STRING, 'lang' => true, 'validate' => 'isGenericName', 'required' => true, 'size' => 32],
        ],
    ];
}

The ObjectModel base class turns that array into add(), update(), save() and delete() behaviour (the TYPE_* constants are TYPE_INT 1, TYPE_BOOL 2, TYPE_STRING 3, TYPE_FLOAT 4, TYPE_DATE 5, TYPE_HTML 6, TYPE_NOTHING 7 and TYPE_SQL 8), validates the fields against the named Validate methods, writes the language table for the lang fields and exposes the object to the legacy Webservice through its $webserviceParameters. Tax also shows a typical legacy rule: deleting a tax that orders use only marks it as deleted (a soft delete that historizes the tax) so that old invoices stay correct.

The class is declared as TaxCore and is used as Tax. A runtime autoloader (the prestashop/autoload package in vendor/) generates the class Tax extends TaxCore on demand, with eval(), and that generated stub is the extension point of the legacy override system: a file in override/classes/ that declares class Tax extends TaxCore replaces the stub. The check on the installation confirmed it.

php -r 'require "/var/www/html/config/config.inc.php";
$r = new ReflectionClass("Tax");
echo $r->getFileName(), " parent=", $r->getParentClass()->getName(), "\n";'
# .../vendor/prestashop/autoload/src/Autoloader.php(113) : eval()'d code parent=TaxCore

This is also why static analysis and IDEs cannot find Tax, Db or Module without stubs: the classes with those names do not exist on disk. Section "Overrides and decoration" of the module upgrades page covers the override mechanism and its alternatives.

The other legacy pillars are the Context singleton (shop, language, currency, cookie, cart, customer, employee, Smarty, link and the current controller), Configuration (key-value settings in ps_configuration), Db and DbQuery (SQL with pSQL() escaping), Hook (the hook dispatcher) and Tools. They are global, so they are easy to call from anywhere and hard to test, and most of the Core’s rules exist to keep new code away from them.

Kernels, environments and endpoints

Until 8.x a single Symfony kernel served the back office. Since 9.0 there are three, one per application, each with its own configuration folder, cache folder and endpoint, selected by an application id. The id is a global option of bin/console (--app-id, default admin).

Application Kernel class App id Config folder Cache folder

Back office

AdminKernel

admin

app/config/admin

var/cache/<env>/admin

Admin API

AdminAPIKernel

admin-api

app/config/admin-api

var/cache/<env>/admin-api

Front office (experimental)

FrontKernel

front

app/config/front

var/cache/<env>/front

The three kernel classes are one-line subclasses of the abstract AppKernel, which registers the common bundles (FrameworkBundle, SecurityBundle, TwigBundle, MonologBundle, DoctrineBundle, API Platform, the PrestaShop bundle, the translation tools bundle, the JS routing bundle, Twig components, Twig extras and UX Icons, plus the debug and web profiler bundles in the dev and test environments) and loads, for each active module, the module’s service files. The three applications share app/config/config.yml and extend it in their own folder. The environments are dev, prod and test: the console defaults to dev unless -e or SYMFONY_ENV says otherwise, and the back office runs in dev exactly when the legacy constant _PS_MODE_DEV_ is true.

On the checked installation the route tables showed how different the three applications are: the back office had 843 routes (debug:router), the Admin API about 300 (debug:router --app-id=admin-api) and the front kernel none (debug:router --app-id=front printed an empty table), because the front office still dispatches its URLs through the legacy Dispatcher and only borrows a container. The experimental front container is the front_container_v2 feature flag; it is disabled by default.

php bin/console --version                        # PrestaShop 9.2.0 with Symfony 6.4.46 (env: dev, debug: true)
php bin/console --version --env=prod             # (env: prod, debug: false)
php bin/console cache:clear --env=prod --app-id=admin-api
php bin/console debug:router --app-id=admin-api

Each kernel has its own compiled container, so a service defined for one is not visible in another, and a cache clear must name the application (--app-id) to clear its folder. The per-module service loading of the AppKernel is the part that matters to a module developer, and it is exact: for each active module the kernel looks in config/ for the first existing file of services.php, services-9.2.yml, services-9.yml and services.yml (the first two are new in 9.2), and then does the same in config/admin/. The corresponding block for config/front/ is commented out in the kernel source with a note to enable it once there is a unique container. A module with a front-office service therefore keeps it in config/front/ for the legacy front container and nothing in config/admin/ or config/services.yml reaches it.

The life of a back-office request

A back-office URL has two possible fates, and 9.0 made both of them end in the same Symfony layout.

flowchart TD A["HTTP request to the admin folder"] --> B["AdminKernel boots: app/config/admin plus the services of active modules"] B --> C{"Does a Symfony route match?"} C -- "yes" --> D["Symfony controller: PrestaShopAdminController or FrameworkBundleAdminController"] D --> E["Form, grid, command bus and query bus"] E --> F["Twig template for the page content"] C -- "no: legacy URL such as index.php?controller=AdminImport" --> G["Legacy router checker instantiates the legacy controller"] G --> H["Legacy init, postProcess and content: Smarty and HelperForm"] F --> I["Twig components render the layout: head, navigation, header, toolbar and footer"] H --> I I --> J["HTML response"]

For a migrated page the controller gets its data through the buses and returns a Twig response. For a legacy page, Symfony still owns the request: a special route matches the legacy URL, a legacy controller is instantiated and run (init, permission checks, postProcess, initContent), and its output is captured as a string and placed inside the same layout. Since 9.0 the layout is made of Twig components under PrestaShopBundle\Twig\Component (HeadTag, NavBar, EmployeeDropdown, MultistoreHeader, Footer and a Legacy sub-namespace that renders the same pieces for the old default theme). Some legacy controller methods that only prepared layout data (run, initHeader, initContent, initFooter, display) are no longer called on the Symfony workflow, which matters to a module that overrides one of them; the hooks actionDispatcher, actionDispatcherBefore and actionDispatcherAfter were kept.

Which page is migrated and which is legacy shows in the code and in the flags. On the checked installation the Symfony admin controllers number 97 classes under src/PrestaShopBundle/Controller/Admin, organized as the menu is (Sell, Improve, Configure), and the back office has 653 Twig templates in the bundle next to 172 Smarty templates in the admin themes folder (the admin folder holds two themes: default for legacy pages and new-theme for Symfony pages). The controller hierarchy has two Symfony base classes: FrameworkBundleAdminController, kept for compatibility and deprecated since 9.0 (removal planned for 10.0), and PrestaShopAdminController, the one to use, which is a service that receives its dependencies by injection.

The front office is different. A front request enters through index.php, the legacy Dispatcher picks a FrontController subclass (ProductController, CartController and the other 30 or so in controllers/front), the controller reads the shop through ObjectModel classes, and a Smarty template of the active theme renders the response. No Symfony route is involved, which is why the front kernel has no routes. A module adds front pages by extending ModuleFrontController.

Smarty and Twig

Two template engines coexist, split by application. The front office uses Smarty 4.5.7 with the theme’s .tpl files (Hummingbird 2.1.2 by default, Classic 3.1.2 still shipped); the migrated back office pages use Twig 3.27.1 with .twig templates that extend @PrestaShop/Admin/layout.html.twig and use the PrestaShop UI components; legacy back-office pages still use Smarty in the default admin theme. A module follows the same split: its views/templates/front and views/templates/hook are Smarty, and its views/templates/admin is Twig for Symfony controllers.

The 9.0 layout work removed the last place where a Symfony page needed a legacy controller to render the shell. A module that injects CSS or JavaScript through the controller object (addCSS, addJS) still works because Symfony pages keep a fake legacy controller used only as a container for those assets; the preferred way is the actionAdminControllerSetMedia hook.

Context: legacy and split

The legacy Context::getContext() object remains the source of truth for the request in legacy code. In 9.0 a set of read-only services was introduced to replace it in new code: ApiClientContext, CountryContext, CurrencyContext, EmployeeContext, LanguageContext, LegacyControllerContext and ShopContext under PrestaShop\PrestaShop\Core\Context. They are injected like any service, and ShopContext answers questions such as getId(), getName(), getThemeName(), isMultiShopEnabled() and getShopConstraint(), LanguageContext answers getIsoCode(), getLocale(), formatPrice() and so on.

These services are built by listeners from the HTTP request, and that has a practical consequence that was reproduced: in a console command, asking for ShopContext failed with "Cannot build shop context as no shopConstraint has been defined", while the legacy Context::getContext()->shop was available. Console code that needs the shop therefore uses the legacy context, or sets the constraint on the context builder first. The developer documentation also explains how to initialize legacy classes from the CLI with the legacy context loader service.

Feature flags

A feature flag is a named switch for a capability that ships disabled or in beta, and 9.x uses them heavily to roll out migrated pages and new features. The flags of a fresh 9.2.0 installation (20 of them) are listed below, with the state and stability read from the ps_feature_flag table.

Flag State Stability What it controls

country, carrier, tax_rules_group, merchandise_return, tag, quick_access, email_body_translation, hook_module_v2

enabled

stable

Migrated back-office pages: countries, carriers, tax rules, merchandise returns, tags, quick accesses, e-mail body translation and the "hook a module" page

customer_group, customer_threads, store, permission, catalog_price_rule

disabled

beta

Migrated pages that are not yet the default: customer groups, customer service threads, stores, permissions, catalog price rules

improved_shipment

disabled

beta

Multi-shipment (9.1)

discount

disabled

beta

The new discount system

improved_b2b, new_pricing

disabled

beta

Improved B2B mode and the new pricing (9.2)

front_container_v2

disabled

beta

The experimental Symfony container in the front office

admin_api_multistore, admin_api_experimental_endpoints

disabled

beta

Admin API multistore support and experimental API endpoints

The mechanism is layered. A flag has a type string that lists its sources in priority order, env,dotenv,db by default (env,query,dotenv,db for new_pricing), and the first source that can answer is used.

Layer Behaviour

env

The environment variable PS_FF_<FLAG_NAME> (for example PS_FF_DISCOUNT=1). Read-only: the flag cannot be changed from the back office or the console while the variable is set.

query

The query parameter PS_FF_<FLAG_NAME>, honoured only in debug mode and always read-only. Only new_pricing includes it.

dotenv

The same variable in the .env file at the root of the installation. Read-only as well.

db

The state column of ps_feature_flag. The only layer that the back office page and the console can change.

prestashop:feature-flag lists, enables and disables flags, and its Type column shows the order of the sources with the one in use in brackets (for example env,dotenv,[db]); --help shows the usage. The behaviour was confirmed with the probe module of this page, which prints the layer that answered: with nothing set it reported discount as disabled from the db layer, and the same command run with PS_FF_DISCOUNT=1 in its environment reported it enabled from the env layer. A flag can be read in code with the FeatureFlagManager service (isEnabled(), isDisabled(), getUsedType() and isReadonly()). A flag can be removed once its feature becomes the default (the 9.0 changelog lists the removal of the image flag), so code that depends on a flag must cope with its absence in a later release: getUsedType() throws a RuntimeException for a flag name that does not exist.

A probe module

The module pcfbarch is a console command that puts several of these layers side by side: it prints the kernel, the legacy context, the state and source layer of three flags, and the same tax read through the ObjectModel and through a CQRS query. It has one service definition and one class.

services:
  _defaults:
    public: false
    autowire: true
    autoconfigure: true

  PocketCoffee\PcfbArch\Console\InspectCommand:
    arguments:
      $kernel: '@kernel'
      $queryBus: '@prestashop.core.query_bus'
<?php

declare(strict_types=1);

namespace PocketCoffee\PcfbArch\Console;

use PrestaShop\PrestaShop\Core\CommandBus\CommandBusInterface;
use PrestaShop\PrestaShop\Core\Domain\Tax\Query\GetTaxForEditing;
use PrestaShop\PrestaShop\Core\Domain\Tax\QueryResult\EditableTax;
use PrestaShop\PrestaShop\Core\FeatureFlag\FeatureFlagManager;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpKernel\KernelInterface;

#[AsCommand(name: 'pcfbarch:inspect', description: 'Shows the kernel, the legacy context, feature flags and the same tax read through two layers')]
final class InspectCommand extends Command
{
    public function __construct(
        private readonly KernelInterface $kernel,
        private readonly FeatureFlagManager $featureFlags,
        private readonly CommandBusInterface $queryBus,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $output->writeln(sprintf('Kernel   : %s (environment %s, debug %s)', $this->kernel::class, $this->kernel->getEnvironment(), $this->kernel->isDebug() ? 'on' : 'off'));
        $output->writeln(sprintf('Cache dir: %s', $this->kernel->getCacheDir()));
        // In a console command the split contexts (ShopContext, LanguageContext, ...) are not built:
        // the request listeners that fill them only run for HTTP requests. The legacy Context is.
        $legacyContext = \Context::getContext();
        $output->writeln(sprintf('Shop     : #%d "%s" (legacy Context)', $legacyContext->shop->id, $legacyContext->shop->name));
        $output->writeln(sprintf('Language : #%d %s (legacy Context)', $legacyContext->language->id, $legacyContext->language->iso_code));

        foreach (['carrier', 'discount', 'new_pricing'] as $flag) {
            $output->writeln(sprintf('Flag     : %-12s %s (read from the "%s" layer)', $flag, $this->featureFlags->isEnabled($flag) ? 'enabled' : 'disabled', $this->featureFlags->getUsedType($flag)));
        }

        // The same fact through the legacy ObjectModel and through the CQRS query bus.
        $legacy = new \Tax(1);
        $output->writeln(sprintf('Legacy   : Tax::$rate of tax 1 = %s (ObjectModel, static definition, global state)', $legacy->rate));

        /** @var EditableTax $editable */
        $editable = $this->queryBus->handle(new GetTaxForEditing(1));
        $output->writeln(sprintf('CQRS     : EditableTax::getRate() of tax 1 = %s (%s)', $editable->getRate(), $editable::class));

        return Command::SUCCESS;
    }
}

The main class Pcfbarch extends Module with the usual constructor fields (name, tab, version, author, compliancy) and an isUsingNewTranslationSystem() method, and the module has a composer.json with a PSR-4 autoload section and a generated vendor/autoload.php, as in the CQRS page. After installing the module, the console printed the following, and the second run, with PS_FF_DISCOUNT=1 in the environment, changed only the discount line.

$ php bin/console pcfbarch:inspect
Kernel   : AdminKernel (environment dev, debug on)
Cache dir: /var/www/html/var/cache/dev/admin
Shop     : #1 "PrestaShop" (legacy Context)
Language : #1 en (legacy Context)
Flag     : carrier      enabled (read from the "db" layer)
Flag     : discount     disabled (read from the "db" layer)
Flag     : new_pricing  disabled (read from the "db" layer)
Legacy   : Tax::$rate of tax 1 = 10.000 (ObjectModel, static definition, global state)
CQRS     : EditableTax::getRate() of tax 1 = 10 (PrestaShop\PrestaShop\Core\Domain\Tax\QueryResult\EditableTax)

$ PS_FF_DISCOUNT=1 php bin/console pcfbarch:inspect | grep discount
Flag     : discount     enabled (read from the "env" layer)

The two last lines are the heart of the migration story: the same fact is a float property on a mutable global object in one layer and a typed, immutable result object in the other. They also show a subtle difference in representation (the ObjectModel formats the rate to three decimals, the DTO returns a number), the kind of difference to keep in mind when a module mixes the two.

Where does a module’s code belong

The layers give a decision table for the Pocket Coffee features and for any module.

You want to Put it in Because

Show the roast date on the product page

A hook method in the module class, calling a service for the data

Hooks are the stable extension surface of every layer; see the hooks page

Store the roast date

A table of the module and a repository class (a Core-style domain service)

Never alter core tables; keeps upgrades safe

Save and read it from several places

A command, a query and handlers registered as services

One entry point for the back office, the console and the Admin API (CQRS page)

Add a back-office page

A Symfony controller extending PrestaShopAdminController, registered as a service, with a route and a Twig template

The legacy ModuleAdminController still works, but the Symfony stack is what new pages use

Add a customer-facing page

A ModuleFrontController with a Smarty template

The front office is legacy and has no Symfony routes

Change what a core service does

Service decoration in config/services.yml

No change to core code, reversible, composes with other modules

Change a legacy class

A hook if one exists; as a last resort an override

Overrides are exclusive and forbidden in partner modules

Log "order placed" with the order number and total

A hook subscriber on actionValidateOrder that calls a logger service

The order creation is legacy (PaymentModule::validateOrder); the hook is its public contract

Two warnings come from the architecture itself. First, a Symfony controller must not call a legacy controller: the documentation calls it a no-go. Second, the front office does not see the back-office container, so a front hook cannot get the buses, the router or the translator from it; a front controller or a service of config/front/ is the way, and anything that needs the buses runs in the back-office container or the Admin API.

Legacy guidance from older books

PrestaShop Recipes (2017) was written for 1.6 and describes the structure that was already legacy in 1.7: a flat classes/ and controllers/ tree, AdminController pages built with HelperForm and HelperList, theme and module templates in Smarty, and customization by core overrides. Most of that still exists in 9.2, which is why the old books remain readable, but the book has no Symfony, Twig, Core and Adapter layers, no commands and queries, no kernels, no Admin API and no feature flags, so it describes only one of the two applications in this page. Treat the book as a guide to the legacy stack and the official 9.x documentation as the guide to everything that replaced it.

Pocket Coffee thread

The roast date badge crosses four layers. The roast date lives in a table of the module and is read by a domain service in the module (Core-style code, no legacy dependency except Db). A query handler returns a RoastDateInfo to any caller. The badge is rendered by a hook method in the module class using a Smarty template, in the legacy front-office stack. The product page field that edits the date is a Symfony form on the migrated product page, which sends a command through the bus. The "order placed" log entry listens to a legacy action hook, because the order creation is still legacy code. The pages that follow build each of these pieces.