Skip to main content
Version: Thelia 3

Module structure

This page describes every component of a Thelia 3 module and how each one is wired into the application.

The key point up front: Thelia 3 modules contain almost no XML. Services, controllers, commands, event subscribers, hooks, loops and forms are all discovered automatically from your PHP classes. The only XML you still write is the small set the core requires: module.xml, the Propel schema.xml, and a config.xml for a few legacy constructs.

Directory layout

local/modules/MyProject/
├── MyProject.php # Main module class (extends BaseModule)
├── composer.json # Package definition (for distribution)
├── Config/
│ ├── module.xml # Module metadata (required, XSD module-2_2.xsd)
│ ├── schema.xml # Propel database schema (required if you add tables)
│ └── config.xml # Legacy constructs only - see below
├── Api/
│ ├── Resource/ # API Platform resources (auto-discovered)
│ │ └── MyResource.php
│ └── Addon/ # Resource addons (auto-tagged)
│ └── ProductCustomField.php
├── Controller/ # #[Route] attributes - auto-scanned
│ ├── Front/
│ │ └── MyFrontController.php
│ └── Admin/
│ └── MyAdminController.php
├── EventListener/ # EventSubscriberInterface - auto-tagged
│ └── OrderEventListener.php
├── Service/ # plain services - autowired
│ └── MyService.php
├── LiveComponent/ # Symfony UX components
│ └── MyComponent.php
├── Hook/ # extends BaseHook - auto-tagged
│ └── BackHook.php
├── Loop/ # extends BaseLoop - auto-tagged (legacy)
│ └── MyLoop.php
├── Form/ # extends BaseForm - auto-tagged
│ └── ConfigurationForm.php
├── Command/ # #[AsCommand] - auto-discovered
│ └── MyCommand.php
├── Model/ # generated by Propel from schema.xml
│ ├── MyProjectDataQuery.php
│ └── MyProjectData.php
├── templates/
│ ├── frontOffice/
│ │ └── flexy/
│ │ └── my-template.html.twig
│ └── backOffice/
│ └── default-twig/
│ └── my-template.html.twig
└── I18n/
├── en_US.php
└── fr_FR.php
Modules are scanned by your configureServices()

None of the auto-discovery above happens by magic. It is triggered by the configureServices() method on your module class, which calls Symfony's load()->autowire()->autoconfigure() over the module directory. If your module does not define configureServices(), nothing is registered: no services, no controllers, no hooks, no loops, no forms.

The main module class

The entry point for your module:

// local/modules/MyProject/MyProject.php
<?php

declare(strict_types=1);

namespace MyProject;

use Symfony\Component\DependencyInjection\Loader\Configurator\ServicesConfigurator;
use Thelia\Module\BaseModule;

final class MyProject extends BaseModule
{
/** Translation domain used by Translator::trans(..., 'myproject') and the |trans Twig filter. */
public const DOMAIN_NAME = 'myproject';

public static function configureServices(ServicesConfigurator $servicesConfigurator): void
{
$servicesConfigurator->load(self::getModuleCode().'\\', __DIR__)
->exclude([__DIR__.'/I18n/*'])
->autowire()
->autoconfigure();
}
}

The main class must:

  • Have the same name as the module directory.
  • Extend Thelia\Module\BaseModule (or AbstractDeliveryModule / AbstractPaymentModule).
  • Be in a namespace matching the module name.

BaseModule::getModuleCode() derives the module code from the last segment of the class FQCN, so for MyProject\MyProject it returns MyProject. There is no MODULE_CODE constant to declare; the only constant by convention is DOMAIN_NAME, the translation domain used by your I18n/ files.

DOMAIN_NAME vs the module code

DOMAIN_NAME is a convention, not a requirement enforced by BaseModule. It is the string you pass as the translation domain. The module code is always computed by getModuleCode() from the class name. Do not introduce a MODULE_CODE constant expecting the core to read it.

What configureServices() registers

configureServices() is the single mechanism that scans your module. The core registers the relevant Symfony autoconfiguration tags at boot (Thelia\Core\TheliaKernel::loadAutoConfigureInterfaces()), so a plain autowire()->autoconfigure() is enough to wire everything:

ComponentHow it is discoveredWhat you write
Servicesautowire()a plain class under the module
ControllersControllerInterfacecontroller.service_arguments#[Route] on methods
CommandsContainerAwareInterfacethelia.command#[AsCommand] on a class extending ContainerAwareCommand
Event subscribersEventSubscriberInterface (Symfony autoconfigure)a subscriber class
HooksBaseHookInterfacehook.event_listenerextends BaseHook + getSubscribedHooks()
LoopsLoopInterfacethelia.loopextends BaseLoop
FormsFormInterfacethelia.formextends BaseForm
API resources#[ApiResource] in Api/Resource/ (added to api_platform.mapping.paths)the resource class
API addonsResourceAddonInterfacethelia.api.resource.addonthe addon class

