Front Controllers and Templates

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 ran on the official Docker image prestashop/prestashop:9.2.0 (PHP 8.5.12, MySQL 8.4, Smarty 4.5.7) with Hummingbird 2.1.2 as the active theme, in developer mode and with friendly URLs and the CCC asset combination turned on, which is how the image installs the shop. The front controller mechanism is the one PrestaShop has used since 1.5 and is unchanged in 9; the places where older documentation or older stores differ are in Version notes. PrestaShop is open source software, so no plan or edition changes anything on this page.

A hook can put a block into a page the theme already has, but a module that needs a page of its own (a calendar, a quote form, a payment confirmation, an AJAX endpoint) needs a front controller: a class that PrestaShop finds by name, runs inside the same lifecycle as the core controllers, and renders into the theme’s layout. Writing one involves four things that this page treats in turn: the class and its lifecycle, the URLs that reach it, the template it renders, and the way a theme replaces that template. It follows the page on services and admin controllers, which is the back-office counterpart, and it uses the settings of module configuration and the hooks of the hooks page.

The running example is the public Pocket Coffee roast calendar: the page /roast-calendar lists the next roasting days and lets a visitor type the batch code printed on a bag to see when it was roasted. A second page, club, is reserved for signed-in Coffee Club members. The module is called pcfdfront.

What was run and what was not

The module was installed with bin/console prestashop:module install pcfdfront. The pages were requested over HTTP with and without a cookie jar: the page in English and in Spanish, through the three URL forms, with valid and invalid form posts, as an AJAX request with the Accept header and with the ajax parameter, and the Coffee Club page as a guest. A theme override of the page template was added to and removed from the Hummingbird theme folder to measure the lookup order, the compile cache and the effect of an empty override. php -l, PHP CS Fixer with the PrestaShop standard and the assets of the combined CSS and JavaScript files were checked.

Not run: signed-in customer sessions (no customer account was created, so the Coffee Club page was only checked as a guest), the $ssl option and HTTPS redirections (the test shop runs on plain HTTP), friendly URLs turned off, a child theme, moduleRoutes with URL keywords, CCC turned off, and the experimental Twig front kernel. The Smarty templates were checked by the markup the server returned; no browser was used.

A front controller in outline

A module front controller follows three rules:

  • the file is in controllers/front/ of the module;

  • the class is named <ModuleClassName><FileName>ModuleFrontController in CamelCase: the module pcfdfront has the class Pcfdfront, the file roast.php gives PcfdfrontRoastModuleFrontController;

  • the class extends ModuleFrontController, which extends FrontController and Controller.

Nothing else registers it: PrestaShop finds the file when a request names the module and the controller. $this->module is the instance of the module and $this->context is the current Context (customer, cart, language, currency, shop, link, smarty). The controller below is the whole page.

<?php

declare(strict_types=1);

/**
 * The class name is <ModuleClassName><FileName>ModuleFrontController: Pcfdfront + Roast.
 * The file is controllers/front/roast.php, reachable at
 * index.php?fc=module&module=pcfdfront&controller=roast
 *
 * @property Pcfdfront $module
 */
class PcfdfrontRoastModuleFrontController extends ModuleFrontController
{
    /** Anybody can open the page: no redirect to the login page. */
    public $auth = false;

    /** Name used for the layout lookup and the body class. */
    public $page_name = 'module-pcfdfront-roast';

    private string $batch = '';

    public function init(): void
    {
        parent::init();

        $this->batch = strtoupper(trim((string) Tools::getValue('batch')));
    }

    /**
     * Runs after init() and before initContent(); form submissions are processed here.
     */
    public function postProcess(): void
    {
        if (!Tools::isSubmit('submitBatch')) {
            return;
        }

        // static token of the visitor, rendered in the form as {$static_token}
        if (Tools::getValue('token') !== Tools::getToken(false)) {
            $this->errors[] = $this->trans('Your session expired, please try again.', [], 'Modules.Pcfdfront.Shop');

            return;
        }

        if (!preg_match('/^PC-[A-Z]{3}-\d{3}$/', $this->batch)) {
            $this->errors[] = $this->trans('Batch codes look like PC-ETH-001.', [], 'Modules.Pcfdfront.Shop');

            return;
        }

        [$knownBatch, $roastDate] = array_pad(explode(' ', (string) Configuration::get(Pcfdfront::KEY_LAST_BATCH), 2), 2, '');
        if ($this->batch !== $knownBatch) {
            $this->errors[] = $this->trans('We could not find this batch.', [], 'Modules.Pcfdfront.Shop');

            return;
        }

        // The result survives the redirect (POST/redirect/GET) through the notification mechanism.
        $this->success[] = $this->trans('Batch %batch% was roasted on %date%.', ['%batch%' => $this->batch, '%date%' => $roastDate], 'Modules.Pcfdfront.Shop');
        $this->redirectWithNotifications($this->context->link->getModuleLink($this->module->name, 'roast'));
    }

