Symfony und Twig sind hervorragend für serverseitig gerenderte Seiten. Aber was, wenn einzelne Komponenten reaktive Interaktivität brauchen — einen Audioplayer mit Wellenform-Visualisierung, ein Buchungsformular mit Live-Verfügbarkeitsprüfung oder eine filtrierbare Songliste? In einem meiner Projekte habe ich genau dafür Svelte 5 mit Vite in ein bestehendes Symfony-Projekt integriert. Hier ist, wie das funktioniert.
Warum Svelte statt Vue oder React?
Svelte kompiliert zur Build-Zeit zu reinem JavaScript — kein virtuelles DOM, kein Framework-Overhead zur Laufzeit. Für eine Website, die primär serverseitig gerendert wird und nur einzelne interaktive Inseln braucht, ist das ideal:
- Kleine Bundle-Size: Kein Framework-Code wird zum Client geschickt
- Kein hydration Problem: Svelte-Komponenten werden in existierende DOM-Elemente gemounted
- TypeScript-Support: Erstklassig seit Svelte 5
Die Architektur: Zwei-Phasen-Build
Der Kern der Integration: SCSS wird separat kompiliert (wie gehabt), und Vite kümmert sich nur um TypeScript + Svelte:
{
"scripts": {
"build": "npm run vite && npm run post-vite",
"vite": "vite build",
"post-vite": "node templates/assets/node/build-scss.js && node templates/assets/node/copy-assets.js && node templates/assets/node/compress.js"
}
}
Phase 1: vite build kompiliert TypeScript und Svelte-Komponenten zu public/assets/js/app.js mit Manifest-Datei für Symfonys Cache-Busting.
Phase 2: Node-Scripts übernehmen SCSS-Kompilierung, Asset-Kopierung und Brotli/Gzip-Komprimierung.
Vite-Konfiguration
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import symfonyPlugin from 'vite-plugin-symfony';
import path from 'path';
export default defineConfig({
plugins: [
symfonyPlugin(),
svelte()
],
root: 'templates/assets/ts/src/',
build: {
outDir: path.resolve(__dirname, 'public/assets/js'),
emptyOutDir: true,
manifest: true,
rollupOptions: {
output: {
entryFileNames: 'app.js',
chunkFileNames: '[name].js',
assetFileNames: '[name].[ext]',
},
input: {
app: path.resolve(
__dirname,
'templates/assets/ts/src/main.ts'
)
}
}
}
});
vite-plugin-symfony ist das Bindeglied: Es generiert eine entrypoints.json, die Symfony lesen kann, um die richtigen Dateinamen mit Content-Hash in Twig-Templates einzusetzen.
Svelte 5 Runes: Das neue Reaktivitäts-Modell
Svelte 5 hat das Reaktivitäts-System grundlegend überarbeitet. Statt der magischen $:-Labels gibt es jetzt explizite Runes:
| Svelte 4 | Svelte 5 | Zweck |
|---|---|---|
export let prop |
let { prop } = $props() |
Props empfangen |
let count = 0 |
let count = $state(0) |
Reaktiver State |
$: doubled = count * 2 |
let doubled = $derived(count * 2) |
Berechnete Werte |
$: { console.log(count) } |
$effect(() => { console.log(count) }) |
Seiteneffekte |
Hier ein konkretes Beispiel aus dem Projekt — eine Songliste mit Genre-Filter:
<script lang="ts">
import { onMount } from 'svelte';
import SongCard from './SongCard.svelte';
interface Song {
slug: string;
title: string;
genre: string;
file: string;
}
let { locale = 'de', showFilters = false }:
{ locale?: string; showFilters?: boolean } = $props();
let songs: Song[] = $state([]);
let loading: boolean = $state(true);
let selectedGenre: string = $state('');
let genres: string[] = $derived(
[...new Set(songs.map((s) => s.genre))].sort()
);
let filteredSongs: Song[] = $derived(
songs.filter((s) => {
if (selectedGenre && s.genre !== selectedGenre) return false;
return true;
})
);
onMount(async () => {
const res = await fetch('/api/songs/' + locale);
songs = await res.json();
loading = false;
});
</script>
Mount statt Konstruktor
In Svelte 5 wird new Component({target}) durch mount() ersetzt. Der Einstiegspunkt (main.ts) zeigt, wie Svelte-Komponenten in bestehende Twig-Templates gemounted werden:
import { mount } from 'svelte';
import SongList from './SongList.svelte';
document.addEventListener('DOMContentLoaded', () => {
const locale = document.documentElement.lang || 'de';
const songListTarget = document.getElementById('song-list-app');
if (songListTarget) {
mount(SongList, {
target: songListTarget,
props: {
locale: songListTarget.dataset.locale || locale,
showFilters: songListTarget.dataset.showFilters === 'true',
},
});
}
});
Im Twig-Template wird nur ein leerer Container mit data-*-Attributen gerendert:
<div id="song-list-app"
data-locale="{{ app.request.locale }}"
data-show-filters="true">
</div>
Hybridansatz: Nicht alles braucht Svelte
Ein wichtiger Punkt: Nicht jede Interaktion braucht ein reaktives Framework. Für einfache DOM-Manipulation reicht reines TypeScript. In meinem Projekt gibt es neben den Svelte-Komponenten auch reine TS-Module:
- consentPlayer.ts: DSGVO-konformes Laden von Mixcloud/YouTube-Embeds nach User-Klick
- bookingFormValidation.ts: Formularvalidierung mit AbortController für Request-Cancellation
// AbortController für Race-Condition-freie Verfügbarkeitsprüfung
let currentAbortController: AbortController | null = null;
function checkAvailability(): void {
if (currentAbortController) {
currentAbortController.abort();
}
currentAbortController = new AbortController();
fetch('/api/check-availability?date=' + dateValue, {
signal: currentAbortController.signal
})
.then((res) => res.json())
.then((data) => { /* UI updaten */ })
.catch((error: Error) => {
if (error.name === 'AbortError') return;
console.error(error);
});
}
Playwright-Tests
Für E2E-Tests setzen wir Playwright ein. Das funktioniert hervorragend mit Svelte-Komponenten, da Playwright auf das reale DOM wartet:
import { test, expect } from '@playwright/test';
test('song cards render after API load', async ({ page }) => {
await page.goto('/');
// Warten auf asynchrones Loading (WaveSurfer initialisiert)
await page.waitForFunction(
() => {
const btn = document.querySelector('.song-card-play-btn');
return btn && !btn.hasAttribute('disabled');
},
{ timeout: 15000 }
);
const cards = page.locator('.song-card');
await expect(cards.first()).toBeVisible();
});
test('only one song plays at a time', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.song-card-play-btn:not([disabled])');
const firstBtn = page.locator('.song-card-play-btn').first();
const secondBtn = page.locator('.song-card-play-btn').nth(1);
const firstVinyl = page.locator('.mini-vinyl').first();
await firstBtn.click();
await expect(firstVinyl).toHaveClass(/spinning/);
await secondBtn.click();
// Erster Song stoppt automatisch via CustomEvent
await expect(firstVinyl).not.toHaveClass(/spinning/);
});
Fazit
Die Kombination Symfony + Svelte 5 + Vite ergibt ein leistungsfähiges Setup: Serverseitiges Rendering für SEO und schnelle Ladezeiten, reaktive Svelte-Inseln für interaktive Komponenten, und Vite für blitzschnelle Builds. Der Zwei-Phasen-Build mag anfangs ungewöhnlich wirken, aber er erlaubt es, das bestehende SCSS-Build unangetastet zu lassen und Svelte schrittweise zu integrieren.
Kommentare
Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.