Module Services and Admin Controllers

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.

This page was written against PrestaShop 9.2.0 (released on 2026-09-30) and checked on 2026-10-27. Everything shown was run on the official Docker image prestashop/prestashop:9.2.0 (PHP 8.5.12, MySQL 8.4, Symfony 6.4.46, Twig 3.27.1) in developer mode. The mechanisms are the ones of PrestaShop 9; controllers written for 8.x use another base class and a different way of reaching services, which the comparison table in the section on PrestaShopAdminController sets out. PrestaShop is open source software, so no plan or edition changes anything described here.

A module that does more than add a hook usually grows a small back-office application: a page of its own in the menu, a list or a calendar, some actions that change data, and services that hold the logic so that the page stays thin. PrestaShop 9 builds that on Symfony. The module declares its services in a YAML (or, since 9.2, PHP) file, routes URLs to controller methods in another YAML file, writes controllers as ordinary Symfony controllers on top of PrestaShopAdminController, protects them with a PHP attribute, and registers a tab so that the page has a menu entry and permissions. This page explains each of those pieces and, because the most surprising behaviour of PrestaShop modules is in the way services are split between containers, spends most of its length there. It sits after the page on module configuration, whose modern settings page already used the same controller pattern, and before the pages on front controllers and templates (the shop-window counterpart) and on upgrades, overrides and service decoration.

The running example is the Pocket Coffee roast calendar: a page under Catalog > Roasts that lists the next four roasting days, shows the last logged batch, lets an employee log a batch such as PC-ETH-001 and lets another employee purge the log. The module is called pcfdadmin. It has services for the calendar, a label and a shop-window banner, a controller with five actions, a hidden permission model made of one tab, and a throw-away module that was used to measure how PrestaShop loads service files.

What was run and what was not

The module pcfdadmin was installed with bin/console prestashop:module install. Its routes were listed with debug:router; its services were inspected with debug:container for the back-office kernel (--app-id=admin) and the Admin API kernel (--app-id=admin-api); its page, JSON actions and redirects were requested with an authenticated HTTP session; Module::get() was probed from a back-office request and from a front-office hook; permission denials were provoked by deleting the module’s own role rows of the super administrator profile for the duration of one request and restoring them; the tab was followed through a disable and enable of the module. A throw-away module with four service files measured the file priority of 9.2, and a PHP script that loads a services file with a bare Symfony container measured what a wildcard resource does with the index.php files that every module folder contains. bin/console lint:yaml, lint:twig, prestashop:linter:security-attribute find-missing, prestashop:linter:legacy-link and PHP CS Fixer with the PrestaShop standard were run on the module; the two linters list routes of other modules, not of this one.

Not run: the config/webservice/ folder, #[DemoRestricted] with the demo mode on, a second employee profile with real restricted permissions (the denials were provoked by editing role rows), legacy ModuleAdminController tabs, multistore permissions, and the route option _parent that the 9.x documentation describes for pages without a tab; a search of the 9.2.0 source found no use of it, so it is not documented here as working.

Where module code runs: three containers

A PrestaShop request is served by one of several kernels, each with its own service container. A module has to know which one its code runs in because a service that exists in one does not exist in another.

  • The back-office kernel (AdminKernel, application id admin) serves the modern, Symfony pages: every route in a module’s routes.yml. It has the full Symfony container: router, translator, Twig, forms, Doctrine, security, the PrestaShop adapters.

  • The Admin API kernel (AdminAPIKernel, application id admin-api) serves the REST API and has its own container, built from the same module service files as the back-office one.

  • The front-office container is not a Symfony kernel. It is a light container that PrestaShop builds by hand for hooks and ModuleFrontController pages. It holds Doctrine and a short list of core services; it has no router, no translator and no Twig.

The module class itself is instantiated in all of them, and Module::get('service.id') resolves a service against the container of the current request. That is why the same line $this->get('router') works in getContent() and raises an exception in a front-office hook.

Which module service folder is loaded into which container: config loads in the back-office and Admin API kernels, config/admin in the same two, config/front only in the front container

The picture is the result of these measurements on 9.2.0 and is explained in the next section.

Service definition files

Folders and which container each reaches

PrestaShop reads the service definitions of every installed module from a config folder of the module and from three sub-folders, one per kind of container. The compiler pass that does it (LoadServicesFromModulesPass) is built for one container name at a time: the empty name for the Symfony kernels, admin/ and front/ for the legacy containers.

Folder Documentation says Measured on 9.2.0

config/

Symfony container only: all Symfony components and PrestaShop bundle services.

Defined in the back-office kernel and in the Admin API kernel. Not in the front container: PrestaShop\Module\Pcfdadmin\Controller\RoastController raised a ServiceNotFoundException from a front hook.

config/admin/

Symfony container and the admin legacy container.

Defined in both Symfony kernels (debug:container pcfdadmin.admin.roast_label finds it with --app-id=admin and --app-id=admin-api); reachable with $this->get() from getContent(); not in the front container.

config/front/

Symfony container and the front legacy container.

Defined in the front container only. debug:container pcfdadmin.front.roast_banner finds nothing in either Symfony kernel, and $this->get('pcfdadmin.front.roast_banner') raises ServiceNotFoundException in getContent().