    public function initContent(): void
    {
        parent::initContent();

        $this->context->smarty->assign([
            'pcfdfront_next' => $this->nextRoasts(4),
            'pcfdfront_batch' => $this->batch,
            'pcfdfront_next_url' => $this->context->link->getModuleLink($this->module->name, 'roast', ['ajax' => 1, 'action' => 'next']),
        ]);

        $this->setTemplate('module:pcfdfront/views/templates/front/roast.tpl');
    }

    /**
     * Called instead of display() when the request is AJAX and has action=next.
     */
    public function displayAjaxNext(): void
    {
        header('Content-Type: application/json; charset=utf-8');
        $this->ajaxRender((string) json_encode(['next' => $this->nextRoasts(3)]));
    }

    /**
     * @return string[]
     */
    private function nextRoasts(int $count): array
    {
        $weekday = max(1, min(7, (int) Configuration::get(Pcfdfront::KEY_WEEKDAY)));
        $day = new DateTimeImmutable('today');
        $day = $day->modify('+' . (($weekday - (int) $day->format('N') + 7) % 7) . ' days');

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

        return $dates;
    }

    protected function getBreadcrumbLinks(): array
    {
        $breadcrumb = parent::getBreadcrumbLinks();
        $breadcrumb['links'][] = [
            'title' => $this->trans('Roast calendar', [], 'Modules.Pcfdfront.Shop'),
            'url' => $this->context->link->getModuleLink($this->module->name, 'roast'),
        ];

        return $breadcrumb;
    }

    public function getTemplateVarPage(): array
    {
        $page = parent::getTemplateVarPage();
        $page['meta']['title'] = $this->trans('Roast calendar', [], 'Modules.Pcfdfront.Shop');
        $page['meta']['description'] = $this->trans('When Pocket Coffee roasts its beans, and how to check the roast date of your bag.', [], 'Modules.Pcfdfront.Shop');

        return $page;
    }
}

The lifecycle

Controller::run() calls the steps below in this order. Overriding the right one is most of the skill.

flowchart TD A["Dispatcher: fc=module, module=pcfdfront, controller=roast"] --> B["__construct, then init: hooks actionFrontControllerInitBefore, SSL redirect, login redirect for auth controllers, country and currency"] B --> C["checkAccess"] C --> D["setMedia: assets"] D --> E["postProcess: forms and POST actions"] E --> F["initHeader, initContent: assign variables, setTemplate, initFooter"] F --> G{"AJAX request?"} G -- no --> H["display: Smarty renders the template inside the layout"] G -- yes --> I["displayAjaxAction if action is set, else displayAjax"]
Method What it is for

__construct()

Sets the properties. Rarely overridden.

init()

Core setup: runs the hooks actionFrontControllerInitBefore and action<ControllerName>InitBefore, redirects to HTTPS when $ssl is true and the shop uses SSL, redirects a guest to the login page when $auth is true, applies geolocation, builds the cart and the customer. When the request is AJAX it switches the header and footer off. Call parent::init() first, then read the request.

checkAccess(), viewAccess()

Return true by default; a controller can refuse access. A refusal renders the "cursed page" of the theme instead of the content.

setMedia()

Register stylesheets and scripts. It runs before postProcess().

postProcess()

Process input. Nothing is rendered yet, so this is where to validate, change data, add messages and redirect.

initContent()

Assign Smarty variables and choose the template with setTemplate(). Always call parent::initContent() first: it assigns the shared variables (urls, page, cart, customer, breadcrumb, static_token and more).

display()

Renders the template through the layout. Rarely overridden.

