PHPers Bydgoszcz #9 | 16.09.2026

Symfony UX

Nowoczesny UX bez drugiego frontendu

Shift+L
jasny motyw
F
pełny ekran
S
notatki
R
przeładuj demo
Esc
przegląd slajdów
O mnie

Paweł Cierzniakowski

Accesto Accesto
Paweł Cierzniakowski
Znacie to?

"Dodajmy interaktywne (...)"

Backendencje, walidacja, uprawnienia
JSON
Nowe APIserializacja, wersjonowanie, CORS, auth tokeny
fetch
SPAReact, stan, router, walidacja...

Jedna funkcja, która wymusza:

  • dwa stosy technologiczne,
  • dwa buildy,
  • dwa repozytoria,
  • synchronizację między nimi.
Symfony UX

HTML z serwera, interaktywność z gotowych klocków.

ux.symfony.com/packages
  • Icons
  • Map
  • Twig Components
  • Live Components
  • Turbo
  • Stimulus
  • Toolkit
  • Native
  • Autocomplete
  • Translator
  • Chart.js
  • React
  • Vue.js
  • Image Cropper
  • Stylized Dropzone
  • Notify
  • CalendarLink
  • Svelte
  • Lazy Image
  • Swup Integration
  • Toggle Password
  • Typed
01

Fundament

Stimulus

JavaScript przypięty do HTML-a, nie odwrotnie

AssetMapper

Frontend bez Node'a, bez bundlera, bez node_modules

Importmapy w przeglądarce

Natywne ES modules. Paczki z npm pobierane raz do assets/vendor.

Wersjonowanie w prod

asset-map:compile -> pliki z hashem, preload zależności.
AssetMapper | controllers.json

Kontrolery z paczek UX: co ładować od razu, a co na żądanie

Samodzielna rejestracja komponentów

Po composer require paczka UX dopisuje swój kontroler, a jej biblioteki (np. Tom Select) trafiają do importmapy.

"fetch": "eager"

Kontroler ładuje się od razu, na każdej stronie (tu: Live i Turbo).

"fetch": "lazy"

Kontroler doładuje się dopiero tam, gdzie pojawi się na stronie. Razem z nim Chart.js czy Tom Select.
Turbo

Frames podmieniają fragment, Streams dokładają zmiany

Turbo | kod

Zwykłe linki, zwykły formularz, zwykły Twig

02

Twig Components

Twig Components | anonimowe

Sam szablon, zero PHP

<twig:Alert
  type="warning"
  title="Nic nie znaleziono"
  id="empty"
>
  Spróbuj "kawa" albo "książka".
</twig:Alert>
                    

{% props %}

Propsy z wartościami domyślnymi. Plik w templates/components i już jest komponent.

attributes

Wszystko spoza propsów (tu: id) ląduje w HTML, a class doklei się do domyślnej.

Treść między tagami

Trafia do bloku content.
Twig Components | klasa PHP

Logika w PHP, szablon tylko wyświetla

<twig:ProductCard :product="product">
  <twig:block name="actions">
    <button>Do koszyka</button>
  </twig:block>
</twig:ProductCard>
Twig Components | demo

Propsy, bloki, logika w PHP, attributes w HTML

03

Live Components

Jak to działa

Stan w HTML-u, logika w PHP, zmiany przez morphing DOM

1. RenderTwig renderuje komponent, propsy (z sumą kontrolną) lądują w atrybucie HTML
2. Interakcjadata-model albo data-action: Stimulus wysyła propsy + zmiany
3. Serwerodtwarza obiekt, woła akcję, renderuje ten sam szablon
4. Morphprzeglądarka podmienia tylko to, co się zmieniło (fokus i kursor zostają)

Bez API. Bez stanu po stronie JS.
Bez sesji: każdy request niesie cały stan.

Live | wyszukiwarka

Cała "aplikacja" to klasa i szablon

Live | wyszukiwarka | demo

Wyszukiwarka

Live | wyszukiwarka | data-model

Kiedy wysłać request? Modyfikatory data-model