config/webservice/

Symfony container and the Webservice container.

Not run.

The difference in the config/front/ row is worth remembering: do not rely on a front-office service being available from a back-office controller or from a console command, even though the documentation table says it is. Put what both sides need in a file that each folder imports (next section).

The measurement of what a front hook can reach, on the same module, was:

Service asked for with $this->get() in hookDisplayHome() Result

router, translator

ServiceNotFoundException

prestashop.adapter.legacy.configuration

found

pcfdadmin.front.roast_banner (declared in config/front/)

found

pcfdadmin.admin.roast_label (declared in config/admin/)

ServiceNotFoundException

PrestaShop\Module\Pcfdadmin\Controller\RoastController (declared in config/)

ServiceNotFoundException

SymfonyContainer::getInstance()

null

and in getContent() of a back-office request: router, translator, prestashop.adapter.legacy.configuration, pcfdadmin.admin.roast_label, the controller and RoastCalendar were all found, pcfdadmin.front.roast_banner was not. The light front container is described by the core files config/services/common.yml and config/services/front/services_*.yml, which import the adapter and core service files; that is why prestashop.adapter.legacy.configuration is available there.

File names and their priority

Until 9.1 each folder had one possible file, services.yml. Since 9.2 PrestaShop loads the first of these that exists in the folder and ignores the rest, so a module can ship a file per PrestaShop generation:

Order File

1

services.php, a PHP file that returns a closure receiving the ContainerConfigurator

2

services-{major}.{minor}.yml, for example services-9.2.yml

3

services-{major}.yml, for example services-9.yml

4

services.yml

The source of the compiler pass confirms the list and the early break; the behaviour was measured with a throw-away module whose four files each set a different value of the same parameter. With all four present the container reported services.php; after removing that file it reported services-9.2.yml, then services-9.yml, then services.yml. Because only one file is read, a more specific file does not add to a less specific one: if services-9.2.yml exists, services.yml is not loaded, so the specific file must import it when it wants both (imports: - { resource: services.yml }). The same priority applies in config/admin/, config/front/ and config/webservice/. A module that has to run on 9.0 and 9.1 as well keeps a plain services.yml and adds services-9.2.yml for the new behaviour. A PHP file is the way to branch on the version:

<?php
// config/services.php (PrestaShop 9.2 and later)
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

return static function (ContainerConfigurator $container): void {
    if (version_compare(_PS_VERSION_, '9.2.0', '>=')) {
        $container->import('services/services-ps-9.2.php');

        return;
    }

    $container->import('services/services-ps-9.php');
};

Definitions shared between folders

The three kinds of container cannot see each other’s folder, so a service that several of them need (here the roast calendar) is declared once in a file that each folder imports. The services of the folders are merged into the container they reach, which is how RoastCalendar ended up in the two Symfony kernels (through config/services.yml and config/admin/services.yml, both of which import it, a harmless duplicate) and in the front container.

# Definitions shared by every container: the Symfony one (back office), the admin legacy one and the front one.
# No autowiring here: the legacy containers are built by hand, so arguments are explicit.
services:
  _defaults:
    public: true

  PrestaShop\Module\Pcfdadmin\Roast\RoastCalendar:
    arguments:
      - '@prestashop.adapter.legacy.configuration'

The full container file imports it and declares the controller. The defaults make the services private, autowired and autoconfigured; the controller needs autowire and autoconfigure or Symfony cannot give it its container, as the measurement of the previous module showed (has no container set, did you forget to define it as a service subscriber?).

imports:
  - { resource: ./common.yml }

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

  # Controllers are services whose id is the fully qualified class name.
  PrestaShop\Module\Pcfdadmin\Controller\RoastController: ~

The two light files declare only what their container needs. Their definitions use explicit arguments, because the legacy containers are built by hand and the documentation asks not to use named arguments there.

imports:
  - { resource: ../common.yml }

services:
  _defaults:
    public: true

  pcfdadmin.admin.roast_label:
    class: PrestaShop\Module\Pcfdadmin\Roast\RoastLabel
    arguments:
      - '@PrestaShop\Module\Pcfdadmin\Roast\RoastCalendar'
imports:
  - { resource: ../common.yml }

services:
  _defaults:
    public: true

  pcfdadmin.front.roast_banner:
    class: PrestaShop\Module\Pcfdadmin\Roast\RoastBanner
    arguments:
      - '@PrestaShop\Module\Pcfdadmin\Roast\RoastCalendar'

What a service definition can say

Everything the Symfony container offers is available in the full container; the legacy containers accept the same syntax but contain fewer core services. The keys that modules use:

Key Meaning

_defaults

Values applied to every service of the file: public, autowire, autoconfigure, bind, tags.

public: true/false

Whether $container->get('id') can fetch the service from outside. A private service that nothing uses is removed when the container is compiled, so debug:container cannot find it. Services that a module class fetches with $this->get() must be public.

autowire: true

Constructor and method arguments are resolved from their type hints.

autoconfigure: true

Tags are added from the class: a class extending AbstractController becomes a controller (and public), a form type is tagged form.type, a console command console.command, an event subscriber kernel.event_subscriber.

class

The class; optional when the id is the class name.

arguments