displayAjax() and displayAjax<Action>()

The AJAX answer; see below.

The base class has more hooks: action<ControllerName>SetVariablesBefore and actionFrontControllerSetVariablesBefore run while the shared variables are built and let another module add variables.

Properties that change the behaviour

Property Default Effect

$auth

false

When true, a visitor who is not signed in is redirected to the login page (/login) from init(). $authRedirection is passed as the back parameter.

$guestAllowed

false

Whether a guest-checkout customer counts as signed in for $auth pages.

$ssl

false

Forces HTTPS for the page when the shop has SSL enabled. When the shop setting PS_SSL_ENABLED is on, the constructor turns it on for every controller.

$page_name

empty

The page name; used for the body id and class and for the layout lookup. For a module controller without it the dispatcher’s controller name is used.

$php_self

empty

The core controllers' page name; it takes precedence over the dispatcher’s name in the layout lookup.

$maintenance

false

Protected. Set true to show the maintenance page.

$content_only / $display_header / $display_footer

false / true / true

Render without header and footer; set to false automatically for AJAX requests.

$errors, $warning, $success, $info

empty arrays

Messages shown in the notification area of the theme.

$ajax

from the request

True when the request has ajax or accepts application/json.

The Coffee Club page uses $auth:

<?php

declare(strict_types=1);

/**
 * Coffee Club page: only for signed-in customers.
 *
 * @property Pcfdfront $module
 */
class PcfdfrontClubModuleFrontController extends ModuleFrontController
{
    /** Guests are redirected to the login page. */
    public $auth = true;

    /** Guest checkout accounts do not count as signed in for this page. */
    public $guestAllowed = false;

    public function initContent(): void
    {
        parent::initContent();

        $this->context->smarty->assign('pcfdfront_firstname', $this->context->customer->firstname);
        $this->setTemplate('module:pcfdfront/views/templates/front/club.tpl');
    }
}

A request for /module/pcfdfront/club without a session answered 302 with Location: http://localhost:8080/login.

URLs

A module controller is reached with Link::getModuleLink($module, $controller = 'default', array $params = [], $ssl = null, $idLang = null, $idShop = null, $relativeProtocol = false). The language and the shop default to the current ones, and the method chooses HTTP or HTTPS and the URL style by itself. Calls measured on the test shop (friendly URLs on, English and Spanish active):

Call Result

getModuleLink('pcfdfront', 'club')

http://localhost:8080/module/pcfdfront/club

getModuleLink('pcfdfront', 'club', ['ref' => 'x y'])

http://localhost:8080/module/pcfdfront/club?ref=x+y

getModuleLink('pcfdfront', 'roast')

http://localhost:8080/roast-calendar (the module registered a route)

getModuleLink('pcfdfront', 'roast', ['ajax' => 1, 'action' => 'next'])

http://localhost:8080/roast-calendar?ajax=1&action=next

getModuleLink('pcfdfront', 'roast', [], null, 2)

http://localhost:8080/es/roast-calendar

getModuleLink('pcfdfront')

http://localhost:8080/module/pcfdfront/default

Without friendly URLs the same controller is reached as index.php?fc=module&module=pcfdfront&controller=roast (with &id_lang= for another language); that form worked on the test shop as well. Never write such URLs by hand: getModuleLink() is what keeps the page reachable when the merchant switches friendly URLs, languages or HTTPS on or off. In a template, {$link->getModuleLink('pcfdfront', 'roast')} does the same, and so does the core’s {url entity='module' name='pcfdfront' controller='roast' params=['ajax' => 1]} helper, which returned http://localhost:8080/roast-calendar?ajax=1.

Friendly URLs with moduleRoutes

A module chooses its own friendly URL by answering the hook moduleRoutes with a list of routes. The route key must be module-<module>-<controller>: getModuleLink() looks for exactly that name to decide whether the module has a route of its own.

<?php

/**
 * Pocket Coffee: public roast calendar page (module front controller).
 */

declare(strict_types=1);

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

class Pcfdfront extends Module
{
    public const KEY_WEEKDAY = 'PCFDFRONT_ROAST_WEEKDAY';
    public const KEY_LAST_BATCH = 'PCFDFRONT_LAST_BATCH';

