thelia / flexy
thelia design system
Package info
github.com/thelia-templates/flexy
Type:thelia-frontoffice-template
pkg:composer/thelia/flexy
Requires
- php: >=8.3 <9.0
- symfony/http-client: ^6.4 || ^7.4
- symfony/stimulus-bundle: ^2.19
- symfony/twig-bundle: ^6.4 || ^7.4
- symfony/ux-icons: ^2.21
- symfony/ux-live-component: ^2.19
- symfony/ux-translator: ^2.19
- symfony/ux-twig-component: ^2.36
- symfonycasts/dynamic-forms: ^0.1.2
- symfonycasts/tailwind-bundle: ^0.11.1
- tales-from-a-dev/twig-tailwind-extra: ^1.0.0
- thelia/core: ^3.2
- thelia/installer: ~1.1
- thelia/page-module: ^2.0
- thelia/seone-module: ^1.1
- thelia/thelia-blocks-module: ^3.0
- thelia/thelia-library-module: ^2.0
- thelia/twig-engine-module: ^1.1
- twig/extra-bundle: ^3.24.0
- twig/html-extra: ^3.24.0
- twig/intl-extra: ^3.24
- twig/string-extra: ^3.24
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
- thelia/thelia-skeleton: <3.2
Replaces
None
- dev-main / 1.3.x-dev
- 1.2.x-dev
- 1.2.1
- 1.2.0
- 1.1.0
- 1.0.3
- 1.0.2
- 1.0.1
- 1.0.0
- 1.0.0-beta10
- 1.0.0-beta9
- 1.0.0-beta8
- 1.0.0-beta7
- 1.0.0-beta6
- 1.0.0-beta5
- 1.0.0-beta4
- 1.0.0-beta3
- 1.0.0-beta2
- 1.0.0-beta1
- 0.2
- 0.1
- dev-perf/product-card-strip-queries
- dev-feat/checkout-summary-hook
- dev-fix/current-currency-everywhere
- dev-fix/order-confirmation-and-free-delivery
- dev-fix/checkout-summary-and-a11y-small-fixes
- dev-feat/orders-page-size
- dev-feat/express-payment-in-checkout
- dev-feat/modal-improvements
This package is auto-updated.
Last update: 2026-10-21 08:04:37 UTC
README
Front-office template for Thelia 3, built on Twig, Symfony UX and Tailwind CSS v4.
Requirements
- Thelia 3.0.0 or later
- PHP 8.3+
Assets are served through Symfony AssetMapper — there is no Node build step, no bundler and no dist/ directory. Tailwind is compiled by symfonycasts/tailwind-bundle, which downloads a standalone binary on first use.
Installation
composer require thelia/flexy
thelia/installer places the template under templates/frontOffice/flexy. Activate it from the back office, or set it in .env:
ACTIVE_FRONT_TEMPLATE=flexy
Layout
| Path | Contents |
|---|---|
*.html.twig |
Pages, at the root. config/views.yaml lists the ones a controller renders, which are not reachable by name |
components/ |
Twig components, grouped by responsibility: Atoms, Molecules, Organisms, Layouts, Forms, Fields |
src/ |
PHP: controllers, services, DTOs, Twig extensions, form types (FlexyBundle\ namespace) |
assets/ |
Styles, icons, images, Stimulus controllers |
form/ |
Form theme — applied explicitly per template, never registered globally |
translations/ |
messages.en_US.yaml, messages.fr_FR.yaml |
docs/ |
Notes on the parts whose behaviour the code alone does not explain |
A component owns its template, its styles and its behaviour in a single directory. Base.php holds the data, Base.html.twig the markup, Base.css the styles, base_controller.js the interactions.
Child templates
A template of the shop that declares <parent>flexy</parent> in its template.xml ships only
what it overrides, and Flexy answers for the rest:
| It ships | What happens |
|---|---|
A root page (product.html.twig) |
Replaces Flexy's. To extend one instead of copying it, {% extends '@theme_flexy/base.html.twig' %}: every template of the chain is registered under @theme_<name>. base.html.twig exposes the favicons and fonts blocks for the two things a shop always replaces |
A component directory (components/Molecules/Button/) |
Replaces Flexy's, anonymous components included; the other components of Flexy stay available |
assets/styles/app.css |
Becomes the Tailwind entry point. Import Flexy's (@import "../../../flexy/assets/styles/app.css"), which carries its @source list, then add your own sources and a @theme block: a token declared there replaces Flexy's |
assets/icons/*.svg |
Added to Flexy's icons; a file of the same name replaces it everywhere ux_icon() asks for that name |
translations/messages.<locale>.yaml |
Loaded after Flexy's: a key it repeats replaces Flexy's |
An importmap.php, Stimulus controllers |
Its own; without them, Flexy's serve |
/toolkit lists the stories of the whole chain, the child's first, and previews them at the
breakpoints of the nearest variables.css. Their statuses come from components/Toolkit/story-statuses.php
(return ['Molecules/Button' => ComponentStatus::READY];, values ready, waiting or hidden):
the nearest template that names a story answers for it, ComponentStatus for the rest.
The project has one setting to check: twig_component.anonymous_template_directory must be
'@Flexy' (config/packages/twig_component.yaml). A project configuration may set a filesystem
path there, which is the nearest components/ directory alone: a child that ships one component
would then lose every anonymous component of Flexy.
debug:twig-component cannot read a namespace in that setting, so Flexy hands it the nearest
components/ directory of the chain instead: the child's anonymous components are listed under
their own name, the ones it inherits under the theme_<name>: prefix only.
The shop setting address_legal_identifiers_required decides whether the SIRET and the VAT number, which appear once a company name is typed, are mandatory. Only the value 0 makes them optional (address and customer information forms; on the guest checkout, only the required marking follows it); by default they are mandatory.
ux:icons:import writes into Flexy's assets/icons/ (the ux_icons.icon_dir Flexy configures,
as <prefix>/<name>.svg), not the child's: move the imported file into the child's
assets/icons/ afterwards.
Extending it
The template declares theme_hook() extension points across its pages — layout.head.top, product.bottom, cart.top and others. A module answers one by implementing Thelia\Core\Hook\Theme\ThemeHookInterface; the tag priority drives the rendering order.
The points are part of the theme's public surface: their names and parameters are not renamed.
| Hook point | Where | Parameters |
|---|---|---|
layout.head.top, layout.head.bottom, layout.head.<view> |
<head> of every page |
breadcrumb (and title, description, og_type on top) |
layout.body.top, layout.body.bottom |
First and last thing of <body> |
— |
layout.header.actions |
Header action row, between the account button and the cart | — |
layout.header.bottom |
Under the header | — |
layout.footer.top |
Above the footer | — |
home.top, home.bottom |
Home page | — |
category.top, category.bottom |
Category page | category |
brand.top, brand.bottom |
Brand page | brand |
product.top, product.details.bottom, product.bottom |
Product page | product |
product.pse.alerts |
Under the variant selector, outside the cart form | pseId, outOfStock, taxedPrice |
product-card.bottom |
Product card of every listing, under the price, outside the card links | productId |
cart.top, cart.bottom |
Cart page (and one-page checkout) | — |
cart.item.bottom |
Each cart line | cartItem |
checkout.top, checkout.bottom |
Every checkout step | — |
checkout.payment.extra |
Under the payment method the buyer chose, after its options | module (id), code, optionChoices |
checkout.summary.bottom |
Order summary of the cart and checkout steps, under the total, in a live component: for a module paying part of the order another way (gift card form, amount paid, amount left to pay) | summary (the figures the summary shows) |
checkout-identify.form.bottom |
Identification step | next_step_url |
order-placed.top, order-placed.bottom |
Order confirmation | — |
login.form.top, login.form.bottom, register.form.top, register.form.bottom |
Login and registration forms | — |
account.top, account.bottom |
Account home | customer |
account-order.top, account-order.bottom |
Order detail | order |
account-order.item.top, account-order.item.bottom |
Each line of an order | order, orderProduct |
account-order-return.top, account-order-return.bottom |
Return request of an order | order |
account-return.top, account-return.bottom |
Return detail | orderReturn |
sitemap.urls |
Inside each <urlset> of the sitemap |
context, lang |
product-card.bottom is rendered once per card, thirty times on a listing page: an answer reads what it needs for the whole page at once rather than one query per product. A control a module adds there sits outside the card's links, so a button does not end up inside an anchor. layout.header.actions takes a header button; the Molecules:HeaderButton component keeps it aligned with the others.
The SEOne module already answers layout.head.top and layout.head.bottom, which is where the title, description, canonical, hreflang and structured data come from.
The head also calls a hook named after the view, layout.head.<view>, for what a single page needs: a module that links a stylesheet on the product page alone answers layout.head.product, one that adds a script to the cart answers layout.head.checkout-cart. The view is the request's _view attribute: the core sets it for the pages it routes (index, product, category, content, folder, brand...), and FlexyController sets it for the pages it renders, from the template name (checkout-cart, account, login...).
Each section of the sitemap calls sitemap.urls inside its <urlset>, with context set to the section (categories, products, content) and lang left empty: a module answers with <url> entries, xhtml:link alternates included. The content section holds nothing else, so the pages a module serves have a place of their own. With the Sitemap module active and more than one language shown, the categories and products are listed once per language, each entry with the xhtml:link alternates of all of them and an x-default on the default language, the codes SEOne prints in the page head; otherwise one entry per element, in the default language. The sitemap is cached (sitemap_ttl, two hours by default): ?flush=1 regenerates a section.
The header, the footer and the terms links name no content: they read the header_links, footer_links and consent.<code> content slots of the core (content_slot() and content_slot_first() in a template). The shop fills them from its settings, and a module can answer them instead through Thelia\Core\Content\Slot\ContentSlotResolverInterface.
Payment options
A payment module offers choices of its own — paying in three instalments, keeping the card for next time — by answering the core's TheliaEvents::MODULE_PAYMENT_GET_OPTIONS event with PaymentModuleOptionGroups. The payment step shows the groups of the method the buyer chose under its card: a group that takes exactly one option (minimumSelectedOptions and maximumSelectedOptions at 1) as radios, any other as boxes.
The order button is a link to /checkout/pay, not a form, so each answer is kept as soon as it is given: in the session, under payment_module_option_choices, as a list of {group, values} entries — the shape of the core's PaymentModuleOptionChoice. Only the options the module offers for the cart are kept, and the list is emptied when the buyer changes method. The module reads it in its pay():
$choices = $request->getSession()->get('payment_module_option_choices', []); // [['group' => 'my_module_instalments', 'values' => ['three']]]
No entry for a group means the buyer picked nothing in it: the module applies its default. What does not fit an option — a saved card to name, a notice — goes in the checkout.payment.extra hook, rendered under the chosen method only. That hook sits in a live component, where a <script> does not run on a re-render: a module that needs a script on the payment step loads it from layout.head.checkout-payment and layout.head.checkout-onepage.
Replacing the product search
The search page, the listing and the suggestions ask FlexyBundle\Service\ProductSearch, which by default matches the API's title filter (word starts, titles only). A module replaces who finds the products by implementing FlexyBundle\Search\ProductSearchEngineInterface: autoconfiguration tags it flexy.product_search_engine, and the engine with the highest tag priority answers.
public function productIds(string $term, string $locale, int $limit): array; // [12, 4, 27], most relevant first
The engine only says which products match. The theme keeps the rest: it drops the products a visitor may not see, paginates, and reads the page through the product API. With no sort chosen the results follow the engine's order; a sort chosen in the selector applies to the matches. An engine returning [] means no result: the theme does not fall back on its own search.
Adding a field to the sign-up and account forms
The sign-up form (flexybundle_form_customer_register_form) and the account form (flexybundle_form_customer_update_form) render every field they hold, a module's included, inside the form and above the submit button. A module adds its field when the form is built, by listening to TheliaEvents::FORM_AFTER_BUILD.'.'.<form name> and calling $event->getForm()->getFormBuilder()->add(...). The theme's controllers only read their own fields: the module reads its value on the event the account is saved through, TheliaEvents::CREATE_CUSTOMER_MINIMAL for the sign-up and TheliaEvents::CUSTOMER_UPDATEPROFILE for the account form.
Front events
The cart dispatches the addPseToCart and removePseFromCart DOM events, {pse, quantity}, that a module's script can listen to on window; they are part of the theme's public surface like the hook points. Which action sends which event, and what quantity counts, is described in docs/stimulus-and-assets.md.
window.addEventListener('addPseToCart', (event) => { console.log(event.detail.pse, event.detail.quantity); });
Listing a module's component in the toolkit
The toolkit (/toolkit, served only while the kernel runs in debug) walks the components/ directories of the template chain and nothing else. A module lists its own components by implementing FlexyBundle\Toolkit\StoryProviderInterface; autoconfiguration tags it, and the tag priority sets the order in the sidebar. Each story names the template the toolkit renders and the file "Show the code" reads:
namespace FlexyExtensionDemo\Toolkit; use FlexyBundle\Toolkit\ComponentStatus; use FlexyBundle\Toolkit\Story; use FlexyBundle\Toolkit\StoryProviderInterface; final readonly class CalloutStoryProvider implements StoryProviderInterface { public function stories(): array { return [ new Story( category: 'Modules', name: 'Flexy extension demo / Callout', twigPath: '@FlexyExtensionDemoModule/toolkit/Callout.html.twig', sourcePath: __DIR__.'/../templates/toolkit/Callout.html.twig', status: ComponentStatus::READY, ), ]; } }
A module's templates/ directory is registered by the core as the @{Code}Module Twig namespace, so the story template needs nothing more. Its status follows the theme's rules (READY, WAITING, HIDDEN drops it), but story-statuses.php does not apply to it. Module stories come after those of the templates, and a story whose slug collides with a template story, the child's or one it inherits, stops the page rather than shadowing it.
Mind the stylesheet: assets/styles/app.css limits the Tailwind scan to the theme's own files, so a utility class used only in a module template is never compiled. Build a module component out of the theme's components and classes.
Giving a checkout step of a module a screen
A step declared through CheckoutStepProviderInterface is served by this theme when its componentName() names a
Twig or Live component: GET /checkout/step/{code} (route checkout_step, code in lower case, digits and
underscores, the code the provider answers) renders checkout-step.html.twig with that component in the frame of the
tunnel, with the previous and next links of the configured order. The step is reachable only once the steps before it are
settled; otherwise the buyer is sent back to the first incomplete step that has a screen. A step that names no component
has no screen: the navigation walks past it, its check still applies at the placement. On a one-page checkout the route
redirects, every step being on the cart page.
Deploying
Check that your web server serves .webmanifest as application/manifest+json. Once the
assets are compiled the manifest is a static file, so its media type comes from the server
and nothing in the theme can set it. Whether a given server maps that extension depends on
its own table, so verify rather than assume:
curl -sI https://example.com/assets/.../site.webmanifest | grep -i content-type
If it answers anything else, map the extension in the server configuration. With nginx, for instance:
types { application/manifest+json webmanifest; }
Development
ddev composer cs-diff # coding standards, dry run ddev composer cs # and fix them ddev composer phpstan-flexy # static analysis ddev composer test:http-flexy # HTTP smoke tests
Run test suites through the composer test:* scripts only. Calling vendor/bin/phpunit directly resolves to the development database instead of the test one.
Pull requests to main go through the merge queue: use "Merge when ready", or gh pr merge <number> without a merge method.
Licence
GPL-3.0-or-later. See LICENSE.