Positional arguments: '@service.id', '%parameter%', !tagged_iterator tag.name, scalars.

parent

Inherit another definition. The core abstract form.type.translatable.aware supplies the translator and the locale list to a form type.

tags

Explicit tags, for example { name: controller.service_arguments } when the controller does not extend a base class.

decorates

Wrap an existing service; see the page on decoration.

resource and exclude

Register every class of a folder as a service (see the next table row).

alias

Another id for an existing service.

imports

Load another file.

The wildcard pitfall. A definition with a resource registers every class found in a folder, which saves one line per class. Every module folder contains an index.php redirect stub, and the loader would include it. The documentation says "you might be redirected whenever you open your shop"; the measurement shows the result: a script that loads a services file with resource: "../src/*" through a bare Symfony\Component\DependencyInjection\ContainerBuilder and YamlFileLoader printed nothing at all, not even the line written after the load, because the stub ends with exit;. With the exclusion the same script loaded and listed the service.

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

  # Register every class of src/ as a service, but never the index.php redirect stubs.
  PrestaShop\Module\Pcfdadmin\:
    resource: '../src/*'
    exclude:
      - '../src/index.php'
      - '../src/*/index.php'

The listing above is the correct form. The measurement used a throw-away module and the same file without the two exclude lines; the module was uninstalled and deleted afterwards. In a live installation a definition without the exclusion ends the process that compiles the container, so the symptom is a shop that stops answering rather than an error message that names the file.

Reaching a service

From How

A Symfony controller

Constructor injection (a service used by several actions) or method injection (a service used by one action), by type or with #[Autowire(service: 'service.id')]. $this->get() is not available in PrestaShopAdminController.

Another service

Constructor arguments, in the definition or autowired.

The main module class, back office

$this->get('service.id'); the id must be public. Worked for config/admin/ and config/ services in getContent().

The main module class, front office

$this->get('service.id') for services of config/front/ and of the core light container only.

Any code with the Symfony container

PrestaShop\PrestaShop\Adapter\SymfonyContainer::getInstance(), which returns null in the front office.

Twig

Not services themselves; pass the data from the controller.

PrestaShopAdminController in detail

Anatomy of a controller

Here is the controller of the roast calendar. It shows the three injection styles, the permission attributes of the next section, a CSRF token, JSON and redirect responses, a flash message and the translation call.

<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfdadmin\Controller;