    public function __construct()
    {
        $this->name = 'pcfdfront';
        $this->tab = 'front_office_features';
        $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 page', [], 'Modules.Pcfdfront.Admin');
        $this->description = $this->trans('A public page with the next roasting days and a batch code lookup.', [], 'Modules.Pcfdfront.Admin');
    }

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

    public function install(): bool
    {
        return parent::install()
            && $this->registerHook('moduleRoutes')
            && $this->registerHook('actionFrontControllerSetMedia')
            && Configuration::updateGlobalValue(self::KEY_WEEKDAY, 1)
            && Configuration::updateGlobalValue(self::KEY_LAST_BATCH, 'PC-ETH-001 2026-10-26');
    }

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

    /**
     * Friendly URL: /roast-calendar instead of /module/pcfdfront/roast.
     *
     * @return array<string, array<string, mixed>>
     */
    public function hookModuleRoutes(array $params): array
    {
        return [
            'module-pcfdfront-roast' => [
                'controller' => 'roast',
                'rule' => 'roast-calendar',
                'keywords' => [],
                'params' => [
                    'fc' => 'module',
                    'module' => 'pcfdfront',
                ],
            ],
        ];
    }

    /**
     * Assets loaded on every front page; restrict them to the pages that need them.
     */
    public function hookActionFrontControllerSetMedia(array $params): void
    {
        if ('module-pcfdfront-roast' !== $this->context->controller->php_self && 'module-pcfdfront-roast' !== ($this->context->controller->page_name ?? '')) {
            return;
        }

        $this->context->controller->registerStylesheet(
            'module-pcfdfront-roast',
            'modules/' . $this->name . '/views/css/roast.css',
            ['media' => 'all', 'priority' => 200]
        );
        $this->context->controller->registerJavascript(
            'module-pcfdfront-roast',
            'modules/' . $this->name . '/views/js/roast.js',
            ['position' => 'bottom', 'priority' => 200]
        );
    }
}

After installation /roast-calendar and /es/roast-calendar served the page, getModuleLink() returned the friendly URL, the alternate-language links of the page (alternative_langs) pointed at both, and the page also stayed reachable at /en/module/pcfdfront/roast and at the index.php?fc=module form without a redirect to the friendly URL, so the same content has several addresses. Add a canonical link (the getCanonicalURL() method of the controller returns the URL used for it) to keep search engines on one.

Direct PHP files are not an option any more

Before PrestaShop 1.5 a module put a PHP file at its root and called it directly (modules/mymodule/validation.php). Since 9.0 this is forbidden: the modules/.htaccess file shipped with the shop denies .php, .log, .txt, .lock, .json, .yml, .yaml, .tpl, .twig, config.xml and config_*.xml requests, and the test shop answered 403 for .php, .json and .tpl files of a module. A module that still needs a script a payment provider or a cron job can call turns it into a front controller, as in the next section. The rules in .htaccess only work where the web server honours it; with Nginx the equivalent location rules have to be written by hand.

Forms, notifications and customer-only pages

A form that posts back to the controller follows the pattern of the example. The form carries a hidden token field with the visitor’s static token, which every front template has as $static_token (the same value as Tools::getToken(false)); postProcess() compares it with the posted value before doing anything else. The base class offers isTokenValid(), but it returns true whenever the merchant setting PS_TOKEN_ENABLE is off, so a module that wants CSRF protection regardless of that setting compares the tokens itself, as the example does.

Messages go into $this->errors, $this->warning, $this->success and $this->info; the theme prints them in the notification area. To avoid a repeated post when the visitor refreshes, a successful post redirects with redirectWithNotifications($url), which stores the four lists in the session (or in a cookie when no session can be started) and prints them once after the redirect. The test did exactly that:

Post Answer

wrong token

HTTP 200, the form again with "Your session expired, please try again."

batch=oops

HTTP 200, "Batch codes look like PC-ETH-001."

batch=PC-BRA-009

HTTP 200, "We could not find this batch."

batch=PC-ETH-001

HTTP 302 to /roast-calendar; the following GET showed "Batch PC-ETH-001 was roasted on 2026-10-26." and a second GET did not show it again

The batch code is read from Tools::getValue('batch'), which returns false when the field is absent: always cast it and validate it, as the example does with a regular expression before using it. Everything that comes from the request and reaches a template has to be escaped there.

