Skip to main content
Version: Thelia 3

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.

Official Documentation

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;
}
  • $name is a Thelia form name (a service-name string, see the table below).
  • $data is 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,
) {
}
note

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.
  • ComponentWithFormTrait wires the Symfony form lifecycle (rendering, hydration, submission) and requires you to implement instantiateForm().
  • DefaultActionTrait provides 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.

Prefer constructor injection over extends BaseFrontController

Flexy 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().

ConstantValuePurpose
FrontForm::CUSTOMER_LOGINthelia.front.customer.loginCustomer login
FrontForm::CUSTOMER_CREATEthelia.front.customer.createCustomer registration
FrontForm::CUSTOMER_PROFILE_UPDATEthelia.front.customer.profile.updateProfile update
FrontForm::CUSTOMER_PASSWORD_UPDATEthelia.front.customer.password.updatePassword change
FrontForm::CUSTOMER_LOST_PASSWORDthelia.front.customer.lostpasswordPassword reset request
FrontForm::ADDRESS_CREATEthelia.front.address.createCreate address
FrontForm::ADDRESS_UPDATEthelia.front.address.updateUpdate address
FrontForm::CART_ADDthelia.cart.addAdd to cart
FrontForm::COUPON_CONSUMEthelia.order.couponApply a coupon
FrontForm::ORDER_DELIVERthelia.order.deliveryChoose delivery
FrontForm::ORDER_PAYMENTthelia.order.paymentChoose payment
FrontForm::CONTACTthelia.front.contactContact form
FrontForm::NEWSLETTERthelia.front.newsletterNewsletter 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' %}
note

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 AbstractController

createFormBuilder() 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 FormServiceInterface rather than a concrete service or the container.
  • Validate server-side: call submitForm(), then check getForm()->isValid() before acting.
  • Never persist in the component: dispatch a Thelia event and let the Action listener save.

Learn more