In other words, none of these need to be declared in config.xml. The registration is driven by the interface each class implements (or the base class it extends), combined with autoconfigure().

Config/config.xml, for legacy constructs only

config.xml is now optional, and is only required for a handful of constructs that have no auto-discovery equivalent:

  • Exports and imports (<export>, <import>).
  • Container parameters (<parameters>).
  • Loop name aliases, only when the template alias must differ from the name automatically derived from the class (see Loops).
<!-- local/modules/MyProject/Config/config.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<config xmlns="http://thelia.net/schema/dic/config"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://thelia.net/schema/dic/config http://thelia.net/schema/dic/config/thelia-1.0.xsd">

<!-- Only declare a loop here if its template name must differ from the auto-derived one -->
<loops>
<loop name="my_alias" class="MyProject\Loop\MyLoop"/>
</loops>
</config>
Do not declare hooks, forms or services in config.xml

In Thelia 2 you declared <hooks>, <forms> and <services> in config.xml. In Thelia 3 those declarations are redundant: hooks, forms and services are auto-tagged through configureServices(). Keeping them in config.xml is unnecessary and likely to drift out of sync with your code.

Config/module.xml

This file holds module metadata for the back-office and dependency management. Thelia 3 validates it against the module-2_2.xsd schema (the descriptor validator maps descriptor version 3 to module-2_2.xsd):

<!-- local/modules/MyProject/Config/module.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<module xmlns="http://thelia.net/schema/dic/module"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://thelia.net/schema/dic/module http://thelia.net/schema/dic/module/module-2_2.xsd">

<!-- Fully qualified class name -->
<fullnamespace>MyProject\MyProject</fullnamespace>

<!-- Descriptive info (one block per locale) -->
<descriptive locale="en_US">
<title>My Project Module</title>
<subtitle>Extends Thelia with custom features</subtitle>
<description>Full description of what this module does.</description>
<postscriptum>Additional notes or requirements.</postscriptum>
</descriptive>

<!-- Languages declared for the admin interface -->
<languages>
<language>en_US</language>
<language>fr_FR</language>
</languages>

<!-- Semantic versioning -->
<version>1.0.0</version>

<!-- Author information (one or more <author> inside <authors>) -->
<authors>
<author>
<name>Your Name</name>
<company>Your Company</company>
<email>your@email.com</email>
<website>https://example.com</website>
</author>
</authors>

<!-- Module type: classic, delivery or payment -->
<type>classic</type>

<!-- Minimum Thelia version this module is compatible with -->
<thelia>2.5.0</thelia>

<!-- Stability: alpha, beta, rc, prod -->
<stability>prod</stability>
</module>
The <thelia> tag is a compatibility marker

The <thelia> element states the minimum Thelia version your module targets; existing Thelia 3 modules still carry values such as 2.5.0 for backward compatibility. The <type> element selects the module kind (classic, delivery or payment) and determines which base class you extend.

Routing

Define routes with the #[Route] PHP 8 attribute directly on your controller methods. The core's ModuleAttributeLoader scans the Controller/ directory of every activated module and registers every attributed route automatically. There is nothing else to declare.

// local/modules/MyProject/Controller/Admin/MyAdminController.php
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpFoundation\Response;

#[Route('/admin/module/MyProject', name: 'myproject.admin.config')]
public function indexAction(): Response
{
return $this->render('module-config');
}

#[Route('/my-feature/{id}', name: 'myproject.front.show', requirements: ['id' => '\d+'])]
public function showAction(int $id): Response
{
return $this->render('my-page', ['item_id' => $id]);
}

To prefix every route of your module, override getRoutePrefix() on the module class. The loader prepends it to each route path:

// local/modules/MyProject/MyProject.php
public static function getRoutePrefix(): string
{
return '/my-project';
}
Legacy: routing.xml

A Config/routing.xml file is still loadable but no longer recommended. New modules use #[Route] attributes exclusively. Do not use getAnnotationRoutePrefix(); it is deprecated in favor of getRoutePrefix().

Config/schema.xml

Propel database schema for your custom tables:

<!-- local/modules/MyProject/Config/schema.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<database defaultIdMethod="native" name="thelia"
namespace="MyProject\Model">