<input data-model="on(change)|debounce(300)|min_length(2)|query">
Modyfikator Co robi Kiedy
debounce(300) request dopiero po przerwie w pisaniu (domyślnie 150 ms) wyszukiwarka, każde pole tekstowe
on(change) aktualizacja po opuszczeniu pola, nie przy każdym klawiszu pola z walidacją, daty, kwoty
norender zapisuje wartość w JS, request poleci dopiero z akcją formularz z przyciskiem "Zapisz"
min_length(2) / max_length(50) brak requestu, gdy tekst za krótki lub za długi nie szukaj po jednej literze
min_value(0) / max_value(1000) brak requestu, gdy liczba poza zakresem ceny, ilości
Modyfikatory łączysz przez |, nazwa modelu na końcu. Nieznany modyfikator to błąd w konsoli, nie ciche zignorowanie.
Live | formularze

Sf/UX + Sf/Validator = Walidacja na żywo

Live | formularze | demo

Błędy pojawiają się po opuszczeniu pola

  • walidacja tylko dotkniętych pól
  • ta sama walidacja przy zapisie: submitForm()
  • data-loading blokuje przycisk
  • zero JS do napisania
Live | rodzic i dziecko

Dziecko emituje w górę, rodzic nasłuchuje

Live | rodzic i dziecko | demo

Klik w "+" w wierszu przerysowuje sumę koszyka

  • emitUp('cart:quantity-changed')
  • #[LiveListener] + #[LiveArg]
  • kosz przy 1 szt. usuwa wiersz
Architektura wysp

Statyczna strona, niezależne wyspy, komunikacja tylko eventami

szkielet strony: zwykły Twig (nie przerysowuje się)
FilterIslandprzyciski: zakres, seria
emit()
chart:filter-changed
ChartIsland#[LiveListener]
buduje Chart (ux-chartjs)

Jeden wielki komponent

każda zmiana przerysowuje wszystko i ciągnie cały stan w każdym requeście

Wyspy

małe propsy, tanie re-rendery, każdą wyspę da się przetestować i przenieść osobno
Dwie wyspy | kod

Wyspy nic o sobie nie wiedzą, łączy je nazwa eventu

Dwie wyspy | demo

Włącz "pokaż granice wysp" i patrz, co błyska

Live | UX w detalach

Stany ładowania, debounce, polling: deklaratywnie w HTML-u

action(generate)|... tylko dla tej akcji
model(title)|show tylko dla tego pola
addClass, addAttribute(disabled), delay(300)
Live | UX w detalach | demo

Generowanie trwa 1,2 s i od razu to widać

Live | lazy i defer

Wolny komponent nie blokuje strony

  • defer: request po załadowaniu
  • lazy: gdy wjedzie w viewport
Testy

Komponent testujesz jak zwykłą klasę: bez przeglądarki

04

Ekosystem

UX Icons

200 000+ ikon z Iconify, inline SVG

{{ ux_icon('lucide:rocket', {class: 'icon'}) }}

