Zum Inhalt springen

Svelte 5 und Vite in ein Symfony-Projekt integrieren

Veröffentlicht am 24. Feb. 2026 | ca. 5 Min. Lesezeit |

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.

Thomas Wunner

Thomas Wunner

Fachinformatiker für Anwendungsentwicklung mit Ausbildereignungsprüfung und über 14 Jahre Erfahrung im Aufbau skalierbarer Webanwendungen mit Symfony und Shopware. Abseits der Tastatur ist Thomas als Rettungsschwimmer in der Wasserwacht aktiv, legt als DJ auf und erkundet die Umgebung auf dem Motorrad.

Kommentare

Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.