Zum Inhalt springen

Progressive Web Apps mit Symfony: Service Worker, Offline-Fähigkeit und DSGVO

Veröffentlicht am 15. Dez. 2025 | ca. 2 Min. Lesezeit |

Progressive Web Apps (PWA) sind reguläre Webseiten, die durch spezifische Web-APIs angereichert werden, um App-ähnliche Erlebnisse zu bieten. Der Begriff wurde von Google geprägt. Ein PWA kann auf dem Homescreen installiert werden, offline funktionieren und Push-Benachrichtigungen empfangen — ohne App-Store.

Was macht eine PWA aus?

Drei Kernkomponenten:

  1. HTTPS: Pflicht — Service Worker funktionieren nur über gesicherte Verbindungen
  2. Web App Manifest: JSON-Datei mit App-Metadaten (Name, Icon, Startseite)
  3. Service Worker: JavaScript-Skript, das im Hintergrund läuft und Requests abfängt

Zusätzlich für volle PWA-Fähigkeit:

  • Responsive Design
  • Schnelle Ladezeiten (LCP < 2.5s)
  • Offline-Funktionalität

Web App Manifest

Das Manifest definiert, wie die PWA beim Installieren auf dem Homescreen aussieht:

{
    "name": "Wunner Software",
    "short_name": "WunnerSW",
    "description": "Symfony & Shopware Entwicklung",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#ffffff",
    "theme_color": "#1a365d",
    "lang": "de-DE",
    "icons": [
        {
            "src": "/icons/icon-192.png",
            "sizes": "192x192",
            "type": "image/png",
            "purpose": "any"
        },
        {
            "src": "/icons/icon-512.png",
            "sizes": "512x512",
            "type": "image/png",
            "purpose": "any"
        }
    ],
    "shortcuts": [
        {
            "name": "Blog",
            "url": "/blog",
            "icons": [{"src": "/icons/blog-96.png", "sizes": "96x96"}]
        }
    ]
}

Hinweis: Die Verwendung von "purpose": "maskable any" auf einem einzelnen Icon wird nicht empfohlen. Stattdessen sollten separate Icons für maskable und any bereitgestellt werden, da maskable Icons anders zugeschnitten werden und als universelles Icon zu Darstellungsproblemen führen können.

In Symfony das Manifest ausliefern:

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

class PwaController
{
    #[Route('/manifest.json', name: 'pwa_manifest')]
    public function manifest(): JsonResponse
    {
        return new JsonResponse([
            'name' => 'Wunner Software',
            'short_name' => 'WunnerSW',
            'start_url' => '/',
            'display' => 'standalone',
            'background_color' => '#ffffff',
            'theme_color' => '#1a365d',
            'icons' => [
                ['src' => '/icons/icon-192.png', 'sizes' => '192x192', 'type' => 'image/png'],
                ['src' => '/icons/icon-512.png', 'sizes' => '512x512', 'type' => 'image/png'],
            ],
        ]);
    }
}

Im <head> des Base-Templates:

<link rel="manifest" href="{{ path('pwa_manifest') }}">
<meta name="theme-color" content="#1a365d">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" href="{{ asset('icons/icon-192.png') }}">

Service Worker

Der Service Worker ist ein JavaScript-Skript, das vom Browser im Hintergrund registriert wird. Er kann Network-Requests abfangen und aus einem Cache beantworten.

Registrierung im Frontend

// public/js/register-sw.js
if ('serviceWorker' in navigator) {
    window.addEventListener('load', async () => {
        try {
            const registration = await navigator.serviceWorker.register('/sw.js');
            console.log('Service Worker registriert:', registration.scope);
        } catch (error) {
            console.error('Service Worker Registrierung fehlgeschlagen:', error);
        }
    });
}

Service Worker (sw.js)

// public/sw.js
const CACHE_NAME = 'wunner-sw-v1';
const STATIC_ASSETS = [
    '/',
    '/blog',
    '/css/all.css',
    '/js/app.js',
    '/icons/icon-192.png',
    '/offline.html',
];

// Installation: statische Assets cachen
self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(CACHE_NAME).then((cache) => {
            return cache.addAll(STATIC_ASSETS);
        })
    );
    self.skipWaiting();
});

// Aktivierung: alte Caches bereinigen
self.addEventListener('activate', (event) => {
    event.waitUntil(
        caches.keys().then((cacheNames) =>
            Promise.all(
                cacheNames
                    .filter((name) => name !== CACHE_NAME)
                    .map((name) => caches.delete(name))
            )
        )
    );
    self.clients.claim();
});