use PrestaShop\Module\Pcfdadmin\Roast\RoastCalendar;
use PrestaShopBundle\Controller\Admin\PrestaShopAdminController;
use PrestaShopBundle\Security\Attribute\AdminSecurity;
use PrestaShopBundle\Security\Attribute\DemoRestricted;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class RoastController extends PrestaShopAdminController
{
    // Constructor injection: autowired from the type, RoastCalendar is declared in config/common.yml.
    public function __construct(private readonly RoastCalendar $calendar)
    {
    }

    #[AdminSecurity("is_granted('read', request.get('_legacy_controller'))", message: 'You do not have permission to view the roast calendar.')]
    public function indexAction(
        Request $request,
        // Method injection: a service picked by id, only built when this action runs.
        #[Autowire(service: 'prestashop.adapter.legacy.configuration')]
        $configuration,
    ): Response {
        return $this->render('@Modules/pcfdadmin/views/templates/admin/roasts.html.twig', [
            'layoutTitle' => $this->trans('Roast calendar', [], 'Modules.Pcfdadmin.Admin'),
            'nextRoasts' => $this->calendar->next(4),
            'lastRoast' => $configuration->get('PCFDADMIN_LAST_ROAST'),
            'logUrl' => $this->generateUrl('pcfdadmin_roast_log'),
            'csrfToken' => $this->container->get('security.csrf.token_manager')->getToken('pcfdadmin_log')->getValue(),
        ]);
    }

    #[DemoRestricted(redirectRoute: 'pcfdadmin_roasts')]
    #[AdminSecurity("is_granted('update', request.get('_legacy_controller'))", message: 'You do not have permission to log a roast.', jsonResponse: true)]
    public function logAction(Request $request): JsonResponse
    {
        if (!$this->isCsrfTokenValid('pcfdadmin_log', (string) $request->request->get('csrf'))) {
            return new JsonResponse(['error' => 'Invalid CSRF token.'], Response::HTTP_BAD_REQUEST);
        }

        $batch = trim((string) $request->request->get('batch'));
        if (!preg_match('/^PC-[A-Z]{3}-\d{3}$/', $batch)) {
            return new JsonResponse(['error' => 'Batch codes look like PC-ETH-001.'], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        $this->getConfiguration()->set('PCFDADMIN_LAST_ROAST', $batch . ' ' . date('Y-m-d'));

        return new JsonResponse(['logged' => $batch]);
    }

    #[AdminSecurity("is_granted('delete', request.get('_legacy_controller'))", message: 'You do not have permission to purge the roast log.', redirectRoute: 'pcfdadmin_roasts')]
    public function purgeAction(): RedirectResponse
    {
        $this->getConfiguration()->remove('PCFDADMIN_LAST_ROAST');
        $this->addFlash('success', $this->trans('The roast log was purged.', [], 'Modules.Pcfdadmin.Admin'));

        return $this->redirectToRoute('pcfdadmin_roasts');
    }

    #[AdminSecurity("is_granted('read', request.get('_legacy_controller'))")]
    public function showAction(string $batch): JsonResponse
    {
        return new JsonResponse(['batch' => $batch, 'weekday' => $this->calendar->weekday()]);
    }

    #[AdminSecurity("is_granted('read', request.get('_legacy_controller'))")]
    public function pingAction(): JsonResponse
    {
        return new JsonResponse(['pong' => true, 'url' => $this->generateUrl('pcfdadmin_ping')]);
    }
}

The services the controller uses:

<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfdadmin\Roast;

use PrestaShop\PrestaShop\Core\ConfigurationInterface;

/**
 * Computes the roasting days of Pocket Coffee: one fixed weekday, every week.
 */
class RoastCalendar
{
    public function __construct(private readonly ConfigurationInterface $configuration)
    {
    }

    public function weekday(): int
    {
        return max(1, min(7, (int) $this->configuration->get('PCFDADMIN_ROAST_WEEKDAY', 1)));
    }

    /**
     * @return \DateTimeImmutable[] the next $count roasting days, today included when it is one
     */
    public function next(int $count, ?\DateTimeImmutable $from = null): array
    {
        $day = ($from ?? new \DateTimeImmutable('today'))->setTime(0, 0);
        $ahead = ($this->weekday() - (int) $day->format('N') + 7) % 7;
        $day = $day->modify('+' . $ahead . ' days');

        $days = [];
        for ($i = 0; $i < $count; ++$i) {
            $days[] = $day->modify('+' . (7 * $i) . ' days');
        }

        return $days;
    }
}
<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfdadmin\Roast;

/**
 * Human readable summary, used from the legacy back-office container (config/admin/).
 */
class RoastLabel
{
    private const NAMES = [1 => 'Monday', 2 => 'Tuesday', 3 => 'Wednesday', 4 => 'Thursday', 5 => 'Friday', 6 => 'Saturday', 7 => 'Sunday'];

    public function __construct(private readonly RoastCalendar $calendar)
    {
    }

    public function describe(): string
    {
        $next = $this->calendar->next(1)[0];

        return sprintf('Pocket Coffee roasts every %s. Next roast: %s.', self::NAMES[$this->calendar->weekday()], $next->format('Y-m-d'));
    }
}
<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfdadmin\Roast;

/**
 * Text for the shop window, used from the light front-office container (config/front/).
 */
class RoastBanner
{
    public function __construct(private readonly RoastCalendar $calendar)
    {
    }

    public function text(): string
    {
        return 'Next roast: ' . $this->calendar->next(1)[0]->format('l j F');
    }
}

The Twig template extends the back-office layout. The alias @Modules points to the modules folder, so the template is addressed as @Modules/pcfdadmin/views/templates/admin/roasts.html.twig; the layoutTitle variable is the title of the page.

{% extends '@PrestaShop/Admin/layout.html.twig' %}
{% trans_default_domain 'Modules.Pcfdadmin.Admin' %}

{% block content %}
  <div class="card">
    <h3 class="card-header"><i class="material-icons">local_cafe</i> {{ 'Next roasting days'|trans }}</h3>
    <div class="card-body">
      <ul>
        {% for day in nextRoasts %}
          <li>{{ day|date('Y-m-d') }}</li>
        {% endfor %}
      </ul>
      <p>{{ 'Last logged roast'|trans }}: {{ lastRoast ?: '-' }}</p>
      <form id="pcfdadmin-log" data-url="{{ logUrl }}" data-csrf="{{ csrfToken }}">
        <input type="text" name="batch" placeholder="PC-ETH-001" class="form-control">
        <button type="submit" class="btn btn-primary mt-2">{{ 'Log roast'|trans }}</button>
      </form>
    </div>
  </div>
{% endblock %}

{% block javascripts %}
  {{ parent() }}
  <script>
    document.getElementById('pcfdadmin-log').addEventListener('submit', async (event) => {
      event.preventDefault();
      const form = event.currentTarget;
      const body = new URLSearchParams({ batch: form.elements.batch.value, csrf: form.dataset.csrf });
      // X-Requested-With marks the request as XHR, so a permission failure answers with JSON.
      const response = await fetch(form.dataset.url, { method: 'POST', body, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
      const data = await response.json();
      window.alert(data.logged ? 'Logged ' + data.logged : (data.error || data.message));
    });
  </script>
{% endblock %}

The main module class carries the tab and, in this example, the two probes of the legacy containers.

<?php

/**
 * Pocket Coffee: roast calendar back-office page (Symfony controller, services, tab).
 */

declare(strict_types=1);

if (!defined('_PS_VERSION_')) {
    exit;
}

if (file_exists(__DIR__ . '/vendor/autoload.php')) {
    require_once __DIR__ . '/vendor/autoload.php';
}

class Pcfdadmin extends Module
{
    public const KEY_WEEKDAY = 'PCFDADMIN_ROAST_WEEKDAY';
    public const KEY_LAST_ROAST = 'PCFDADMIN_LAST_ROAST';

    public function __construct()
    {
        $this->name = 'pcfdadmin';
        $this->tab = 'administration';
        $this->version = '1.0.0';
        $this->author = 'Pocket Coffee';
        $this->need_instance = 0;
        $this->ps_versions_compliancy = ['min' => '9.0.0', 'max' => '9.99.99'];

        parent::__construct();

        $this->displayName = $this->trans('Pocket Coffee roast calendar', [], 'Modules.Pcfdadmin.Admin');
        $this->description = $this->trans('Back-office roast calendar built with a Symfony controller.', [], 'Modules.Pcfdadmin.Admin');

        // One tab per back-office page. It carries the permissions (ROLE_MOD_TAB_ADMINPCFDADMINROASTS_*)
        // and, when "visible" is true, a link in the left menu.
        $this->tabs = [
            [
                'route_name' => 'pcfdadmin_roasts',
                'class_name' => 'AdminPcfdadminRoasts',
                'visible' => true,
                'name' => 'Roasts',
                'parent_class_name' => 'AdminCatalog',
                'icon' => 'local_cafe',
                'wording' => 'Roasts',
                'wording_domain' => 'Modules.Pcfdadmin.Admin',
            ],
        ];
    }

    public function isUsingNewTranslationSystem(): bool
    {
        return true;
    }

    public function install(): bool
    {
        return parent::install()
            && $this->registerHook('displayHome')
            && Configuration::updateGlobalValue(self::KEY_WEEKDAY, 1);
    }

    public function uninstall(): bool
    {
        return Configuration::deleteByName(self::KEY_WEEKDAY)
            && Configuration::deleteByName(self::KEY_LAST_ROAST)
            && parent::uninstall();
    }

    /**
     * Back-office legacy context: services of config/admin/ and config/services.yml are reachable.
     */
    public function getContent(): string
    {
        $label = $this->get('pcfdadmin.admin.roast_label');

        return '<div class="alert alert-info">' . $label->describe() . '</div>';
    }

    /**
     * Front-office legacy context: only the services of config/front/ are reachable through $this->get().
     */
    public function hookDisplayHome(array $params): string
    {
        $banner = $this->get('pcfdadmin.front.roast_banner');

        return '<p class="pcfdadmin-banner">' . htmlspecialchars($banner->text(), ENT_QUOTES, 'UTF-8') . '</p>';
    }
}

The Composer file registers the namespace, and composer dump-autoload generates the vendor/autoload.php that the main class requires:

{
  "name": "pocketcoffee/pcfdadmin",
  "description": "Pocket Coffee roast calendar",
  "type": "prestashop-module",
  "license": "AFL-3.0",
  "autoload": { "psr-4": { "PrestaShop\\Module\\Pcfdadmin\\": "src/" } },
  "config": { "prepend-autoloader": false },
  "require": { "php": ">=8.1" }
}

Helpers of the base class

PrestaShopAdminController extends Symfony’s AbstractController, so render(), redirectToRoute(), generateUrl(), addFlash(), isGranted(), isCsrfTokenValid(), json() and createForm() work as in any Symfony application. PrestaShop adds these protected helpers, read from the 9.2.0 source:

Helper Purpose

getConfiguration(), getIniConfiguration()

The Configuration service and the PHP ini reader.

getShopContext(), getLanguageContext(), getCurrencyContext(), getCountryContext(), getEmployeeContext()

The current shop, language, currency, country and employee as value objects.

getLegacyControllerContext(), getApiClientContext(), getEnvironment(), getFeatureFlagStateChecker()

Legacy controller data, API client, kernel environment, feature flags.

dispatchCommand($command), dispatchQuery($query)

Run a CQRS command or query (see CQRS and domain).

presentGrid($grid), buildSearchResponse(...), updateGridPosition(...)

Grid helpers (see grid and forms).

dispatchHookWithParameters($name, $params)

Dispatch a hook from the controller.

trans($id, $parameters = [], $domain = null, $locale = null)

Translate. The order is parameters, then domain.

generateSidebarLink($section, $title)

Link to the contextual help sidebar.

getErrorMessageForException(), getFormErrorsForJS()

Turn exceptions and form errors into messages.

addFlashErrors($messages), addFlashFormErrors($form)

Flash several error messages or the errors of a form.

getAuthorizationLevel($legacyControllerName), hasAuthorizationByShopConstraint(...)

Imperative permission checks for rights that depend on data.

isDemoModeEnabled(), getDemoModeErrorMessage()

Demo-mode checks.

FrameworkBundleAdminController and PrestaShopAdminController

FrameworkBundleAdminController (8.x, deprecated in 9.0, removed in 10.0) PrestaShopAdminController (9.x)

Service access

$this->get('service'), getContainer()

Constructor or method injection; no $this->get()

Registration

Often tagged controller.service_arguments with a public flag

A service whose id is the class name, with autowire and autoconfigure (or the tag)

trans()

trans($key, $domain, array $parameters = [])

trans($id, array $parameters = [], $domain = null, $locale = null)

Commands and queries

getCommandBus(), getQueryBus()

dispatchCommand(), dispatchQuery()

Doctrine

getDoctrine()

Inject EntityManagerInterface or a repository

Security

@AdminSecurity annotation

#[AdminSecurity] attribute

The argument order of trans() is the trap: code ported from 8.x that calls $this->trans('Text', 'Modules.Mymodule.Admin') passes the domain where PrestaShop 9 expects the parameters. A PHP array is required there, so the mistake fails loudly with a type error rather than silently. The form type of the modern configuration page keeps the old order (TranslatorAwareType::trans($key, $domain, $parameters)), so one module can use both orders in different classes.

Rendering

The back-office layout reads these variables from the template context; the controller of a normal page sets the ones it needs.

Variable Meaning

layoutTitle

The page title.

layoutHeaderToolbarBtn

The buttons of the toolbar: a list of entries with href, desc and icon.

requireAddonsSearch, requireBulkActions, requireFilterStatus

Booleans for the Addons search button, bulk actions and the filter status button.

showContentHeader, enableSidebar

Booleans for the content header and the help sidebar.

help_link

URL of the help button, usually from generateSidebarLink().

Routes

Routes live in config/routes.yml of the module and are loaded for the back-office kernel. Four things are specific to PrestaShop.

# Every route of a module is mounted under /modules by PrestaShop: path /pcfdadmin/roasts is served at
# <admin folder>/index.php/modules/pcfdadmin/roasts?_token=...
pcfdadmin_roasts:
  path: /pcfdadmin/roasts
  methods: [GET]
  defaults:
    _controller: 'PrestaShop\Module\Pcfdadmin\Controller\RoastController::indexAction'
    # Name of the Tab (class_name): permissions, menu highlight and Link::getAdminLink() resolve through it.
    _legacy_controller: AdminPcfdadminRoasts
    _legacy_link: AdminPcfdadminRoasts

pcfdadmin_roast_log:
  path: /pcfdadmin/roasts/log
  methods: [POST]
  defaults:
    _controller: 'PrestaShop\Module\Pcfdadmin\Controller\RoastController::logAction'
    _legacy_controller: AdminPcfdadminRoasts
    # "Class:action" lets Link::getAdminLink('AdminPcfdadminRoasts', true, [], ['action' => 'log']) find this route.
    _legacy_link: AdminPcfdadminRoasts:log

pcfdadmin_roast_purge:
  path: /pcfdadmin/roasts/purge
  methods: [POST]
  defaults:
    _controller: 'PrestaShop\Module\Pcfdadmin\Controller\RoastController::purgeAction'
    _legacy_controller: AdminPcfdadminRoasts
    _legacy_link: AdminPcfdadminRoasts:purge

pcfdadmin_roast_show:
  path: /pcfdadmin/roasts/{batch}
  methods: [GET]
  defaults:
    _controller: 'PrestaShop\Module\Pcfdadmin\Controller\RoastController::showAction'
    _legacy_controller: AdminPcfdadminRoasts
  # A placeholder only matches when its requirement matches; anything else is a 404.
  requirements:
    batch: 'PC-[A-Z]{3}-\d{3}'

pcfdadmin_ping:
  path: /pcfdadmin/ping
  methods: [GET]
  defaults:
    _controller: 'PrestaShop\Module\Pcfdadmin\Controller\RoastController::pingAction'
    _legacy_controller: AdminPcfdadminRoasts
    # Serve the route at /pcfdadmin/ping instead of /modules/pcfdadmin/ping.
    _disable_module_prefix: true

The prefix. PrestaShop mounts every module route under /modules. The route pcfdadmin_roasts with the path /pcfdadmin/roasts is served at <admin folder>/index.php/modules/pcfdadmin/roasts, and debug:router listed /modules/pcfdadmin/roasts. Writing /modules/... in the file produces /modules/modules/.... The route option _disable_module_prefix: true removes the prefix: pcfdadmin_ping is served at /admin170.../index.php/pcfdadmin/ping and the prefixed URL answered 404.

The security token. Every URL generated for the back office carries a _token query parameter. A request without a valid token does not reach the controller: it is answered with a page titled "Invalid token" that offers a link with the correct token. Generating URLs through the router (generateUrl(), router->generate(), path() in Twig) or through Link::getAdminLink() adds the token; building a URL by hand does not. Both the router and getAdminLink() added the token even when getAdminLink() was called with its $withToken argument set to false.

_legacy_controller and _legacy_link. The back office was written around legacy controller names (AdminProducts) and a lot of machinery still looks them up. _legacy_controller gives the route the name of the tab it belongs to, which is the key of its permission roles, its menu entry and its breadcrumb, and it is what request.get('_legacy_controller') evaluates to in #[AdminSecurity]. _legacy_link makes Link::getAdminLink() resolve to the route. Calls and results measured:

Call Result

$link->getAdminLink('AdminPcfdadminRoasts')

http://localhost:8080/admin.../index.php/modules/pcfdadmin/roasts?_token=...

$link->getAdminLink('AdminPcfdadminRoasts', true, [], ['action' => 'log'])

.../modules/pcfdadmin/roasts/log?_token=..., because the route’s _legacy_link is AdminPcfdadminRoasts:log

$link->getAdminLink('AdminPcfdadminRoasts', true, ['route' => 'pcfdadmin_ping'])

.../index.php/pcfdadmin/ping?_token=...: a route entry in the second array selects a route by name

$router->generate('pcfdadmin_roast_show', ['batch' => 'PC-ETH-001'])

/admin.../index.php/modules/pcfdadmin/roasts/PC-ETH-001?_token=... (path only)

The prestashop:linter:legacy-link command lists routes that have no _legacy_link; it listed none of this module’s. Appending query parameters to the result of getAdminLink() by hand does not work for Symfony routes; pass them in the arrays.

Placeholders and requirements. A placeholder in the path is passed to the action by name (showAction(string $batch)); the requirements entry restricts what it matches. GET .../modules/pcfdadmin/roasts/PC-ETH-001 answered {"batch":"PC-ETH-001","weekday":1} and GET .../roasts/oops answered 404. Define fixed paths such as /roasts/log before a route with a placeholder in the same position; the first matching route wins.

flowchart LR A["Browser: GET /admin/index.php/modules/pcfdadmin/roasts?_token=..."] --> B{"_token valid?"} B -- no --> C["Invalid token page with a corrected link"] B -- yes --> D["Router: route pcfdadmin_roasts"] D --> E["AdminSecurityListener: is_granted read on AdminPcfdadminRoasts"] E -- denied --> F["AccessDeniedListener: redirect with flash message, or JSON 403"] E -- granted --> G["Controller service RoastController::indexAction"] G --> H["Services: RoastCalendar and Configuration"] G --> I["Twig: roasts.html.twig extends the back-office layout"] I --> J["HTML response"]

Security

Layers

A back-office action is protected by four independent layers, and a module normally uses all of them.

  • The URL token. Described in the previous section. It stops links from other sites and typed URLs.

  • Permissions per tab. #[AdminSecurity] checks that the employee’s profile holds a role.

  • CSRF tokens for changes. As defence in depth, a form or an XHR that changes data can carry a Symfony CSRF token of its own, tied to the action. The controller above creates one with the security.csrf.token_manager service from the container the base class gives it, puts it in the page, and checks it with isCsrfTokenValid(). A call with a wrong token answered {"error":"Invalid CSRF token."} with status 400. purgeAction above checks no token of its own, so for it the URL _token is the only protection against cross-site requests; a real purge button should carry and check a pcfdadmin_purge token in the same way. Symfony forms add form[_token] by themselves.

  • Demo mode. #[DemoRestricted] blocks an action when the shop runs in demo mode (_PS_MODE_DEMO_). It was not run.

Roles and permissions

When a tab is created PrestaShop creates four roles for it, named from the tab’s class_name in capitals: ROLE_MOD_TAB_ADMINPCFDADMINROASTS_CREATE, _READ, _UPDATE and _DELETE. Four more are created for every module (ROLE_MOD_MODULE_PCFDADMIN_CREATE and so on), which the module manager uses for its own actions. The roles of the new tab are granted to the profile of the employee who installs the module and to the super administrator; for any other profile the merchant ticks them in Advanced Parameters > Team > Permissions. The ps_access table links profiles and roles. The convention for what each role means:

Role Used for

READ

Showing a page or listing data. The index action needs this and only this.

CREATE

Adding an item.

UPDATE

Changing an item.

DELETE

Removing an item.

A page that edits settings is allowed to employees who hold any of CREATE, UPDATE or DELETE, as the core settings pages do.

The AdminSecurity attribute

#[PrestaShopBundle\Security\Attribute\AdminSecurity] can be put on a class (it then applies to every action) or on a method; both are evaluated, class first. Its first argument is an expression for Symfony’s is_granted; request.get('_legacy_controller') reads the tab name from the route, so the same expression works for every route of the tab. The attribute replaces the @AdminSecurity annotation, which still exists in 9.2.0 as a deprecated class and logs a deprecation when used. The older ModuleActivated annotation does not exist in 9.2.0; a module that must refuse access when it is disabled has to check it itself, because the routes of a disabled module still answered in the measurement (the JSON action returned 200 after prestashop:module disable).

Argument Meaning

first argument (attribute)

The expression, for example is_granted('update', request.get('_legacy_controller')).

message

The text shown or returned when access is denied (default "Access Denied.").

domain

Translation domain of the message (default Admin.Notifications.Error).

redirectRoute

Route to redirect to when denied; the message is flashed as an error.

redirectQueryParamsToKeep

Query parameters of the current request copied to the redirect route.

url

Redirect URL when no route is given (deprecated).

jsonResponse

Answer with JSON when denied.

statusCode

Throw an HTTP exception with this status instead of an access-denied exception.

exceptionCode

The code of the exception.

What each argument produced, tested by deleting a role row of the super administrator for one request:

Action and missing role Response

purgeAction (redirectRoute), role DELETE missing

HTTP 302 to pcfdadmin_roasts with the error flash "You do not have permission to purge the roast log."; with the role restored the action redirected the same way with the success message "The roast log was purged."

logAction (jsonResponse: true), role UPDATE missing, XHR or not

HTTP 403, application/json, {"status":false,"message":"You do not have permission to log a roast."}

logAction with statusCode: 403 instead

A generic "403 Forbidden" error page. A status code bypasses the access-denied listener, which only handles AccessDeniedException, so it also bypasses redirectRoute and jsonResponse. Use statusCode only when a plain HTTP error is what you want.

indexAction (no redirect arguments), role READ missing

HTTP 403 error page whose title is the message followed by "(403 Forbidden)".

The console command prestashop:linter:security-attribute find-missing lists routes without the attribute (three core routes of the Marketplace module in this install, none of the module under test) and prestashop:linter:security-attribute list lists those that have it.

Tabs

A tab is a row of the ps_tab table. It gives a page a menu entry, a breadcrumb and a set of permission roles. Modules declare them in the $tabs property of the main class, which PrestaShop reads at install and removes at uninstall.

Key Required Meaning

class_name

yes

The tab’s technical name; it must equal _legacy_controller of the routes, and it names the roles.

route_name

no

The Symfony route the menu entry opens.

name

no

The label; a string for all languages, or an array indexed by locale or language code ('en-US' => ...). The class name is shown if absent.

wording, wording_domain

no

Translation key and domain of the label, so the translation system can translate it (since 1.7.8).

parent_class_name

no

Where the entry appears: another tab’s class_name.

visible

no

false creates the tab for permissions only, with no menu entry.

icon

no

A Material icon name, if any.

The tab in the example is class_name: AdminPcfdadminRoasts, parent_class_name: AdminCatalog, visible: true. After installation ps_tab contained one row with route_name = pcfdadmin_roasts, active = 1 and enabled = 1, the back-office menu showed "Roasts" under Catalog (the list item subtab-AdminPcfdadminRoasts was marked as the active one on the page), and the four roles existed and were granted to profile 1. Disabling the module with prestashop:module disable pcfdadmin set enabled to 0 (the entry disappears from the menu) and left active at 1; enabling it set enabled back to 1. The page and the JSON actions kept answering while the module was disabled.

Menu parents available as parent_class_name: for Sell, AdminParentOrders, AdminCatalog, AdminParentCustomer, AdminParentCustomerThreads, AdminStats; for Improve, AdminParentModulesSf, AdminParentThemes, AdminParentShipping, AdminParentPayment, AdminInternational; for Configure, ShopParameters and AdminAdvancedParameters; and AdminDashboard. A top-level entry of the module’s own is possible but discouraged; the documentation lists these parents as of 1.7 and the 9.2 install uses the same names for the ones tried here.

Hidden tabs. A tab with visible => false exists only for permissions; the modern settings page of the previous page uses one. Controllers placed in the controllers/admin/ folder of a module (legacy ModuleAdminController classes) are registered as hidden tabs automatically when no visible tab exists for them. That legacy mechanism was not run.

Manual tabs. A module can also create the Tab object itself, which is what to do when the tab depends on a condition or must be created after install (for example on enable). The documented pattern, not run here, installs the tab in install() and enable() and removes it in uninstall() and disable():

private function installTab(): bool
{
    $tabId = (int) Tab::getIdFromClassName('AdminPcfdadminRoasts');
    $tab = new Tab($tabId ?: null);
    $tab->active = 1;
    $tab->class_name = 'AdminPcfdadminRoasts';
    $tab->route_name = 'pcfdadmin_roasts';
    $tab->name = [];
    foreach (Language::getLanguages() as $lang) {
        $tab->name[$lang['id_lang']] = $this->trans('Roasts', [], 'Modules.Pcfdadmin.Admin', $lang['locale']);
    }
    $tab->id_parent = (int) Tab::getIdFromClassName('AdminCatalog');
    $tab->module = $this->name;

    return $tab->save();
}

Pitfalls

Symptom Cause and fix

has no container set, did you forget to define it as a service subscriber?

The controller is not a service, or lacks autowire: true and autoconfigure: true, or the service id differs from the class name.

ServiceNotFoundException for router or translator in a hook

The hook runs in the front container. Pass what is needed from a controller, or declare a service in config/front/ that does not need them.

A service defined in config/front/ is missing in a back-office controller

config/front/ loads only in the front container on 9.2.0. Share definitions through an imported file.

The shop shows a blank page or exits while the container compiles

A wildcard resource includes index.php. Add the exclude lines.

services-9.2.yml hides services.yml

Only the first existing file is read. Import services.yml from the specific file.

Invalid token after a click

The URL was built by hand. Generate it through the router or Link::getAdminLink().

Access Denied for an employee who can open the module manager

The roles of the tab were not granted to that profile.

generate() fails with a missing parameter

A route with {placeholder} needs it in the call; the requirements must match the value.

The controller works in the browser and the route is 404 in a test

Module routes are mounted under /modules; either use the prefixed URL or _disable_module_prefix.

A disabled module’s page is still reachable

Routes of installed modules stay loaded. Check Module::isEnabled($name) in the action when it matters.

Doctrine repositories not available in the controller

They are not reachable through the base class; inject them.

Version notes

Version Change

1.7.3

Modules can declare Symfony services in config/services.yml.

1.7.5 to 1.7.7

_legacy_link, route_name in tabs and automatic registration of Symfony controllers through $tabs.

1.7.6

Light legacy containers with config/admin/ and config/front/.

8.1

Module autoloaders and service files are registered before the compiler passes, so autoconfiguration, _instanceof and wildcard resources work in modules.

9.0

PrestaShopAdminController, controllers as services, #[AdminSecurity]; the front and Admin API kernels appear.

9.2

services.php, services-9.2.yml and services-9.yml take priority over services.yml.

10.0 (planned)

FrameworkBundleAdminController is removed.