Symfony Live Components and UX 3.0: Reactive Apps Without JavaScript in 2026
Build reactive, real-time interfaces with Symfony Live Components and UX 3.0 — no JavaScript framework required. Complete tutorial with LiveProp, LiveAction, form handling, deferred loading, and URL binding.

Symfony Live Components deliver reactive, real-time user interfaces entirely in PHP and Twig. Released in April 2026, Symfony UX 3.0 removes all 2.x deprecations, raises the minimum to PHP 8.4 and Symfony 7.4, and trims four obsolete packages — producing the leanest UX toolkit to date.
Live Components turn any Twig component into a stateful, interactive element that re-renders server-side on user interaction — without writing JavaScript. Data binding, actions, validation, and form handling all happen in PHP.
Setting Up Symfony UX 3.0 with Live Components
The installation requires Symfony 7.4+, PHP 8.4+, and either AssetMapper or Webpack Encore for the frontend assets.
# terminal
composer require symfony/ux-live-component
# With AssetMapper (recommended for Symfony 7.4+)
php bin/console importmap:require @symfony/ux-live-component
# Verify installation
php bin/console debug:twig --filter=componentAssetMapper handles JavaScript delivery without Node.js, Webpack, or any build step. The Stimulus controller that powers Live Components loads automatically.
Creating a Reactive Search Component with LiveProp
A search bar that filters results as the user types — zero JavaScript. The #[LiveProp] attribute marks properties as stateful, persisted across re-renders through an encrypted URL-safe token.
<?php
// src/Twig/Components/ProductSearch.php
namespace App\Twig\Components;
use App\Repository\ProductRepository;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class ProductSearch
{
use DefaultActionTrait;
// writable: true allows the frontend to modify this property
// url: true syncs the value with ?query= in the address bar
#[LiveProp(writable: true, url: true)]
public string $query = '';
#[LiveProp(writable: true)]
public string $category = 'all';
public function __construct(
private ProductRepository $productRepository,
) {
}
public function getProducts(): array
{
if (strlen($this->query) < 2) {
return [];
}
return $this->productRepository->search(
query: $this->query,
category: $this->category === 'all' ? null : $this->category,
limit: 20,
);
}
}The corresponding Twig template binds inputs to properties with data-model.
{# templates/components/ProductSearch.html.twig #}
<div {{ attributes }}>
<input
type="search"
data-model="debounce(300)|query"
placeholder="Search products..."
class="form-input w-full"
/>
<select data-model="category" class="form-select mt-2">
<option value="all">All categories</option>
<option value="electronics">Electronics</option>
<option value="books">Books</option>
</select>
<div class="mt-4">
{% for product in this.products %}
<div key="{{ product.id }}" class="border-b py-2">
<h3>{{ product.name }}</h3>
<span>{{ product.price|format_currency('EUR') }}</span>
</div>
{% else %}
{% if query|length >= 2 %}
<p>No results for "{{ query }}".</p>
{% endif %}
{% endfor %}
</div>
</div>The debounce(300) modifier waits 300ms after the last keystroke before sending the request. The key attribute on list items enables efficient DOM morphing — only changed elements are updated.
Setting url: true on a LiveProp syncs the property value with the browser URL as a query parameter. Bookmarkable, shareable search results — with one attribute.
Handling User Actions with LiveAction
Beyond data binding, #[LiveAction] exposes PHP methods as callable actions from the frontend. Arguments pass through #[LiveArg].
<?php
// src/Twig/Components/ShoppingCart.php
namespace App\Twig\Components;
use App\Entity\CartItem;
use App\Service\CartService;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentToolsTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class ShoppingCart
{
use DefaultActionTrait;
use ComponentToolsTrait;
#[LiveProp]
public array $items = [];
public function __construct(
private CartService $cartService,
) {
}
#[LiveAction]
public function addItem(#[LiveArg] int $productId, #[LiveArg] int $quantity = 1): void
{
$this->cartService->add($productId, $quantity);
$this->items = $this->cartService->getItems();
// Notify other components on the page
$this->emit('cart:updated', [
'count' => count($this->items),
]);
}
#[LiveAction]
public function removeItem(#[LiveArg] int $itemId): void
{
$this->cartService->remove($itemId);
$this->items = $this->cartService->getItems();
$this->emit('cart:updated', ['count' => count($this->items)]);
}
public function getTotal(): float
{
return array_sum(array_map(
fn(CartItem $item) => $item->getPrice() * $item->getQuantity(),
$this->items,
));
}
}The template triggers actions with the live_action() helper or data-action attributes.
{# templates/components/ShoppingCart.html.twig #}
<div {{ attributes }}>
<h2>Cart ({{ items|length }})</h2>
{% for item in items %}
<div key="{{ item.id }}" class="flex justify-between py-2">
<span>{{ item.name }} x{{ item.quantity }}</span>
<span>{{ (item.price * item.quantity)|format_currency('EUR') }}</span>
<button {{ live_action('removeItem', { itemId: item.id }) }}
class="text-red-600">
Remove
</button>
</div>
{% endfor %}
<div class="border-t pt-2 font-bold">
Total: {{ this.total|format_currency('EUR') }}
</div>
</div>The emit() method broadcasts events to other Live Components on the page — a cart badge component, for instance, can listen with #[LiveListener('cart:updated')] and update its count without a full page reload.
Ready to ace your Symfony interviews?
Practice with our interactive simulators, flashcards, and technical tests.
Real-Time Form Validation Without JavaScript
Live Components integrate directly with Symfony Forms. The ComponentWithFormTrait connects form state, validation errors, and submission to the component lifecycle.
<?php
// src/Twig/Components/RegistrationForm.php
namespace App\Twig\Components;
use App\Entity\User;
use App\Form\RegistrationType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class RegistrationForm extends AbstractController
{
use DefaultActionTrait;
use ComponentWithFormTrait;
#[LiveProp]
public ?User $initialFormData = null;
protected function instantiateForm(): FormInterface
{
return $this->createForm(RegistrationType::class, $this->initialFormData ?? new User());
}
#[LiveAction]
public function save(EntityManagerInterface $em): mixed
{
// Submits + validates — re-renders with errors if invalid
$this->submitForm();
$user = $this->getForm()->getData();
$em->persist($user);
$em->flush();
$this->addFlash('success', 'Account created.');
return $this->redirectToRoute('app_login');
}
}{# templates/components/RegistrationForm.html.twig #}
<div {{ attributes }}>
{{ form_start(form, {
attr: {
'data-action': 'live#action:prevent',
'data-live-action-param': 'save'
}
}) }}
{{ form_row(form.email) }}
{{ form_row(form.plainPassword, { label: 'Password' }) }}
{{ form_row(form.fullName) }}
<button type="submit"
class="btn-primary w-full"
data-loading="addAttribute(disabled) addClass(opacity-50)">
<span data-loading="hide">Create Account</span>
<span data-loading="show" class="animate-spin">Loading...</span>
</button>
{{ form_end(form) }}
</div>Validation errors appear inline as the user moves between fields — the on(change) modifier on data-model triggers re-render on blur, displaying Symfony Validator constraint messages instantly.
Deferred and Lazy Loading for Performance
Heavy components — dashboards, analytics charts, long lists — benefit from deferred rendering. Instead of blocking the initial page load, the component renders a placeholder and fetches content via AJAX after the page is ready.
<?php
// src/Twig/Components/AnalyticsDashboard.php
namespace App\Twig\Components;
use App\Service\AnalyticsService;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class AnalyticsDashboard
{
use DefaultActionTrait;
#[LiveProp]
public string $period = '30d';
public function __construct(
private AnalyticsService $analytics,
) {
}
public function getMetrics(): array
{
// Expensive query — runs only after page load
return $this->analytics->getMetrics($this->period);
}
}{# Parent page: load the dashboard after the page renders #}
<twig:AnalyticsDashboard loading="defer" period="30d" />
{# Or load when scrolled into view #}
<twig:AnalyticsDashboard loading="lazy" period="30d" />The placeholder macro inside the component template defines what users see while the real content loads.
{# templates/components/AnalyticsDashboard.html.twig #}
<div {{ attributes }}>
<h2>Analytics — {{ period }}</h2>
{% for metric in this.metrics %}
<div class="stat-card">
<span class="stat-label">{{ metric.label }}</span>
<span class="stat-value">{{ metric.value|number_format }}</span>
</div>
{% endfor %}
</div>
{% macro placeholder(props) %}
<div class="animate-pulse space-y-4">
<div class="h-6 bg-gray-200 rounded w-1/3"></div>
<div class="h-20 bg-gray-200 rounded"></div>
<div class="h-20 bg-gray-200 rounded"></div>
</div>
{% endmacro %}The loading="defer" mode fires an AJAX request immediately on page load. The loading="lazy" mode uses IntersectionObserver — the request fires only when the component scrolls into the viewport. Both approaches keep the initial page response fast.
Symfony UX 3.0 replaces CSRF tokens with same-origin/CORS protection for Live Components. The csrf argument on #[AsLiveComponent] no longer exists. Ensure the server enforces same-origin policies.
Component Communication with Events
Live Components communicate through an event system. A child component emits an event; a parent (or sibling) listens and reacts.
<?php
// src/Twig/Components/CartBadge.php
namespace App\Twig\Components;
use App\Service\CartService;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class CartBadge
{
use DefaultActionTrait;
#[LiveProp]
public int $count = 0;
public function __construct(CartService $cartService)
{
$this->count = $cartService->getItemCount();
}
// Automatically re-renders when ShoppingCart emits 'cart:updated'
#[LiveListener('cart:updated')]
public function onCartUpdated(#[LiveArg] int $count): void
{
$this->count = $count;
}
}This pattern keeps components decoupled. The ShoppingCart component does not reference CartBadge directly — the event bus handles the connection.
What Changed in Symfony UX 3.0
UX 3.0 is a cleanup release. Applications running without deprecation warnings on 2.x upgrade with minimal friction.
| Change | Before (2.x) | After (3.0) |
|--------|-------------|-------------|
| CSRF protection | csrf: true on #[AsLiveComponent] | Same-origin/CORS (automatic) |
| Twig CVA function | cva() | html_cva() from twig/html-extra 3.12+ |
| Component defaults config | Optional | twig_component.defaults mandatory |
| Removed packages | Swup, LazyImage, Typed, TogglePassword | Use native APIs or UX Toolkit |
| PHP requirement | 8.1+ | 8.4+ |
| Symfony requirement | 6.4+ | 7.4+ |
The UX Toolkit, introduced during the 2.x cycle, provides pre-built UI components (Button, Dialog, Card, Table, Pagination) styled with Shadcn UI or Flowbite 4.0 — covering the gap left by removed packages.
Preparing Symfony interview questions? Understanding Live Components and the event system is increasingly relevant for senior-level positions. The Symfony interview questions guide covers foundational topics that pair well with this hands-on knowledge.
Conclusion
- Live Components eliminate the need for a JavaScript framework in most CRUD-heavy Symfony applications. Data binding, actions, validation, and form handling stay in PHP and Twig
#[LiveProp(writable: true, url: true)]creates bookmarkable, shareable stateful interfaces with one attribute- Deferred and lazy loading (
loading="defer"/loading="lazy") keep initial page loads fast while heavy components render asynchronously - UX 3.0 drops CSRF tokens in favor of same-origin/CORS protection — simpler security with fewer moving parts
- The event system (
emit/#[LiveListener]) enables decoupled component communication without global state management - For APIs, complement Live Components with API Platform on Symfony 7 for backend-heavy architectures
Start practicing!
Test your knowledge with our interview simulators and technical tests.
Tags
Share
Related articles

Doctrine ORM: Mastering Relationships in Symfony
Complete guide to Doctrine ORM relationships in Symfony. OneToMany, ManyToMany, loading strategies, and performance optimization with practical examples.

Symfony Interview Questions: Top 25 in 2026
The 25 most asked Symfony interview questions. Architecture, Doctrine ORM, services, security, forms, and testing with detailed answers and code examples.

Symfony 7: API Platform and Best Practices
Complete guide to building professional REST APIs with Symfony 7 and API Platform 4. State Providers, Processors, validation, and serialization explained.