// Fetch: Cache-First für statische Assets, Network-First für API
self.addEventListener('fetch', (event) => {
    const { request } = event;
    const url = new URL(request.url);

    // Nur GET-Requests cachen
    if (request.method !== 'GET') return;

    // API-Requests: Network-First
    if (url.pathname.startsWith('/api/')) {
        event.respondWith(networkFirst(request));
        return;
    }

    // Blog-Artikel: Stale-While-Revalidate
    if (url.pathname.startsWith('/blog/')) {
        event.respondWith(staleWhileRevalidate(request));
        return;
    }

    // Statische Assets: Cache-First
    event.respondWith(cacheFirst(request));
});

async function cacheFirst(request) {
    const cached = await caches.match(request);
    if (cached) return cached;

    try {
        const response = await fetch(request);
        const cache = await caches.open(CACHE_NAME);
        cache.put(request, response.clone());
        return response;
    } catch {
        return caches.match('/offline.html');
    }
}

async function networkFirst(request) {
    try {
        const response = await fetch(request);
        const cache = await caches.open(CACHE_NAME);
        cache.put(request, response.clone());
        return response;
    } catch {
        const cached = await caches.match(request);
        return cached || new Response(JSON.stringify({ error: 'Offline' }), {
            headers: { 'Content-Type': 'application/json' },
            status: 503,
        });
    }
}

async function staleWhileRevalidate(request) {
    const cache = await caches.open(CACHE_NAME);
    const cached = await cache.match(request);

    const fetchPromise = fetch(request).then((response) => {
        cache.put(request, response.clone());
        return response;
    });

    return cached || fetchPromise;
}

Offline-Seite

<!-- public/offline.html -->
<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Offline — Wunner Software</title>
    <style>
        body { font-family: system-ui; text-align: center; padding: 4rem 1rem; }
        h1 { color: #1a365d; }
    </style>
</head>
<body>
    <h1>Sie sind offline</h1>
    <p>Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.</p>
    <button onclick="window.location.reload()">Erneut versuchen</button>
</body>
</html>

Cache-Versionierung in Symfony

Wenn Assets aktualisiert werden, muss auch der Service-Worker-Cache aktualisiert werden. Eine Möglichkeit ist, die Cache-Version in Symfony zu generieren:

{# Im base.html.twig #}
<script>
    const SW_VERSION = '{{ app.request.locale }}-{{ "now"|date("YmdH") }}';
</script>
<script src="{{ asset('js/register-sw.js') }}"></script>

Oder über eine Symfony-Controller-Variable, die auf einer Asset-Hash basiert.

DSGVO-Aspekte bei PWA

PWA-Features sind per se DSGVO-freundlich, wenn man folgendes beachtet:

Service Worker:

  • Cached nur Daten, die sowieso übertragen werden
  • Kein Tracking durch Service Worker selbst
  • Im Privacy Policy erwähnen: "Wir nutzen Service Worker für Offline-Fähigkeit"

Push-Benachrichtigungen:

  • Erfordern explizite Einwilligung (Browser-Dialog — DSGVO-konform)
  • Nur mit Opt-In versenden
  • Immer Opt-Out ermöglichen

Web App Manifest:

  • Enthält keine personenbezogenen Daten
  • Keine DSGVO-Relevanz

PWA-Prüfung

Hinweis: Die PWA-Kategorie wurde in Lighthouse v12.0 (Mai 2024) entfernt. PWA-Prüfungen werden nicht mehr als eigene Kategorie mit Score bewertet. Stattdessen können PWA-Anforderungen über die Chrome DevTools (Application-Tab) oder spezialisierte Tools wie pwabuilder.com geprüft werden.

Wichtige Punkte für eine installierbare PWA:

  • Manifest vorhanden und valide
  • Service Worker registriert
  • HTTPS
  • Responsive Design
  • Icons in richtigen Größen (192x192, 512x512)
  • Startseite offline-fähig

Fazit

PWA-Features sind für Symfony-Anwendungen mit überschaubarem Aufwand implementierbar. Das Web App Manifest und ein einfacher Service Worker mit Cache-First-Strategie sind in wenigen Stunden eingebaut. Der größte Nutzen: wiederholte Besucher laden die Seite deutlich schneller, weil statische Assets gecacht sind — und bei kurzen Netzwerkausfällen bleibt die Seite nutzbar.

DSGVO-Konformität ist bei PWA kein Problem, solange man auf Push-Benachrichtigungen verzichtet oder diese mit explizitem Opt-In versieht.

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.