symfony/ux-live-component
Symfony UX Live Components let Twig components auto-update on the frontend as users interact, inspired by Livewire and Phoenix LiveView. Build reactive interfaces without writing JavaScript.
Install and wire it up
composer require symfony/ux-live-component
That pulls in symfony/ux-twig-component (Live Components are just Twig components with a hydration layer). Then three pieces of plumbing, all of which bite people who skip them:
config/bundles.php (Flex does this).# config/routes/ux_live_component.yaml
live_component:
resource: '@LiveComponentBundle/config/routes.php'
prefix: /_components
live key in assets/controllers.json (renamed from typed in 2.7) — you do not hand-register it in bootstrap.js anymore.Where to look first
data-model docs page — 80% of day-to-day work is data-model + a lifecycle hook.ComponentWithFormTrait docs — if you're coming from Livewire, this is your wire:model + validation story in one place.First use case: a live search box
#[AsLiveComponent]
final class ProductSearch extends AbstractController
{
use DefaultActionTrait;
#[LiveProp(writable: true)]
public string $query = '';
public function __construct(private ProductRepository $products) {}
public function getProducts(): array
{
return $this->products->search($this->query);
}
}
<div {{ attributes }}>
<input data-model="query" placeholder="Search…">
<ul>
{% for product in this.products %}<li>{{ product.name }}</li>{% endfor %}
</ul>
</div>
Type in the box, the component re-renders over Ajax. Note the {{ attributes }} — forgetting it means no data-controller, no data-live-id, and a component that silently does nothing. That is the single most common "it doesn't work" cause.
data-model is the workhorse. It's the wire:model analogue, with modifiers that matter in production:
<input data-model="on(blur)|query"> {# commit on blur, not every keystroke #}
<input data-model="debounce(300)|query"> {# throttle keystrokes #}
<input data-model="norender|query"> {# update server state, skip the re-render #}
<input data-model="min_length(3)|username"> {# 2.28+: don't even send the request #}
<input data-model="min_value(10)|max_value(100)|quantity">
norender is the escape hatch when an action round-trip is in flight and you don't want churn.
Actions. Use data-action="live#action" with data-live-action-param="save", or the live_action() Twig function added in 2.19. Actions run on the server and return a re-rendered fragment; live#action:prevent covers form submits.
Lifecycle hooks replace controller logic. mount() for initialisation, hydrate()/dehydrate() for state you want to control manually, and — most used — updating($prop) / updated($prop) to react to a single changed prop (reset pagination when query changes, re-validate when email changes).
Forms. ComponentWithFormTrait + a #[LiveProp] holding the FormInterface, instantiateForm() on mount, and submitForm() for programmatic submits. Since 2.8 the ValidatableComponentTrait exposes _errors to the template, which is far nicer than digging into violations yourself. In 3.1 the UnprocessableEntityHttpException from a failed submitForm() carries field paths and messages.
Parent/child composition. A child re-renders in a separate Ajax call after the parent finishes. If you want the child to pick up new prop values from the parent, the child must declare acceptUpdatesFromParent: true on that LiveProp (this became opt-in in 2.8).
Cross-component communication. emit() events between components, plus dispatching real DOM events. In tests, assertComponentEmitEvent($render, 'event')->withData([...])->withDataSubset([...]) (2.27) makes that contract assertable.
Loading states. data-loading="action(save)|show" and data-loading="model(firstName)|show" scope a loading indicator to a specific action or specific model — much better UX than a component-wide spinner. For above-the-fold cost, loading="defer" (on page load) or loading="lazy" (on viewport entry) from 2.17; the old defer attribute is deprecated.
URL binding. UrlMapping on a LiveProp (2.17) reflects component state into the query string; mapPath: true (2.28) maps to the path instead, so /{page} can round-trip through a live component.
JavaScript. All root elements carry __component; use getComponent(element) to get the Component object and its hook system (response:error for custom Ajax error handling). The old live:update-model / live:render events are gone — don't copy tutorials from 2.3 era.
Testing. InteractsWithLiveComponents + createLiveComponent() + ->call('action', [...]) + ->render(). call() accepts files (2.18), and the whole thing runs against the real hydrator, so it catches serialization problems your unit tests won't.
Hydration knobs. useSerializerForHydration: true + serializationContext when a prop is a DTO/collection the serializer must handle (no longer automatic since 2.8), format for DateTime, and modifier: (2.17) to mutate options at runtime — the callable now receives the property name as its second argument (2.26).
The 2.x BC trail will burn you if you follow stale blog posts. Concretely: exposed → writable, serializer hydration became opt-in, child updates became opt-in, morphdom's stable attribute is data-live-id not id, and LiveCollectionType's fields are button_add/button_delete. Check the changelog for the version you actually installed.
Requests are serialized, not parallel. Since 2.5 an in-flight Ajax call blocks queued ones and batches the pending changes. One slow component action stalls every interaction on it. Profile your queries, put debounce on text inputs, and use min_length to avoid firing on the first character.
data-model inputs set their own value. Since 2.5 you should not write value="{{ firstName }}" on a data-model input — it fights the controller. Conversely, if you need a server-controlled value, don't put data-model on it. Checkboxes without a value attribute become booleans (2.8) — convenient, but surprising if you expected a string.
Invalid user data fails silently. Since 2.8, a payload that can't hydrate onto the prop (string into an int) falls back to the last valid value instead of exploding. That's robust, but it means a buggy form looks like "nothing happened" rather than an error. Always validate server-side; don't infer validity from the prop.
Keep LiveProp payloads small. Prop values are dehydrated into the HTML and sent back on every request. Storing whole Doctrine entities is a payload and staleness problem — store IDs and re-fetch. Non-persisted entities get serialized via the serializer, and DoctrineObjectNormalizer's priority was dropped to -100 so your custom normalizers win; use that.
Security posture changed, and it's not opt-out-flavoured. CSRF tokens were removed in 2.22 (and the argument dropped entirely in 3.0) in favour of same-origin/CORS. 3.1 requires X-Requested-With: XMLHttpRequest on the component endpoint and changed the checksum algorithm — users with pages signed before the upgrade must reload, and a CDN/proxy that strips that header will break every component for you. The secret for fingerprints/checksums is configurable (2.23); set it explicitly for multi-instance deploys.
Morphdom will preserve your JS-made DOM changes — the "smart rendering" behaviour from 2.8 — but with limits. Use data-live-id on list items so reordering works, and use data-live-ignore on subtrees that third-party JS owns (maps, editors, charts) so they aren't clobbered.
Debugging. Since 2.5 unexpected Ajax errors surface in a modal, which is great in dev and something you'll want to verify is off in prod. The request itself is a single _batch POST to /_components/... returning an HTML fragment — watch the Network tab, not the Symfony profiler, when the symptom is "the DOM didn't change."
Version floor. 3.x requires Symfony 7.4 and PHP 8.4. Anything older pins you to 2.x and, realistically, a different set of docs.
Extension points worth knowing about: LiveComponentHydrator (custom dehydration, subclass of the DehydratedComponent return type since 2.5), the modifier callable on LiveProp, a per-component route argument on #[AsLiveComponent] if the default endpoint collides with your routing, and the JS hook system on Component. The repository is a read-only sub-tree split — file issues and PRs against symfony/ux, not here.
How can I help you explore Laravel packages today?