AJAX endpoints and cron targets

A request is treated as AJAX when it has an ajax parameter or when its Accept header contains application/json. For such a request init() switches the header and footer off and run() does not call display(); it calls displayAjax<Action>() when the request has an action parameter and the method exists (action=next calls displayAjaxNext()), and displayAjax() otherwise. postProcess() and initContent() still run before it, so keep expensive work out of initContent() or guard it with $this->ajax. ajaxRender($value) sends the answer after the hooks actionAjaxDie<Controller><Method>Before, with Cache-Control: no-store and X-Robots-Tag: noindex headers; set the Content-Type yourself.

Both ways of marking the request worked on the test shop: curl -H 'Accept: application/json' http://localhost:8080/roast-calendar?action=next and curl 'http://localhost:8080/roast-calendar?ajax=1&action=next' answered {"next":["2026-11-02","2026-11-09","2026-11-16"]} with Content-Type: application/json; charset=utf-8.

The script that reads the endpoint from the page and the stylesheet are small. They are registered by the hook actionFrontControllerSetMedia of the module class shown earlier:

document.addEventListener('DOMContentLoaded', () => {
  const list = document.getElementById('pcfdfront-next');
  if (!list) {
    return;
  }
  // The Accept header marks the request as AJAX, so the controller answers with displayAjaxNext().
  fetch(list.dataset.url, { headers: { Accept: 'application/json' } })
    .then((response) => response.json())
    .then((data) => {
      list.setAttribute('data-count', String(data.next.length));
    });
});
.pcfdfront-roast ul { padding-left: 1.25rem; }

registerStylesheet($id, $relativePath, $params) accepts media (default all), priority (default 50, lower first; the example uses 200 to come after the theme), inline, server (local or remote), version and needRtl; registerJavascript($id, $relativePath, $params) accepts position (bottom default or head), priority, inline, attributes, server and version. With the CCC option on (the default for new 9.x installs) the files are merged into the theme’s combined files; the markup contained one theme-<hash>.css and one bottom-<hash>.js and the module’s rules and script were found inside them, not as separate tags. Load assets only on the pages that need them, because the hook runs on every page.

A cron job or a payment provider’s callback should be a front controller as well, because the controller starts the shop context the same way a page does. The documented pattern makes the controller an AJAX one and refuses calls that do not come from the command line or that lack a secret; it was not run here:

<?php
class ExamplemoduleCronModuleFrontController extends ModuleFrontController
{
    public $auth = false;
    public $ajax;

    public function display()
    {
        $this->ajax = 1;

        if (!Tools::isPHPCLI()) {
            $this->ajaxRender('Forbidden call.');

            return;
        }

        // additional token checks, then the work
        $this->ajaxRender("hello\n");
    }
}

Smarty templates

The front office of PrestaShop 9 renders with Smarty 4.5.7. The controller chooses a template with setTemplate(). A module template is written as module:<module>/views/templates/front/<name>.tpl; the module: prefix makes Smarty look first in the theme, then in the module (the next section). A template that is not prefixed is looked up in the theme’s templates/ folder instead, which is wrong for a module.

{extends file='page.tpl'}

{block name='page_title'}
  {l s='Roast calendar' d='Modules.Pcfdfront.Shop'}
{/block}

{block name='page_content'}
  <section class="pcfdfront-roast">
    <h2 class="h4">{l s='Next roasting days' d='Modules.Pcfdfront.Shop'}</h2>
    <ul id="pcfdfront-next" data-url="{$pcfdfront_next_url|escape:'html':'UTF-8'}">
      {foreach $pcfdfront_next as $date}
        <li>{$date|escape:'html':'UTF-8'}</li>
      {/foreach}
    </ul>

    <h2 class="h4">{l s='Check your bag' d='Modules.Pcfdfront.Shop'}</h2>
    <form method="post" action="{$link->getModuleLink('pcfdfront', 'roast')|escape:'html':'UTF-8'}">
      <input type="hidden" name="token" value="{$static_token|escape:'html':'UTF-8'}">
      <label class="form-label" for="pcfdfront-batch">{l s='Batch code printed on the bag' d='Modules.Pcfdfront.Shop'}</label>
      <input class="form-control" id="pcfdfront-batch" name="batch" type="text" value="{$pcfdfront_batch|escape:'html':'UTF-8'}" placeholder="PC-ETH-001" required>
      <button class="btn btn-primary mt-3" type="submit" name="submitBatch" value="1">{l s='Check' d='Modules.Pcfdfront.Shop'}</button>
    </form>
  </section>
{/block}
{extends file='page.tpl'}