<table name="my_project_data" namespace="MyProject\Model">
<column name="id" primaryKey="true" required="true" type="INTEGER" autoIncrement="true"/>
<column name="product_id" type="INTEGER"/>
<column name="custom_field" type="VARCHAR" size="255"/>
<column name="is_active" type="BOOLEAN" default="1"/>
<column name="created_at" type="TIMESTAMP"/>
<column name="updated_at" type="TIMESTAMP"/>

<foreign-key foreignTable="product" onDelete="CASCADE">
<reference local="product_id" foreign="id"/>
</foreign-key>

<behavior name="timestampable"/>
</table>

<!-- Table with i18n support -->
<table name="my_project_content" namespace="MyProject\Model">
<column name="id" primaryKey="true" required="true" type="INTEGER" autoIncrement="true"/>
<column name="visible" type="BOOLEAN" default="1"/>

<behavior name="timestampable"/>
<behavior name="i18n">
<parameter name="i18n_columns" value="title, description"/>
</behavior>
</table>
</database>

After modifying the schema, regenerate the models:

php Thelia module:generate:model MyProject
php Thelia module:generate:sql MyProject
Propel is not Doctrine

There is no EntityManager and no flush(). Each ->save() persists immediately. Respect the native Propel types: use string for decimal columns and int (0/1) for tinyint/BOOLEAN setters. Passing PHP booleans will raise a TypeError.

composer.json

For distributing your module:

{
"name": "your-vendor/my-project",
"description": "Custom Thelia module",
"type": "thelia-module",
"license": "MIT",
"require": {
"thelia/installer": "~1.1"
},
"extra": {
"installer-name": "MyProject"
},
"autoload": {
"psr-4": {
"MyProject\\": ""
}
}
}

API components

Api/Resource/

API Platform resources expose your data through REST endpoints. Any class carrying the #[ApiResource] attribute under Api/Resource/ is discovered automatically: the core adds every activated module's Api/Resource directory to api_platform.mapping.paths, and API Platform scans it. This is independent of configureServices():

// local/modules/MyProject/Api/Resource/MyResource.php
<?php

declare(strict_types=1);

namespace MyProject\Api\Resource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use Propel\Runtime\Map\TableMap;
use Thelia\Api\Resource\PropelResourceInterface;
use Thelia\Api\Resource\PropelResourceTrait;

#[ApiResource(
operations: [
new GetCollection(uriTemplate: '/front/my-resources'),
new Get(uriTemplate: '/front/my-resources/{id}'),
],
)]
class MyResource implements PropelResourceInterface
{
use PropelResourceTrait;

public ?int $id = null;
public string $name;

public static function getPropelRelatedTableMap(): ?TableMap
{
return new \MyProject\Model\Map\MyProjectDataTableMap();
}
}

See API Resources for complete documentation.

Api/Addon/

Resource addons enrich existing resources. They implement ResourceAddonInterface, which the core auto-tags as thelia.api.resource.addon:

// local/modules/MyProject/Api/Addon/ProductCustomField.php
<?php

declare(strict_types=1);

namespace MyProject\Api\Addon;

use Symfony\Component\Serializer\Attribute\Groups;
use Thelia\Api\Resource\Product;
use Thelia\Api\Resource\ResourceAddonInterface;
use Thelia\Api\Resource\ResourceAddonTrait;

class ProductCustomField implements ResourceAddonInterface
{
use ResourceAddonTrait;

#[Groups([Product::GROUP_ADMIN_READ, Product::GROUP_FRONT_READ])]
public ?string $customField = null;

// Implementation...
}

See API Addons for complete documentation.

Front-office components

LiveComponent/

Symfony UX LiveComponents for interactive interfaces:

// local/modules/MyProject/LiveComponent/MyComponent.php
<?php

declare(strict_types=1);

namespace MyProject\LiveComponent;

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent(
name: 'MyProject:MyComponent',
template: '@MyProject/live-component/my-component.html.twig'
)]
final class MyComponent
{
use DefaultActionTrait;

#[LiveProp(writable: true)]
public string $query = '';

// Component logic...
}

See LiveComponents for complete documentation.

Hooks

Hook/

Hooks inject content into front-office and back-office templates. A hook extends Thelia\Core\Hook\BaseHook and exposes a static getSubscribedHooks() method. The core's RegisterHookListenersPass calls method_exists($class, 'getSubscribedHooks') and wires one event listener per subscribed hook. There is no XML and no config.xml <hooks> block.

// local/modules/MyProject/Hook/BackHook.php
<?php

declare(strict_types=1);

namespace MyProject\Hook;

use Thelia\Core\Event\Hook\HookRenderEvent;
use Thelia\Core\Hook\BaseHook;

