Creating a theme from scratch
A Thelia 3 front-office theme is a Symfony bundle. The Flexy theme ships as FlexyBundle: a class extending AbstractBundle that auto-loads its own services, Twig/Live components, controllers and assets. Template files alone are not enough. A real theme needs its Bundle class so Symfony can wire everything together.
This guide walks through the pieces of a theme, using Flexy as the reference, and shows how to build your own.
Prerequisites
- A working Thelia 3 installation (see Installation).
- Familiarity with Twig basics in Thelia and data access.
A front-office theme served through AssetMapper needs no Node.js and no bundler. Flexy is one, and the guide below follows it.
A theme is a bundle
The Flexy theme registers itself as a Symfony bundle. Services and controllers are autowired from src/, components from components/, and both directories are declared as PSR-4 roots in the theme's composer.json:
{
"autoload": {
"psr-4": {
"FlexyBundle\\": "src/",
"FlexyBundle\\Components\\": "components/"
}
}
}
// templates/frontOffice/flexy/src/FlexyBundle.php
namespace FlexyBundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
class FlexyBundle extends AbstractBundle
{
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
{
$container->import('../config/services.yaml');
$container->services()
->defaults()
->autowire()
->autoconfigure();
}
public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void
{
$builder->prependExtensionConfig('twig', [
'paths' => [
\dirname(__DIR__).'/components' => 'Flexy',
\dirname(__DIR__).'/form' => 'FlexyForm',
],
'globals' => [
'flexy_form_themes' => ['@FlexyForm/flexy_form_theme.html.twig'],
],
]);
$builder->prependExtensionConfig('twig_component', [
'anonymous_template_directory' => 'frontOffice/%thelia_front_template%/components/',
'defaults' => [
'FlexyBundle\\Components\\' => [
'template_directory' => '@Flexy',
'name_prefix' => '',
],
],
]);
// AssetMapper, UX Icons, Tailwind and Stimulus are configured the same way.
}
}
What this does:
loadExtension()importsconfig/services.yaml, which registers both PSR-4 roots as autowired, autoconfigured services.autoconfigure()is what makes#[Route]controllers,#[AsTwigComponent]and#[AsLiveComponent]classes work without any XML.prependExtension()configures the framework for the theme: Twig namespaces and globals, the TwigComponent defaults, AssetMapper, UX Icons, Tailwind and the Stimulus controller paths.name_prefix: ''is what makes component names carry no prefix.- Keys that must follow the active front-office template are written with the
%thelia_front_template%parameter, so a theme that is installed but not active does not register its own paths.
The bundle is enabled in config/bundles.php:
// config/bundles.php
return [
// ...
FlexyBundle\FlexyBundle::class => ['all' => true],
];
For your own theme, create a MyThemeBundle class following this pattern (adjust the namespaces and the two psr-4 entries in composer.json), register it in config/bundles.php, and point its Twig and component paths at your theme directory. Without a Bundle class, controllers, Twig components and Live components in your theme will never be discovered.
Directory structure
The Flexy theme lives in templates/frontOffice/flexy/. A theme combines flat page templates at the root, a components/ tree, a src/ PHP tree (the bundle code) and an assets/ pipeline:
templates/frontOffice/my-theme/
├── template.xml # Theme descriptor (read by Thelia)
├── composer.json # type: thelia-frontoffice-template
├── importmap.php # AssetMapper entrypoints and JavaScript dependencies
├── config/
│ ├── views.yaml # root templates that are not pages of their own
│ └── packages/ # framework configuration the theme ships
├── base.html.twig # Base layout
├── index.html.twig # Homepage
├── category.html.twig
├── product.html.twig
├── content.html.twig
├── folder.html.twig
├── search.html.twig
├── login.html.twig
├── customer-register.html.twig
├── account.html.twig
├── account-orders.html.twig
├── account-order.html.twig
├── account-addresses.html.twig
├── address.html.twig
├── address-update.html.twig
├── checkout-cart.html.twig
├── checkout-delivery.html.twig
├── checkout-payment.html.twig
├── checkout-confirm.html.twig
├── checkout-gateway.html.twig
├── checkout-failed.html.twig
├── contact.html.twig
├── contact-success.html.twig
├── password-forgotten.html.twig
├── reset_password.html.twig
├── 404.html.twig
├── error.html.twig
├── maintenance.html.twig
├── components/ # Components, PHP-backed or anonymous (namespace @Flexy)
│ ├── Atoms/
│ ├── Fields/
│ ├── Forms/
│ ├── Layouts/
│ ├── Molecules/
│ └── Organisms/
├── form/ # Form theme (namespace @FlexyForm)
├── src/ # The bundle: Bundle class, Controllers, Services, DTOs
│ ├── MyThemeBundle.php
│ └── Controller/
└── assets/
├── app.js
├── controllers.json
├── controllers/
├── styles/
├── icons/
└── images/
The exact file list above mirrors the real Flexy theme root. Pages like checkout-gateway, customer-activation, customer-informations, password-forgotten-confirm, reset-password-confirm, faq, page, sitemap and wishlist also exist in Flexy. Browse templates/frontOffice/flexy/ to see the full set, then keep only the pages your shop needs.
Theme descriptor: template.xml
Every theme has a template.xml at its root. This is the only XML a theme needs: a descriptor read by Thelia, not a service or routing configuration. Here is the real Flexy descriptor:
<!-- templates/frontOffice/my-theme/template.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<template xmlns="http://thelia.net/schema/dic/template"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://thelia.net/schema/dic/template http://thelia.net/schema/dic/template/template-1_0.xsd">
<descriptive locale="en">
<title>My front office template</title>
</descriptive>
<languages>
<language>en_US</language>
<language>fr_FR</language>
</languages>
<version>1.0.0</version>
<authors>
<author>
<name>Your Name</name>
<company>your-company</company>
<email>contact@example.com</email>
<website>example.com</website>
</author>
</authors>
<thelia>3.0.0</thelia>
<stability>prod</stability>
</template>
The tags, in order:
<descriptive locale="...">: one block per locale, each with a<title>. Add as many as you need (Flexy shipsfranden).<languages>: the locales the theme supports.<version>: the theme version.<authors>: one or more<author>blocks with<name>,<company>,<email>,<website>.<thelia>: the minimum core version the theme requires.<stability>:prod,beta,alpha, etc.<assets>: optional, the directory holding compiled assets. It only drives the Webpack Encore manifest and its symlink. A theme served through AssetMapper, as Flexy is, declares no<assets>tag: it would point at adistdirectory no build ever produces.
There is no <name>, flat <author>, <description>, <parent> or <required_version> tag. The descriptor does not declare theme inheritance. To reuse Flexy from your own theme, render Flexy's components directly (see Using Flexy components) rather than declaring a parent.
Creating the base layout
{# templates/frontOffice/my-theme/base.html.twig #}
<!DOCTYPE html>
<html lang="{{ app.request.locale }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}My Shop{% endblock %}</title>
{% block stylesheets %}
<link rel="stylesheet" href="{{ asset('styles/app.css') }}" blocking="render">
{% endblock %}
{% block javascripts %}
{{ importmap('app') }}
{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
{% block header %}
<twig:Layouts:Header:Base />
{% endblock %}
<main {% block main_attributes %}{% endblock %}>
{% block body %}{% endblock %}
</main>
{% block footer %}
<twig:Layouts:Footer:Base />
{% endblock %}
{% block body_js %}{% endblock %}
</body>
</html>
asset() resolves a path inside the theme's assets/ directory, and importmap() renders the
app entrypoint declared in the theme's importmap.php. Both come from AssetMapper.
Flexy registers exactly two Twig namespaces from its bundle class: @Flexy for components/ and @FlexyForm for form/. Components are usually rendered by name (<twig:Layouts:Header:Base />), and the namespace is used when one component template references another ({% extends '@Flexy/Organisms/AddressCard/Base.html.twig' %}).
Essential pages
Homepage (index.html.twig)
The homepage is served at / by the route named index. Fetch data with resources() (see Data access). Product and category URLs are rewritten, so link with the resource's publicUrl field. There is no product_show or category route to call.
{# templates/frontOffice/my-theme/index.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<section class="featured-categories container">
<h2>Shop by Category</h2>
{% set categories = resources('/api/front/categories', {
parent: 0,
visible: 1,
itemsPerPage: 6
}) %}
<div class="category-grid">
{% for category in categories %}
{# publicUrl is the rewritten, SEO-friendly category URL #}
<a href="{{ category.publicUrl }}" class="category-card">
<h3>{{ category.i18ns.title }}</h3>
</a>
{% endfor %}
</div>
</section>
<section class="featured-products container">
<h2>Featured Products</h2>
{% set products = resources('/api/front/products', {
visible: 1,
itemsPerPage: 8
}) %}
<div class="product-grid">
{% for product in products %}
<twig:Molecules:ProductCard:Base :product="product" />
{% endfor %}
</div>
</section>
{% endblock %}