{# dev: pobiera na żądanie #}
{# prod: bin/console ux:icons:lock #}

Ikony zablokowane w assets/icons: działa offline, zero requestów.

UX Autocomplete

Tom Select jedną opcją

Encje Doctrine: opcje dociągane AJAX-em w trakcie pisania, nie z HTML-a.

#[AsEntityAutocompleteField]
final class ProductField extends AbstractType
{
    public function configureOptions(OptionsResolver $r): void
    {
        $r->setDefaults([
            'class' => Product::class,
            'searchable_fields' => ['name'], // LIKE po stronie serwera
        ]);
    }
}
UX Chart.js

Wykres budowany w PHP

$chart = $chartBuilder
    ->createChart(Chart::TYPE_DOUGHNUT)
    ->setData(['labels' => ..., 'datasets' => ...])
    ->setOptions(['cutout' => '62%']);
{{ render_chart(chart) }}

W Live Component wykres sam się aktualizuje po re-renderze, co widzieliście na wyspach.

UX Map

Leaflet albo Google Maps, API w PHP

$map = (new Map())
    ->center(new Point(52.0, 19.2))
    ->zoom(5.6)
    ->addMarker(new Marker(
        position: new Point(53.12, 18.01),
        title: 'PHPers Bydgoszcz',
        infoWindow: new InfoWindow('Jesteśmy tutaj!', opened: true),
    ));

Tu bez kafelków (tileLayer(false)), bo prezentacja działa offline.

05

A React?

Porównanie
React (SPA) Next.js Symfony UX Live
Renderowanie przeglądarka (CSR) serwer (SSR/RSC) + hydracja serwer (Twig) + morphing DOM
Osobne API tak: REST/GraphQL nie zawsze, ale backend w JS albo BFF przed PHP nie: komponent to PHP
Języki TS + język backendu TS (+ PHP, jeśli już jest) PHP + Twig
Stan klient klient + serwer serwer (LiveProp w HTML)
Walidacja podwójna zwykle podwójna raz: Symfony Validator
Toolchain Node + bundler Node + serwer Next AssetMapper, bez Node
JS (min+gzip) React : + Twój kod więcej niż React: runtime frameworka Live: (cała strona demo )
Każda interakcja lokalnie, natychmiast lokalnie albo serwer request do serwera (latencja)
Najlepsze do bogatych, offline'owych UI treści + aplikacji, SEO CRUD-ów, paneli, formularzy, zespołów PHP
Rozmiary zmierzone przy buildzie tej prezentacji (esbuild minify + gzip -9). React: react + react-dom/client + scheduler, bez routera, zarządzania danymi i kodu aplikacji. Next.js niemierzony: jego runtime zależy od aplikacji.
Uczciwie

Kiedy nie Live Components

Offline-first

Każda interakcja to request. Bez sieci nie ma serwera... zwykle 😉

60 fps

Drag & drop, edytory, gry, canvas: to robota dla JS (Stimulus + biblioteka).

Duża latencja

Użytkownicy daleko od serwera czują każdy klik. debounce i data-loading pomagają, ale nie zmienią fizyki.

Ogromny stan

Propsy jadą w każdym requeście. Trzymaj ID, nie całe kolekcje (albo wyspy).

Osobny zespół frontu

Jeśli już macie dojrzałe SPA i API, migracja dla samej migracji nie ma sensu.

Mieszaj

Live dla 90% ekranów, Stimulus + biblioteka JS dla pozostałych 10%.
AI i Symfony UX

AI pisze to zaskakująco dobrze

Konwencje, nie wymysły

#[AsLiveComponent], <twig:...>, data-*. Model nie musi zgadywać architektury.

Świetna dokumentacja

Dokumentacja Symfony UX i handbook Stimulusa: krótkie, z działającymi przykładami.

Mały kontekst

Komponent to klasa PHP i szablon. Bez API, DTO i stanu po drugiej stronie.

Da się to sprawdzić

Testy InteractsWithLiveComponents i debug:twig-component: agent sam zweryfikuje swój kod.

AGENTS.md w szkielecie

Recepta FrameworkBundle 8.1 dodaje instrukcje dla agentów do nowego projektu.

Ale...

API zmieniało się między wersjami. Daj agentowi aktualną dokumentację (np. Context7), nie pamięć modelu.
Jeszcze jedno...

Żadne demo nie dotknęło serwera

Gdzie
Twoja przeglądarka
PHP
-
SAPI
-
Architektura
-
Start
-
iframe z dememfetch /app/_components/...
Service Workerprzechwytuje request, dokłada nagłówki jako $_SERVER
PHP 8.5 w WebAssemblyphp-cgi + opcache, Symfony 8.1 + UX 3.4 z zipa w pamięci
Dzięki!

Pytania?

symfony.com/bundles/ux-live-component ux.symfony.com
QR: https://forms.gle/zu3VbwfZ6A6EiFFY7

Sprawdzamy co blokuje ludzi przed upgrade PHP
forms.gle/zu3VbwfZ6A6EiFFY7