class BackHook extends BaseHook
{
public function onModuleConfiguration(HookRenderEvent $event): void
{
$event->add(
$this->render('module-config.html.twig', [
'title' => $this->trans('My Module', [], 'myproject'),
])
);
}

public static function getSubscribedHooks(): array
{
return [
'module.configuration' => [
[
'type' => 'back',
'method' => 'onModuleConfiguration',
],
],
];
}
}

getSubscribedHooks() returns a map of hook event name → one or more listener definitions. Each definition declares its type (front or back) and either a method to call or a template to render automatically.

BaseHook gives you helper methods you can call from a hook method, notably:

  • render(string $templateName, array $parameters = []) renders a template from the module assets and returns the HTML.
  • trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null) translates a string.
  • addCSS(...) / addJS(...) emit asset tags.
  • getCustomer(), getCart(), getOrder(), getCurrency(), getLang() read the current session context.
There is no getRouteUrl() on BaseHook

BaseHook does not expose a route-generation helper. To build a URL inside a hook, inject the router or use Thelia\Tools\URL instead. Do not call $this->getRouteUrl(...); the method does not exist.

The bundled modules use this exact pattern, for example CustomerFamily\Hook\ProductModuleHook (subscribes to product.tab-content) and SEOne\Hook\ConfigurationHook (subscribes to module.configuration).

Loops

Loop/

Loops are the legacy template data-providers. A loop extends Thelia\Core\Template\Element\BaseLoop, which implements LoopInterface and is auto-tagged thelia.loop by the core. Again, no config.xml entry is needed to register it.

// local/modules/MyProject/Loop/MyLoop.php
<?php

declare(strict_types=1);

namespace MyProject\Loop;

use Propel\Runtime\ActiveQuery\ModelCriteria;
use Thelia\Core\Template\Element\BaseLoop;
use Thelia\Core\Template\Element\LoopResult;
use Thelia\Core\Template\Element\LoopResultRow;
use Thelia\Core\Template\Element\PropelSearchLoopInterface;
use Thelia\Core\Template\Loop\Argument\Argument;
use Thelia\Core\Template\Loop\Argument\ArgumentCollection;

class MyLoop extends BaseLoop implements PropelSearchLoopInterface
{
protected function getArgDefinitions(): ArgumentCollection
{
return new ArgumentCollection(
Argument::createBooleanTypeArgument('active', true),
);
}

public function buildModelCriteria(): ModelCriteria
{
$query = \MyProject\Model\MyProjectDataQuery::create();

if ($this->getActive()) {
$query->filterByIsActive(1);
}

return $query;
}

public function parseResults(LoopResult $loopResult): LoopResult
{
foreach ($loopResult->getResultDataCollection() as $item) {
$row = new LoopResultRow($item);
$row
->set('ID', $item->getId())
->set('NAME', $item->getCustomField());
$loopResult->addRow($row);
}

return $loopResult;
}
}

The loop's template name is derived automatically from the class name: MyProject\Loop\MyLoop becomes my_loop. You only need a <loop> entry in config.xml when the template alias has to differ from this auto-derived name.

Loops are a legacy mechanism

Loops exist for backward compatibility with Smarty-style data fetching. In a Twig-based front office, prefer fetching data through the API (resources('/api/...')) or a dedicated service instead.

Templates

templates/frontOffice/

Twig templates for the front office, under the active theme directory (the reference theme is the Flexy bundle):

templates/frontOffice/
└── flexy/
├── my-page.html.twig
└── live-component/
└── my-component.html.twig

templates/backOffice/

Back-office templates are Twig as well. The reference back-office theme is the default-twig bundle, a standalone Symfony bundle that owns its routes, hooks, templates, forms and assets.

templates/backOffice/
└── default-twig/
├── module-config.html.twig
└── includes/
└── my-partial.html.twig
The Smarty back-office theme is deprecated

The legacy Smarty default back-office theme is no longer the recommended target and is expected to be dropped in Thelia 3.1. Write your back-office hook templates against the default-twig bundle.

Translations

I18n/

Translation files, one per locale, returning a plain key => translation array:

// local/modules/MyProject/I18n/en_US.php
<?php

return [
'My Module' => 'My Module',
'Configuration saved successfully' => 'Configuration saved successfully',
];
// local/modules/MyProject/I18n/fr_FR.php
<?php

return [
'My Module' => 'Mon Module',
'Configuration saved successfully' => 'Configuration enregistrée avec succès',
];

Use the translations from your code:

// In services or hooks (Thelia translator, domain = your DOMAIN_NAME)
$this->translator->trans('My Module', [], 'myproject');
{# In Twig templates #}
{{ 'My Module'|trans({}, 'myproject') }}

Learn more