Front-Office Forms
Thelia 3 builds front-office forms with Symfony Forms, usually wrapped in a LiveComponent for interactive validation and submission. Instead of building forms inline, Flexy resolves predefined Thelia forms by name through the FormServiceInterface.
For the underlying form component, see symfony.com/doc/current/forms.html. For the LiveComponent integration, see symfony.com/bundles/ux-live-component/current/index.html.
How Thelia forms are resolved
Thelia ships ready-made forms (login, registration, address, cart, contact, coupon, etc.). You do not rebuild them: you ask the form service for one by name and get a fully configured Symfony\Component\Form\Form back.
// core/lib/Thelia/Core/Form/FormServiceInterface.php
namespace Thelia\Core\Form;
use Symfony\Component\Form\Form;
interface FormServiceInterface
{
public function getFormByName(?string $name, array $data = []): Form;
}
$nameis a Thelia form name (a service-name string, see the table below).$datais the optional default data array used to pre-fill the form.
Inject the interface (not a concrete implementation) into your component or controller:
use Thelia\Core\Form\FormServiceInterface;
public function __construct(
private readonly FormServiceInterface $formService,
) {
}
Inject Thelia\Core\Form\FormServiceInterface. A no-op default implementation is registered in the core, so the container still builds when no form renderer module (such as TwigEngine) is active. The real renderer is provided by that module at runtime.
A LiveComponent form
The Flexy theme exposes its forms as LiveComponents. A good example is AccountCustomerUpdate, the "edit my profile" component:
// templates/frontOffice/flexy/src/UiComponents/AccountCustomerUpdate/AccountCustomerUpdate.php
namespace FlexyBundle\UiComponents\AccountCustomerUpdate;
use FlexyBundle\Form\CustomerUpdateForm;
use Symfony\Component\Form\FormInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Thelia\Controller\Front\BaseFrontController;
use Thelia\Core\Form\FormServiceInterface;
#[AsLiveComponent(
name: 'Flexy:AccountCustomerUpdate',
template: '@UiComponents/AccountCustomerUpdate/AccountCustomerUpdate.html.twig'
)]
class AccountCustomerUpdate extends BaseFrontController
{
use ComponentWithFormTrait;
use DefaultActionTrait;
#[LiveProp]
public ?array $customer = null;
public function __construct(
private readonly FormServiceInterface $formService,
) {
}
protected function instantiateForm(): FormInterface
{
return $this->formService->getFormByName(CustomerUpdateForm::FORM_NAME, $this->customer ?? []);
}
}
Three pieces make this a form component:
#[AsLiveComponent]registers it and binds it to a Twig template.ComponentWithFormTraitwires the Symfony form lifecycle (rendering, hydration, submission) and requires you to implementinstantiateForm().DefaultActionTraitprovides the default re-render action triggered by LiveProp changes.
instantiateForm() returns the form built by $this->formService->getFormByName(...). Here the name comes from a Flexy form class constant (CustomerUpdateForm::FORM_NAME), but it can equally be a FrontForm constant.
extends BaseFrontControllerFlexy components extend Thelia\Controller\Front\BaseFrontController. That base class exposes container helpers (a service-locator style). It is convenient, but it hides dependencies, so treat it as an anti-pattern. For your own components, prefer plain constructor injection of the exact services you need, as shown above with FormServiceInterface. Extending BaseFrontController is documented here only because it is what the current Flexy theme does.
Handling submission with a LiveAction
Add a #[LiveAction] method that calls submitForm() then checks validity. The PromoCodeForm checkout component shows the pattern:
// templates/frontOffice/flexy/src/UiComponents/Checkout/PromoCodeForm/PromoCodeForm.php
namespace FlexyBundle\UiComponents\Checkout\PromoCodeForm;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Thelia\Controller\Front\BaseFrontController;
use Thelia\Core\Event\Coupon\CouponConsumeEvent;
use Thelia\Core\Event\TheliaEvents;
use Thelia\Core\Form\FormServiceInterface;
use Thelia\Domain\Cart\CartFacade;
use Thelia\Form\CouponCode;
#[AsLiveComponent(
name: 'Flexy:Checkout:PromoCodeForm',
template: '@UiComponents/Checkout/PromoCodeForm/PromoCodeForm.html.twig'
)]
class PromoCodeForm extends BaseFrontController
{
use ComponentWithFormTrait;
use DefaultActionTrait;
public function __construct(
private readonly FormServiceInterface $formService,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly CartFacade $cartFacade,
) {
}
protected function instantiateForm(): FormInterface
{
return $this->formService->getFormByName(CouponCode::getName());
}
#[LiveAction]
public function save(): void
{
$this->submitForm();
$couponCode = $this->getForm()->get('coupon-code')->getData();
$this->eventDispatcher->dispatch(
new CouponConsumeEvent($couponCode),
TheliaEvents::COUPON_CONSUME,
);
$this->cartFacade->recalculatePostage($this->cartFacade->getOrCreateFromSession());
}
}
The flow follows Thelia's event-driven model: the component never persists data itself. It dispatches an event (CouponConsumeEvent) and an Action listener does the work.
To guard against invalid input, check getForm()->isValid() after submitForm():
#[LiveAction]
public function save(): void
{
$this->submitForm();
if (!$this->getForm()->isValid()) {
return; // Validation errors are rendered automatically by the form theme
}
// Dispatch your event with the valid data
}
Template
{# templates/frontOffice/flexy/src/UiComponents/.../SomeForm.html.twig #}
<div {{ attributes }}>
{{ form_start(form) }}
{{ form_widget(form) }}
<button
type="submit"
data-action="live#action:prevent"
data-live-action-param="save"
>Submit</button>
{{ form_end(form) }}
</div>
Available Thelia form names
Thelia\Form\Definition\FrontForm lists the core front-office forms. The constant values are service-name strings (for example FrontForm::CART_ADD resolves to 'thelia.cart.add'); pass the constant, not the literal, to getFormByName().
| Constant | Value | Purpose |
|---|---|---|
FrontForm::CUSTOMER_LOGIN | thelia.front.customer.login | Customer login |
FrontForm::CUSTOMER_CREATE | thelia.front.customer.create | Customer registration |
FrontForm::CUSTOMER_PROFILE_UPDATE | thelia.front.customer.profile.update | Profile update |
FrontForm::CUSTOMER_PASSWORD_UPDATE | thelia.front.customer.password.update | Password change |
FrontForm::CUSTOMER_LOST_PASSWORD | thelia.front.customer.lostpassword | Password reset request |
FrontForm::ADDRESS_CREATE | thelia.front.address.create | Create address |
FrontForm::ADDRESS_UPDATE | thelia.front.address.update | Update address |
FrontForm::CART_ADD | thelia.cart.add | Add to cart |
FrontForm::COUPON_CONSUME | thelia.order.coupon | Apply a coupon |
FrontForm::ORDER_DELIVER | thelia.order.delivery | Choose delivery |
FrontForm::ORDER_PAYMENT | thelia.order.payment | Choose payment |
FrontForm::CONTACT | thelia.front.contact | Contact form |
FrontForm::NEWSLETTER | thelia.front.newsletter | Newsletter subscription |
Pre-filling a form
Pass defaults through the second getFormByName() argument:
protected function instantiateForm(): FormInterface
{
return $this->formService->getFormByName(FrontForm::CART_ADD, [
'product' => $this->product['id'],
'product_sale_elements_id' => $this->currentPse['id'],
'quantity' => 1,
'append' => 1,
'newness' => 0,
]);
}
Real-time validation
LiveComponents can validate fields as the user types, using data-model bindings:
{# Validate on change #}
{{ form_widget(form.email, {
attr: {'data-model': 'on(change)|email'}
}) }}
{# Debounced validation #}
{{ form_widget(form.username, {
attr: {'data-model': 'debounce(500)|username'}
}) }}
Flexy form theme
The Flexy theme registers its form theme globally, so widgets are styled automatically. You do not need a {% form_theme %} tag in each template. This is configured in the bundle's config/packages/twig.yaml:
# templates/frontOffice/flexy/config/packages/twig.yaml
twig:
paths:
"%kernel.project_dir%/templates/frontOffice/%thelia_front_template%/form": formTwig
form_themes:
- "frontOffice/%thelia_front_template%/form/flexy_form_theme.html.twig"
The formTwig Twig namespace points at the theme's form/ directory, which is why partials reference the theme as @formTwig/flexy_form_theme.html.twig:
{% use '@formTwig/flexy_form_theme.html.twig' %}
Because the theme is applied via form_themes in twig.yaml, every front-office form is rendered with the Flexy widgets by default. Only add an explicit {% form_theme form '@formTwig/flexy_form_theme.html.twig' %} when you render a form in a context where the global theme does not apply.
Plain Symfony fallback
If you need a one-off form that has no Thelia definition, you can build it inline with Symfony's createFormBuilder(). This is standard Symfony, not the Thelia way. Prefer a named Thelia form whenever one exists:
createFormBuilder() requires AbstractControllercreateFormBuilder() is a helper provided by Symfony's Symfony\Bundle\FrameworkBundle\Controller\AbstractController. Thelia's BaseFrontController (used by the components above) does not extend it, so the call below only works in a component that extends AbstractController, as the Flexy CategoryFilters component does. Otherwise, inject Symfony\Component\Form\FormFactoryInterface and call $this->formFactory->createBuilder().
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormInterface;
protected function instantiateForm(): FormInterface
{
// Plain Symfony fallback - only when no Thelia FrontForm fits
return $this->createFormBuilder()
->add('name', TextType::class)
->add('email', EmailType::class)
->add('message', TextareaType::class)
->getForm();
}
Best practices
- Use a named Thelia form (
FrontForm::*or a theme form constant) over a hand-built one. - Inject
FormServiceInterfacerather than a concrete service or the container. - Validate server-side: call
submitForm(), then checkgetForm()->isValid()before acting. - Never persist in the component: dispatch a Thelia event and let the Action listener save.
Learn more
- LiveComponents: component lifecycle and LiveProps
- Flexy Theme: theme structure and assets
- Stimulus: JavaScript controllers