{block name='page_title'}
  {l s='Coffee Club' d='Modules.Pcfdfront.Shop'}
{/block}

{block name='page_content'}
  <p>{l s='Welcome back, %name%.' sprintf=['%name%' => $pcfdfront_firstname] d='Modules.Pcfdfront.Shop'}</p>
{/block}

What the templates use

  • {extends file='page.tpl'} puts the page inside the theme’s page template, which itself extends the layout. The blocks a child fills in Hummingbird are page_title (its text is wrapped in the page title component), page_content_top, page_content and page_footer; the core themes use the same block names, which is why a module page looks right in any theme.

  • {l s='text' d='Modules.Pcfdfront.Shop'} translates a string in the new translation system, with the domain after d=; the old form {l s='text' mod='pcfdfront'} belongs to the classic system. sprintf=['%name%' => $value] replaces placeholders. The translations themselves are the subject of the page on module translations.

  • The shared variables assigned by the base class: $urls (all page URLs and image bases), $link (the Link object), $page (meta, body classes, page name), $customer, $cart, $language, $currency, $shop, $configuration, $breadcrumb and $static_token. The complete list is in the theme template-variable reference.

  • Escaping. $smarty->escape_html is on in the front, so a plain {$variable} is HTML-escaped automatically (the batch code <b>x</b> was printed as &lt;B&gt;X&lt;/B&gt;) and the modifier nofilter turns that off. The explicit |escape:'html':'UTF-8' of the example is redundant in the front office but is the convention of the core templates, and it protects the same template if it is reused where escaping is off. Since 9.0 trans() and the Smarty l function no longer escape their result, so a translated string that is built with request data has to be escaped by you. The name of your own variables should be prefixed with the module (pcfdfront_next), because Smarty variables are global and a generic name such as products overwrites a theme variable.

  • Debugging: {debug} opens the Smarty console and {dump($var)} prints a variable; in developer mode every template is wrapped in <!-- begin ... --> and <!-- end ... --> comments that show which file was used.

Page metadata, breadcrumb and layout

Three methods give the page its identity. getTemplateVarPage() returns the $page array; the example overrides it to set the meta title and description (the response contained <title>Roast calendar</title> and the description in the prestashop JavaScript object). getBreadcrumbLinks() returns the breadcrumb; the example appends "Roast calendar" after "Home", which showed in both the visible breadcrumb and the structured data. $page_name gives the body id: <body id="module-pcfdfront-roast" class="lang-en country-gb currency-eur layout-full-width page ...">.

The layout is chosen by getLayout(): the page name is looked up in the theme_settings.layouts map of the theme’s config/theme.yml, the default_layout (layout-full-width in Hummingbird) applies when the page is not listed, and a module can replace the choice by answering the hook overrideLayoutTemplate. ?content_only=1 renders the layout without header and footer, which is how pop-ups load pages.

Twig is not available here. The storefront runs on the light front container, which has no Twig service, and the Symfony front kernel that would provide one is experimental and sits behind the feature flag front_container_v2, which is disabled by default in 9.2.0. Smarty is the template language of front controllers.

Theme overrides of module templates

The theme is the owner of the look of the shop, so PrestaShop lets it replace any template of any module without editing the module. The rule is a mirror: a file at themes/<theme>/modules/<module>/<path> takes the place of modules/<module>/<path>. The resolver for the module: resource tries, in order, the active theme’s modules/ folder, the parent theme’s modules/ folder and the module’s own folder, and uses the first file that exists.

How a module template is found: theme modules folder first then the parent theme then the module itself; the compiled copy is served until it is removed; the template extends page.tpl and the layout

Hummingbird itself uses the mechanism for the core modules: its themes/hummingbird/modules/ folder holds a directory for ps_featuredproducts, contactform, ps_emailsubscription, ps_mainmenu and about forty other modules. The override for the Pocket Coffee page needs one file:

{extends file='page.tpl'}

{block name='page_title'}{l s='Roast calendar' d='Modules.Pcfdfront.Shop'}{/block}

