Progressive Web Apps (PWA) are regular websites enhanced with specific Web APIs to deliver app-like experiences. The term was coined by Google. A PWA can be installed on the home screen, work offline, and receive push notifications — without an app store.
What Makes a PWA?
Three core components:
- HTTPS: Mandatory — Service Workers only function over secure connections
- Web App Manifest: JSON file with app metadata (name, icon, start page)
- Service Worker: JavaScript script that runs in the background and intercepts requests
Additionally, for full PWA capability:
- Responsive design
- Fast load times (LCP < 2.5s)
- Offline functionality
Web App Manifest
The manifest defines how the PWA looks when installed on the home screen:
{
"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"}]
}
]
}
Note: Using "purpose": "maskable any" on a single icon is not recommended. Instead, provide separate icons for maskable and any, since maskable icons are cropped differently and using them as universal icons can cause display issues.
Serving the manifest in Symfony:
<?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'],
],
]);
}
}
In the <head> of the base template:
<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
The Service Worker is a JavaScript script that the browser registers in the background. It can intercept network requests and serve responses from a cache.
Frontend Registration
// 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 registered:', registration.scope);
} catch (error) {
console.error('Service Worker registration failed:', 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: cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
});
// Activation: clean up old caches
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 for static assets, Network-First for API
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Only cache GET requests
if (request.method !== 'GET') return;
// API requests: Network-First
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Blog articles: Stale-While-Revalidate
if (url.pathname.startsWith('/blog/')) {
event.respondWith(staleWhileRevalidate(request));
return;
}
// Static 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 Page
<!-- 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 Versioning in Symfony
When assets are updated, the Service Worker cache must also be updated. One approach is to generate the cache version in Symfony:
{# In base.html.twig #}
<script>
const SW_VERSION = '{{ app.request.locale }}-{{ "now"|date("YmdH") }}';
</script>
<script src="{{ asset('js/register-sw.js') }}"></script>
Alternatively, a Symfony controller variable based on an asset hash can be used.
GDPR Considerations for PWAs
PWA features are inherently GDPR-friendly, provided you keep the following in mind:
Service Worker:
- Only caches data that is transmitted anyway
- No tracking through the Service Worker itself
- Mention in the privacy policy: "We use Service Workers for offline capability"
Push Notifications:
- Require explicit consent (browser dialog — GDPR-compliant)
- Only send with opt-in
- Always provide an opt-out option
Web App Manifest:
- Contains no personal data
- No GDPR relevance
PWA Verification
Note: The PWA category was removed from Lighthouse v12.0 (May 2024). PWA checks are no longer evaluated as a separate category with a score. Instead, PWA requirements can be verified via Chrome DevTools (Application tab) or specialised tools like pwabuilder.com.
Key points for an installable PWA:
- Manifest present and valid
- Service Worker registered
- HTTPS
- Responsive design
- Icons in the correct sizes (192x192, 512x512)
- Start page available offline
Conclusion
PWA features can be implemented in Symfony applications with manageable effort. The Web App Manifest and a simple Service Worker with a Cache-First strategy can be set up in just a few hours. The biggest benefit: returning visitors load the page significantly faster because static assets are cached — and during brief network outages, the site remains usable.
GDPR compliance is not an issue with PWAs, as long as you either forgo push notifications or implement them with explicit opt-in.
Comments
Comments are provided by Remark42. By loading comments, data is transmitted to our comment server.