Migrating from Thelia 2
In Thelia 2, a module controller routes with @Route annotations, declares its services in config.xml, and serves Smarty templates. In Thelia 3, the same controller uses #[Route] PHP attributes, registers its services from a static configureServices() method, and serves Twig. Most of the config.xml is gone. This guide maps every change so you can plan a migration.
What changed at the platform level
Thelia 3 keeps the Thelia model (Propel ORM, event-driven flow, modules) but rebases the framework underneath it:
| Layer | Thelia 2 | Thelia 3 |
|---|---|---|
| PHP | >= 8.2 | >= 8.3 |
| Symfony | 6.4 | 7.4 LTS |
| API Platform | 3.4 (api-platform/core metapackage) | 4.3 standalone (api-platform/symfony) |
| Front-office templates | Smarty | Twig (FlexyBundle) |
| Back-office templates | Smarty (default) | Twig (default-twig bundle) |
| Routing | @Route annotations + routing.xml | #[Route] PHP 8 attributes |
| Module DI | config.xml (services, hooks, loops, forms) | configureServices() + autoconfiguration |
| ORM | Propel | Propel (unchanged) |
| Install | php Thelia thelia:install | php bin/install (standalone) + thelia:install |
The Thelia version numbers here come from the root composer.json (php: ">= 8.3", symfony/*: 7.4.*). The thelia/core library itself still declares a >= 8.2 floor, but the project you install requires PHP 8.3.
The two changes that surprise people coming from Thelia 2:
- The back-office is now Twig, not Smarty. Thelia 2's
defaultSmarty back-office still ships in Thelia 3 during the transition, but the reference is thedefault-twigbundle (see below). - The API is the same API Platform, upgraded. Thelia 2 already shipped API Platform 3.4 with the Propel bridge. Thelia 3 does not introduce API Platform; it moves it from 3.4 to 4.3 standalone. The work is upgrading existing resources, not rewriting a custom API.
Front-office: Smarty → Twig (FlexyBundle)
The front-office is served by the FlexyBundle (templates/frontOffice/flexy/, class FlexyBundle). Templates are .html.twig, data comes from the API through the DataAccessService, and interactivity is built with Symfony UX (Stimulus, TwigComponent, LiveComponent) instead of jQuery.
| Thelia 2 | Thelia 3 |
|---|---|
Smarty .html templates | Twig .html.twig templates in FlexyBundle |
{loop type="product"}...{/loop} | resources('/api/front/products', {...}) via DataAccessService |
{intl l="..."} | {{ '...'|trans }} |
{$VAR} | {{ var }} |
| Custom jQuery scripts | Stimulus controllers |
| Full page reloads | LiveComponents for reactive UI |
| No component system | A set of pre-built Twig/Live components in FlexyBundle |
Data access in a Twig template uses the resources() function, which calls the API internally:
{# templates/frontOffice/flexy/category.html.twig #}
{% for product in resources('/api/front/products', { category: category.id }) %}
<article>{{ product.title }}</article>
{% endfor %}
DataAccessService::loop() and DataAccessService::loopCount() (and the matching Twig loop() / loopCount() helpers) are marked @deprecated. They exist only to ease the transition. Migrate to resources().
Back-office: the default-twig bundle
The back-office reference in Thelia 3 is the default-twig bundle (templates/backOffice/default-twig/, class BackOfficeDefaultTwigBundle). Its README opens with: "Modern Bootstrap 5 / Twig / Stimulus port of the legacy Smarty back-office."
It is a full Symfony bundle, autonomous from the core:
src/Controller/:#[Route]controllers, grouped by domain (Catalog/,Customer/,Order/, …)src/Repository/andsrc/Service/: Propel queries and presenters, kept out of the controllerssrc/Twig/andsrc/UiComponents/: Twig extensions plusAsTwigComponent/AsLiveComponentcomponentssrc/Form/: Symfony form typessrc/Hook/: back-office hooks, declared with the bundle's#[AsHook]attribute (auto-tagged at bundle build)form/bo_form_theme.html.twig: a Bootstrap 5 form themeassets/: SCSS, Stimulus controllers, images, built with npm
The legacy Smarty back-office (templates/backOffice/default/) still ships side by side with default-twig during the transition, but it is no longer recommended and is expected to be dropped in Thelia 3.1. Build new back-office work on the default-twig bundle.
Activate it at install time with --backoffice_theme=default-twig:
php bin/install \
--frontoffice_theme=flexy --backoffice_theme=default-twig \
--pdf_theme=default --email_theme=default \
--with-demo --with-admin
Emails and PDF: Smarty → Twig
The email and PDF themes moved from Smarty to Twig, and the PDF engine changed. If you ship a custom email or PDF theme, or a module that hooks into one, these apply:
| Thelia 2 | Thelia 3 |
|---|---|
Smarty .html / .txt email templates | Twig .html.twig (HTML body) + .txt.twig (text body) |
Smarty .html PDF templates | Twig .html.twig (invoice.html.twig, delivery.html.twig) |
PDF engine spipu/html2pdf | dompdf |
{loop}, {hook}, {config} | loop(...), hook(...), config(...) Twig functions |
{intl l="..."} | {{ '...'|trans({}, 'email', locale) }} (domain email or pdf) |
{format_money}, {format_date}, {format_address} | format_money(...), format_date(...), format_address(...) functions |
i18n placeholders %ref (single %) | %ref% (wrapped) for the email / pdf domains |
html2pdf <page> / <page_header> / <page_footer> / [[page_cu]] | @page / div { position: fixed } / counter(page) in CSS |
catalogs in I18n/{locale}.php (Thelia translator) | catalogs in translations/{domain}.{locale}.php (Symfony translator) |
The layout mechanism changes too: Smarty {extends} becomes Twig {% extends %} with {% block %}. See Emails and PDF for the full theme reference.
Fresh installs seed the message subjects in the database in Twig syntax too ({{ order_ref }} instead of {$order_ref}). The subject goes through the same engine as the message body, so a Smarty email theme cannot render the default subjects: port the theme to Twig, or rewrite the subjects in Smarty syntax from the back office.
dompdf is LGPL-2.1, compatible with Thelia's GPL-3.0. It lays out from CSS 2.1, so a template ported from html2pdf must produce valid table and CSS markup. One known limit: dompdf 3.1 does not resolve counter(pages) (the page total), so the footer shows the current page without a total.
API Platform 3.4 → 4.3 standalone
Thelia 2 ships API Platform 3.4 with the Propel bridge (PropelResourceInterface and ResourceAddonInterface already exist there). The migration is the API Platform 4.3 upgrade, pulled in through the api-platform/symfony package (^4.3) instead of the legacy api-platform/core metapackage.
If your module exposes or extends API resources, these breaking changes apply:
| Change | Before (AP 3.4) | After (AP 4.3) |
|---|---|---|
| IRI / resource-class / URL interfaces | ApiPlatform\Api\IriConverterInterface (and ResourceClassResolverInterface, UrlGeneratorInterface) | ApiPlatform\Metadata\IriConverterInterface (and …\ResourceClassResolverInterface, …\UrlGeneratorInterface) |
| Exceptions | ApiPlatform\Exception\InvalidArgumentException / RuntimeException | ApiPlatform\Metadata\Exception\… |
| OpenAPI on an operation | openapiContext: [...] | openapi: new Operation(...) (ApiPlatform\OpenApi\Model\Operation) |
Extending ObjectNormalizer | extends ObjectNormalizer | ObjectNormalizer is now final, so use NormalizerAwareInterface + NormalizerAwareTrait and delegate |
| Declaring property types | ApiProperty::withBuiltinTypes([...]) | ApiProperty::withNativeType(Type $type) |
// Before (API Platform 3.4)
new GetCollection(
openapiContext: ['parameters' => [['name' => 'foo', 'in' => 'query']]]
)
// After (API Platform 4.3)
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\OpenApi\Model\Parameter;
new GetCollection(
openapi: new Operation(parameters: [
new Parameter(name: 'foo', in: 'query', schema: ['type' => 'string']),
])
)
openapiContext is still supported on #[ApiProperty] (only the operation form changed). The ApiPlatform\Metadata\*, ApiPlatform\State\* and ApiPlatform\OpenApi\* namespaces are unchanged.
Routing: @Route annotations → #[Route] attributes
Symfony 7 removed AnnotatedRouteControllerLoader, so Doctrine @Route annotations no longer work. Thelia's old ModuleAnnotationLoader was deleted and replaced by ModuleAttributeLoader (Thelia\Core\Routing\ModuleAttributeLoader), which auto-scans each active module's Controller/ directory for #[Route] attributes and prefixes the routes with the module's getRoutePrefix().
// Before - Thelia 2 (Symfony 6.4)
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route("/my-path", name="my_route", methods="GET", requirements={"id"="\d+"})
*/
public function myAction(): Response { ... }
// After - Thelia 3 (Symfony 7.4)
use Symfony\Component\Routing\Attribute\Route;
#[Route('/my-path', name: 'my_route', methods: ['GET'], requirements: ['id' => '\d+'])]
public function myAction(): Response { ... }
Watch the syntax shifts: methods="GET" (string) becomes methods: ['GET'] (array), and requirements={"id"="\d+"} becomes requirements: ['id' => '\d+'] (PHP array).
Config/routing.xml files are deprecated as well. Thelia 3 still loads them and logs a deprecation for each one, but support will go away in a later release: move the routes to #[Route] attributes on the controllers, keep the same route names and paths, and delete the file. The modules shipped with Thelia already made the switch.
BaseModule::getAnnotationRoutePrefix() is @deprecated. Override BaseModule::getRoutePrefix() instead: same signature, same behavior. ModuleAttributeLoader calls getRoutePrefix().
Modules: config.xml → configureServices() + autoconfiguration
This is the largest change for module authors. In Thelia 3, config.xml is optional. Services, hooks, loops, forms and commands are registered by autoconfiguration instead of XML.
Thelia 2 (config.xml) | Thelia 3 |
|---|---|
<services> business services | static configureServices() with load()->autowire()->autoconfigure() |
<hooks> | extends BaseHook + getSubscribedHooks(), auto-discovered, no XML |
<loops> | extends BaseLoop, auto-tagged with a snake_case name, no XML (loops still work, but prefer API resources for new code) |
<forms> | extends BaseForm + static getName(), auto-tagged, no XML |
<commands> | autoconfigured |
Services move out of XML into a static method on your module class:
// Before - Config/config.xml
// <services>
// <service id="MyModule\Service\Mailer" class="MyModule\Service\Mailer">
// <argument type="service" id="mailer.mailer"/>
// </service>
// </services>
// After - local/modules/MyModule/MyModule.php
use Symfony\Component\DependencyInjection\Loader\Configurator\ServicesConfigurator;
public static function configureServices(ServicesConfigurator $servicesConfigurator): void
{
$servicesConfigurator
->load('MyModule\\', __DIR__)
->autowire()
->autoconfigure();
}
Hooks, loops and forms are picked up by their base class. The core registers them for autoconfiguration in TheliaKernel (registerForAutoconfiguration(BaseHookInterface::class), LoopInterface::class, FormInterface::class):
// local/modules/MyModule/Hook/FrontHook.php
use Thelia\Core\Hook\BaseHook;
class FrontHook extends BaseHook
{
public static function getSubscribedHooks(): array
{
return [
'main.head-bottom' => [
['type' => 'front', 'method' => 'onMainHeadBottom'],
],
];
}
public function onMainHeadBottom(/* HookRenderEvent $event */): void { ... }
}
<hooks>, <loops> and <forms> blocksA frequent migration mistake is keeping these in config.xml and extending the base class, which registers the service twice. Delete the XML declarations: the base class is enough.
What config.xml is still used for, and only this:
<exports>/<imports>: import/export profiles<parameters>: module parameters- a
<loop>alias when you want a loop name different from the auto-generated snake_case one
// Before - #[TaggedIterator] / #[TaggedLocator] (deprecated since Symfony 7.1)
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
public function __construct(#[TaggedIterator('my.tag')] iterable $handlers) {}
// After
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
public function __construct(#[AutowireIterator('my.tag')] iterable $handlers) {}
module.xml (validated against module-2_2.xsd) and schema.xml (Propel) remain required.
Installation: bin/install
Thelia 2 installs with php Thelia thelia:install, which boots the kernel. Thelia 3 adds bin/install, a standalone script: the database and module phases run on PDO and the filesystem only (no kernel), and it boots App\Kernel in-process just for template:set, the demo import, module:post-activate-all and admin creation. It takes CLI options and environment variables instead of interactive prompts.
# Recommended in Thelia 3
php bin/install \
--frontoffice_theme=flexy --backoffice_theme=default-twig \
--pdf_theme=default --email_theme=default \
--with-demo --with-admin
thelia:install still exists in Thelia 3, but bin/install is the recommended path.
Testing
Thelia 3 ships a test framework in Thelia\Test\ (core/lib/Thelia/Test/):
- Extend
IntegrationTestCasefor functional tests (boots the kernel, rolls back the transaction per test). - Extend
ApiTestCasefor API tests (JWT login + JSON-LD assertions). - Use
FixtureFactoryto build entities, carts and orders.
// tests/MyModuleTest.php
use Thelia\Test\IntegrationTestCase;
final class MyModuleTest extends IntegrationTestCase
{
public function testSomething(): void
{
$factory = $this->createFixtureFactory();
$product = $factory->product($factory->category(), $factory->taxRule(), $factory->currency());
self::assertNotNull($product->getId());
}
}
Bootstrap the isolated test database with bin/test-prepare. It creates the test DB, applies the schema, runs module:post-activate-all, and generates the JWT keypair (lexik:jwt:generate-keypair --skip-if-exists --env=test).
What stayed the same
You do not rewrite these. They carry over unchanged:
- Propel ORM: same query API, same models. No
EntityManager, noflush(): every->save()persists immediately. Respect the strict native types (stringforDECIMAL,intfortinyint, so pass1/0, nottrue/false). - Event-driven flow:
Controller → dispatch(Event) → Action listener → Model::save(). A controller never persists. TheliaEventsconstants: event names are unchanged.- Module lifecycle methods:
install(),update(),preActivation(),postActivation(), etc. onBaseModule. module.xml+schema.xml: still required, same format.
Migration checklist
Front-office templates
- Convert
.htmlSmarty templates to.html.twig - Replace
{loop}calls with theresources()Twig function (not the deprecatedloop()) - Replace
{intl l="..."}with{{ '...'|trans }} - Replace
{$VAR}with{{ var }} - Convert jQuery to Stimulus controllers, reactive UI to LiveComponents
Back-office
- Rebuild back-office screens on the
default-twigbundle (the Smartydefaulttheme is deprecated) - Move controllers to
#[Route]attributes, queries toRepository/, presenters toService/ - Declare hooks with the bundle's
#[AsHook]attribute
Emails and PDF
- Convert email templates to a
.html.twig+.txt.twigpair, PDF templates to.html.twig - Replace
{intl}with{{ '...'|trans({}, 'email', locale) }}(domainemail/pdf), and rewrite placeholders from%refto%ref% - Move i18n catalogs from
I18n/{locale}.phptotranslations/{domain}.{locale}.php - Port html2pdf
<page>/<page_header>/<page_footer>/[[page_cu]]to CSS (@page,position: fixed,counter(page)) - Rewrite Smarty
{extends}layouts as Twig{% extends %}/{% block %}
Modules
- Add static
configureServices()to your module class; remove the<services>XML - Replace
routing.xml/@Routewith#[Route]attributes inController/ - Remove
<hooks>,<loops>,<forms>fromconfig.xml; the base classes auto-register them - Keep in
config.xmlonly<exports>,<imports>,<parameters>, and any<loop>alias - Replace
#[TaggedIterator]/#[TaggedLocator]with#[AutowireIterator]/#[AutowireLocator] - Replace
getAnnotationRoutePrefix()withgetRoutePrefix() - Audit Propel setter calls for strict types (
setVisible(1), notsetVisible(true))
API resources
- Move
ApiPlatform\Api\*imports toApiPlatform\Metadata\* - Move
ApiPlatform\Exception\*imports toApiPlatform\Metadata\Exception\* - Replace operation
openapiContextwithopenapi: new Operation(...) - If you extend
ObjectNormalizer, switch toNormalizerAwareInterface(it is nowfinal) - Replace
ApiProperty::withBuiltinTypes()withwithNativeType()
Testing
- Bootstrap the test DB with
php bin/test-prepare - Extend
IntegrationTestCase/ApiTestCase - Use
FixtureFactoryfor test data
Namespace stability
| Class / method | Status in Thelia 3 |
|---|---|
Thelia\Module\BaseModule | Stable |
Thelia\Controller\Front\BaseFrontController | Stable |
Thelia\Controller\Admin\BaseAdminController | Stable |
Thelia\Core\Event\TheliaEvents | Stable |
Thelia\Core\Hook\BaseHook | Stable |
Thelia\Form\BaseForm | Stable |
Thelia\Core\Template\Element\BaseLoop | Stable; still supported, but prefer API resources for new code |
BaseModule::getAnnotationRoutePrefix() | Deprecated; use getRoutePrefix() |
See also
- Architecture: understand the new system design
- Front-Office: Twig and Symfony UX guide
- Modules: modern module development
- Testing: new test framework