{block name='page_content'}
  <p class="pcfdfront-theme">Overridden by the theme: {foreach $pcfdfront_next as $date}{$date} {/foreach}</p>
{/block}

Saved at themes/hummingbird/modules/pcfdfront/views/templates/front/roast.tpl, it replaced the module’s template (the response had <p class="pcfdfront-theme">Overridden by the theme: 2026-11-02 2026-11-09 2026-11-16 2026-11-23</p> and none of the module’s markup). The experiment gave three results that matter in practice:

  • The compiled copy wins. Smarty compiles a template once and the Docker image uses PS_SMARTY_FORCE_COMPILE = 0 (never check the source: 0 never, 1 check, 2 force). Adding the override file changed nothing until the compiled file of that template under var/cache/<env>/smarty/compile/ was deleted; after that the override applied. Likewise, removing the override restored the module’s template only after the compiled copy was removed. In practice a merchant uses Advanced Parameters > Performance > Clear cache, which empties the Smarty caches; that button was not run, the compiled file was deleted directly.

  • An empty override renders nothing. With an empty roast.tpl in the theme the response was 318 bytes: the two developer-mode comments around nothing, without a header, a layout or an error. An override must be a full template, so copy the module’s file first and edit the copy.

  • Includes follow the path style. When a module template includes another with a relative path ({include file='./_partials/card.tpl'}), Smarty resolves it from the module folder and ignores a theme override of that partial. When the module uses {include file='module:pcfdfront/views/templates/front/_partials/card.tpl'} the override chain applies to the partial too. A module that wants to be themable uses the module: form everywhere; if a module you override does not, the partials have to be overridden together with the main file. This comes from the documentation and was not run.

CSS and JavaScript files of a module can be replaced the same way (themes/<theme>/modules/<module>/css/... and .../js/...), which was not run. A distributed theme should not rely on overrides of modules it does not control, because a module update can rename a block or a CSS class, and an override that is stuck at the old version silently loses the fix. The 9.2 One Page Checkout documentation shows how to override while keeping the DOM structure and selectors the module’s JavaScript needs.

Pitfalls

Symptom Cause and fix

The controller is never called (a 404 or the home page)

File name or class name does not follow <ModuleClassName><FileName>ModuleFrontController; the file is not in controllers/front/; the module is not enabled.

No template found or a blank page

setTemplate() was called without the module: prefix, the path is wrong, or parent::initContent() was not called.

The page has no variables like $urls

parent::initContent() was not called first.

The form always says the session expired

The hidden field is not named token or it holds something other than $static_token; cookies are blocked so each request is a new visitor.

The theme override is ignored

The compiled template is cached. Clear the cache. A relative {include} ignores partial overrides.

The override renders a blank page

An empty or half-written override file. Copy the module’s file before editing.

The same page has two addresses

The friendly route and the module/<name>/<controller> form both answer. Set a canonical URL.

The AJAX call receives the full HTML page

The request has neither the ajax parameter nor Accept: application/json, or there is no displayAjax method.

Assets are loaded on every page

registerStylesheet() in actionFrontControllerSetMedia runs everywhere; test the page name first.

A visitor sees someone else’s data

The page read a customer or cart without $auth = true and without checking isLogged().

The shop was upgraded to 9 and a payment script returns 403

Direct access to .php files under modules/ is blocked; convert the script to a front controller.

$this->get('router') raises ServiceNotFoundException

The front container has no router, translator or Twig; the page on services and admin controllers lists what it has.

Version notes

Version Change

1.4 and earlier

Modules called PHP files at their root and included the theme’s header and footer by hand. Removed from the documentation; do not copy such code from old books.

1.5

Module front controllers with ModuleFrontController and getModuleLink().

1.7

Smarty templates extend page.tpl; stylesheets and scripts are registered with registerStylesheet() and registerJavascript(), and addCSS() and addJS() stopped working.

9.0

Direct calls to PHP files under modules/ are blocked; trans() and Smarty l stop escaping; the Symfony front kernel appears as an experiment.

9.1

Hummingbird becomes the default theme of new installs; Classic is deprecated for new development.

9.2

The One Page Checkout module (ps_onepagecheckout) changes the checkout templates that payment and carrier modules and themes override.