π± Progressive Web Apps in Symfony β A Complete Implementation Guide
Progressive Web Apps (PWAs) sit at the intersection of the web and native apps. They load instantly, work offline, can be installed on the home screen, and feel as snappy as a native application β all without an App Store. This guide explains what PWAs are, what you can configure, and exactly how to implement one in a Symfony project.
π€ What is a PWA?
A Progressive Web App is a regular web application that meets a set of browser criteria to be treated as a first-class app. The browser can then:
- π² Prompt users to install it on their home screen (Android, iOS, Windows, macOS)
- β‘ Cache pages and assets so navigation feels instant
- π Work offline or on flaky connections
- π Send push notifications (optional)
- π₯οΈ Run in standalone window without browser chrome
The key insight: you don't ship anything to an App Store. Your website is the app.
ποΈ The Three Pillars
Every PWA requires exactly three things:
| Pillar | File | Purpose |
|---|---|---|
| π HTTPS | Server config | Secure context (required by browsers for SW) |
| π Web App Manifest | manifest.json |
Describes the app to the browser |
| βοΈ Service Worker | service-worker.js |
JavaScript that runs in the background |
π The Web App Manifest
The manifest is a JSON file that tells the browser how to present your app when installed.
{
"name": "SeriousDesign",
"short_name": "SD",
"description": "Full-stack project management and portfolio",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#080808",
"theme_color": "#080808",
"orientation": "any",
"icons": [
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
ποΈ Key Options Explained
display β How the app window looks
| Value | Description |
|---|---|
standalone |
Runs like a native app β no browser address bar or navigation UI |
fullscreen |
Full screen, no browser UI at all (games, media players) |
minimal-ui |
Shows a minimal navigation bar (back, forward, refresh) |
browser |
Opens in the regular browser tab (effectively disables install UI) |
For most apps: standalone is what you want.
start_url β Where to land after install
This is the URL opened when the user taps your app icon. Use a meaningful page, not just /. For authenticated apps, use /dashboard or the app's home screen. For public sites, / is fine.
theme_color β Browser chrome tint
The color of the status bar and browser UI on mobile. Must match your <meta name="theme-color"> in base.html.twig for consistency. Dark apps should use a dark color here.
background_color β Splash screen background
Shown during the splash screen while your app is loading for the first time after install. Should match your app's primary background so the transition is seamless.
icons β Home screen and splash images
Browsers request specific sizes. You need at minimum:
- 192Γ192 β home screen icon on Android
- 512Γ512 β splash screen and app stores
The "purpose": "maskable" flag tells the system the icon is safe to crop into different shapes (circle, rounded square, etc.). Design your icon with padding around the main content to support this.
orientation β Allowed screen orientations
| Value | Effect |
|---|---|
any |
Rotates freely with the device |
portrait |
Locked to portrait |
landscape |
Locked to landscape |
shortcuts β Quick actions from long-press
"shortcuts": [
{
"name": "Dashboard",
"url": "/dashboard",
"icons": [{ "src": "icon-dashboard.png", "sizes": "96x96" }]
},
{
"name": "Calendar",
"url": "/calendar"
}
]
These appear when you long-press the app icon on Android and Windows.
categories β App Store discovery
"categories": ["productivity", "business"]
Used by app directories and search. Common values: productivity, business, education, entertainment, games, utilities.
βοΈ The Service Worker
The service worker is a JavaScript file that the browser installs and runs in a separate thread β independent from the page. It intercepts every network request and decides how to handle it.
π Caching Strategies
There are five common strategies. The right one depends on what you're caching.
1. ποΈ Cache-First (best for immutable assets)
Check the cache first. Only go to the network if not found. Never stale because the URL changes when the content changes (fingerprinting).
Request β Cache? β Return immediately
β Not found β Network β Store in cache β Return
Use for: Fingerprinted JS/CSS (app-HASH.js), fonts, images with content-hashes.
async function cacheFirst(req, cacheName) {
const cached = await caches.match(req);
if (cached) return cached;
const res = await fetch(req);
if (res.ok) (await caches.open(cacheName)).put(req, res.clone());
return res;
}
2. π Network-First (best for dynamic content)
Try the network. Fall back to cache if offline or slow. Always fresh when online.
Request β Network β Return + update cache
β Failed β Cache? β Return
β No cache β Error
Use for: API responses, authenticated pages that change frequently.
3. β‘ Stale-While-Revalidate (best for pages β the sweet spot)
Return the cached version immediately (zero latency), then fetch a fresh copy in the background for the next visit. The user always sees the page from the previous request, updated by one cycle.
Request β Cache? β Return INSTANTLY
|
ββ Fetch fresh in background β Update cache
Use for: HTML pages in a single-user authenticated app. Instant navigation, always one step ahead.
async function staleWhileRevalidate(req, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(req);
const networkFetch = fetch(req).then(res => {
if (res.ok) cache.put(req, res.clone());
return res;
}).catch(() => null);
return cached ?? networkFetch; // return cache immediately, or wait if first visit
}
4. π¦ Cache-Only
Only serve from cache. Never touch the network. Useful for precached app shells.
5. π Network-Only
Always go to the network. Never cache. Required for POST requests, authentication, and any mutation.
π― Strategy Selection Guide
| Asset type | Strategy | Why |
|---|---|---|
Fingerprinted JS/CSS (*.βHASH.js) |
Cache-First | URL changes = safe forever |
| Fonts (Google/local) | Cache-First | Rarely change |
| HTML pages | Stale-While-Revalidate | Instant + eventually fresh |
| API / JSON endpoints | Network-First | Must be current |
| POST, PUT, DELETE | Network-Only | Mutations can't be cached |
Auth routes (/logout, /login) |
Network-Only | Must never serve stale |
πΌ Symfony-Specific Implementation
Step 1 β Create the manifest
Place manifest.json in /public/. Reference it in your base template:
{# templates/base.html.twig #}
<link rel="manifest" href="/manifest.json"/>
<meta name="theme-color" content="#080808"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
Step 2 β Write the service worker
Place service-worker.js in /public/. It must be at the root so its scope covers the whole app.
'use strict';
const CACHE_VERSION = 'sd-v1'; // bump on deploy to clear page cache
const ASSET_CACHE = `assets-${CACHE_VERSION}`;
const PAGE_CACHE = `pages-${CACHE_VERSION}`;
const NETWORK_ONLY = ['/logout', '/login', '/_profiler', '/_wdt'];
const PRECACHE = ['/offline'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(ASSET_CACHE)
.then(c => c.addAll(PRECACHE))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(
keys.filter(k => k !== ASSET_CACHE && k !== PAGE_CACHE)
.map(k => caches.delete(k))
))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', event => {
const req = event.request;
const url = new URL(req.url);
if (req.method !== 'GET') return;
if (NETWORK_ONLY.some(p => url.pathname.startsWith(p))) return;
// Fingerprinted Symfony AssetMapper files β Cache-First
if (/\/assets\/.*-[A-Za-z0-9_-]{5,}\.(js|css|woff2?|png|svg|jpg)/.test(url.pathname)) {
event.respondWith(cacheFirst(req, ASSET_CACHE));
return;
}
// Google Fonts β Cache-First
if (url.hostname.includes('fonts.g')) {
event.respondWith(cacheFirst(req, ASSET_CACHE));
return;
}
// HTML pages β Stale-While-Revalidate
if (url.origin === self.location.origin && req.headers.get('Accept')?.includes('text/html')) {
event.respondWith(staleWhileRevalidate(req, PAGE_CACHE));
return;
}
// Everything else β Network-First
if (url.origin === self.location.origin) {
event.respondWith(networkFirst(req, ASSET_CACHE));
}
});
Step 3 β Register the service worker
In your main JS entry point (e.g. assets/app.js):
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js', { scope: '/' })
.catch(err => console.warn('[SW] Registration failed:', err));
});
}
β οΈ Critical: Do NOT use defer or type="module" on the SW registration call β it must run after load. The window.addEventListener('load', ...) wrapper already handles timing correctly.
Step 4 β Cache fingerprinted assets with HTTP headers
Symfony's AssetMapper (and Webpack Encore) fingerprint filenames: app-fTuUKdc.js. Since the URL changes whenever content changes, you can set an aggressive Cache-Control header β the browser will cache them forever and never re-request them.
In /public/.htaccess:
<IfModule mod_headers.c>
# Immutable cache for fingerprinted files (safe forever β URL changes with content)
<FilesMatch "^.+-[A-Za-z0-9_-]{5,}\.(js|css|woff2?|png|jpg|svg|gif|ico|webp)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# Service worker must NEVER be cached β browser must always fetch the latest version
<Files "service-worker.js">
Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
</Files>
</IfModule>
π‘
immutabletells the browser not to even bother with a conditional revalidation request (If-None-Match). The browser will use the locally cached file until the URL changes β which it will when you next deploy.
Step 5 β Create the offline fallback page
Create a Symfony route and template for /offline. The SW precaches it during install so it's always available:
// src/Controller/Site/SiteController.php
#[Route('/offline', name: 'app_offline')]
public function offline(): Response
{
return $this->render('front/home/offline.html.twig');
}
{# templates/front/home/offline.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<div class="offline-message">
<h1>π‘ You're offline</h1>
<p>No internet connection. Check your network and try again.</p>
<a href="/">Try again</a>
</div>
{% endblock %}
Step 6 β Handle Turbo Drive π
If your Symfony app uses @hotwired/turbo (Turbo Drive), there's an important interaction: Turbo caches pages in memory and swaps them without a full page load. The service worker handles network requests; Turbo's in-memory cache handles DOM swaps. Both work together automatically β but be aware:
- Turbo fires
turbo:loadinstead ofDOMContentLoadedon navigation - Any JS that re-initializes components must listen to
turbo:load - Service worker caches the server's HTML response; Turbo's cache is a separate DOM snapshot cache in memory
Do NOT return cached HTML from the SW for requests with Accept: text/vnd.turbo-stream.html (Turbo Frame/Stream requests). Add this to your fetch handler:
// Skip Turbo Streams β let them always hit the network
if (req.headers.get('Accept')?.includes('turbo-stream')) return;
π Deployment Checklist
β
HTTPS enabled (required β SW won't register on http://)
β
manifest.json in /public/, linked in <head>
β
service-worker.js in /public/ (scope = root)
β
SW registered in JS (not blocked by defer/module)
β
/offline route + template created and precached
β
Cache-Control: immutable on fingerprinted assets (.htaccess)
β
service-worker.js itself is NOT cached (no-store)
β
theme-color meta tag matches manifest
β
Icons: 192px + 512px maskable
β
Auth routes in NETWORK_ONLY list (/logout, /login)
β
POST/PUT/DELETE return without caching
β»οΈ Updates β Do Users Need to Do Anything?
No. The browser handles it entirely automatically. Here is why:
The three mechanisms that make it seamless
1. service-worker.js is never cached by the browser
The .htaccess rule below forces the browser to always fetch a fresh copy of the SW file on every visit:
<Files "service-worker.js">
Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
</Files>
The browser compares the fetched file byte-for-byte with the currently installed version. Any change triggers an update.
2. skipWaiting() activates the new SW immediately
Without this flag, the new service worker would sit in a waiting state until every tab of your app is closed. With skipWaiting() in the install handler, the new SW activates the moment installation finishes β no closing, no refreshing required.
3. clients.claim() takes control of all open tabs
Without this flag, the newly activated SW would only control tabs opened after activation. clients.claim() makes it take over all currently open tabs immediately.
The full update flow (user opens app on phone)
User opens the app
β Browser fetches /service-worker.js (no-store: always fresh)
β Detects change (e.g. sd-v1 β sd-v2)
β Installs new SW in the background
β skipWaiting() β activates immediately (no tab-close needed)
β clients.claim() β takes control of all open tabs
β activate handler deletes stale cache buckets (pages-sd-v1, assets-sd-v1)
β Next navigation: fresh HTML fetched, cached as sd-v2
When to bump CACHE_VERSION
Fingerprinted assets (JS, CSS, fonts) invalidate themselves β a new deploy = a new hash = a new URL = the browser fetches fresh automatically. You never need to bump the version for those.
HTML pages are different. With stale-while-revalidate, a cached page is served immediately and a fresh copy is fetched in the background. The user will see the new version on their next navigation β one cycle behind. That is acceptable for most changes.
Bump CACHE_VERSION when you want the user to see fresh pages on the very first open after a deploy β for example after a significant visual redesign or data structure change:
// Just change this one constant on deploy:
const CACHE_VERSION = 'sd-v2'; // was sd-v1
Rule of thumb:
| Change type | Bump needed? |
|---|---|
| JS/CSS/image fix | β β fingerprint handles it |
| Bug fix in PHP/Twig | β β stale-while-revalidate self-corrects in one cycle |
| Major visual redesign | β β force fresh HTML on first open |
| Breaking data structure change | β β avoid serving stale layout |
π Testing and Debugging
Open Chrome DevTools β Application tab:
- Manifest β shows parsed manifest, install button, icons
- Service Workers β shows registration status, update, skip waiting
- Cache Storage β inspect what's cached and delete entries
- Lighthouse β run a PWA audit for a full checklist
For testing the offline experience: in DevTools β Network tab β check "Offline", then navigate to a previously visited page.
π Browser Compatibility
Not all browsers treat PWAs the same way. The gap is between the service worker features (caching, offline) and the installability features (home screen, standalone window).
Feature matrix
| Feature | Chrome / Edge | Safari iOS | Firefox Desktop | Firefox Android |
|---|---|---|---|---|
| Service worker registration | β | β | β since v44 | β |
Cache API (caches.*) |
β | β | β | β |
skipWaiting / clients.claim |
β | β | β | β |
| Offline fallback via SW | β | β | β | β |
manifest.json (name, icons) |
β | β | β partial | β partial |
| PWA install prompt | β | β (Safari) | β removed v85 | β |
beforeinstallprompt event |
β | β | β | β |
display: standalone |
β | β | β ignored | β οΈ URL bar visible |
theme-color meta |
β | β | β ignored | β ignored |
apple-mobile-web-app-* tags |
N/A | β | β | β |
π¦ Firefox in detail
Firefox has full service worker support β our caching strategies, stale-while-revalidate, and offline fallback all work correctly in Firefox. What does not work is the installability layer.
Firefox Desktop removed PWA installation support in v85 (January 2021). There is no install prompt, no standalone window, no home screen icon. This is a deliberate browser policy decision and cannot be worked around in your code.
Firefox Android allows "Add to Home Screen" but the app opens inside a Firefox browser tab with the URL bar visible β not in a true standalone window the way Chrome Android does.
Practical impact:
- Firefox users visiting your app in the browser still get all the speed benefits β service worker caching means assets never re-download and previously visited pages load from cache.
- They simply cannot install it as a standalone app icon on desktop.
- The
theme-color,apple-mobile-web-app-capable, andapple-mobile-web-app-status-bar-styletags in your<head>are silently ignored by Firefox. They do no harm β keep them for Chrome and Safari.
π§ Safari iOS in detail
Safari iOS has supported PWA installation ("Add to Home Screen") since iOS 11.3. A few quirks to know:
- Uses
apple-mobile-web-app-*meta tags, NOT the manifestdisplayfield, to determine standalone behaviour. - Does not support the
beforeinstallpromptevent β no custom install button possible. - Push notifications via service worker are supported from iOS 16.4+.
- Service workers are sandboxed per tab in older iOS β behaviour improved significantly in iOS 16.
What this means for a Symfony app
Your service worker code does not need to change for Firefox compatibility β it already uses only standard, widely-supported APIs. The limitation is purely at the browser's install layer, which is outside your control.
If you ever want to add a manual install button for browsers that support beforeinstallprompt (Chrome, Edge):
let deferredPrompt;
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
deferredPrompt = e;
document.getElementById('install-btn').style.display = 'block';
});
document.getElementById('install-btn').addEventListener('click', () => {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(() => { deferredPrompt = null; });
});
Firefox and Safari users simply won't see the button β graceful degradation by nature.
π§© Optional Enhancements
π Push Notifications
// Request permission
const permission = await Notification.requestPermission();
// Subscribe to push via SW
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: YOUR_VAPID_PUBLIC_KEY
});
Symfony has the symfony/web-push component for sending pushes server-side.
π Background Sync
Queue form submissions when offline, replay when connection returns:
self.addEventListener('sync', event => {
if (event.tag === 'submit-form') {
event.waitUntil(replayQueuedRequests());
}
});
π Install Analytics
window.addEventListener('appinstalled', () => {
// Track when users install your app
analytics.track('pwa_installed');
});
window.addEventListener('beforeinstallprompt', e => {
// Show a custom install button instead of the browser's default prompt
e.preventDefault();
deferredPrompt = e;
showInstallButton();
});
π What This Achieves
For a single-user authenticated app with AssetMapper + Turbo Drive + this service worker:
| Scenario | Before | After |
|---|---|---|
| Revisit a page (cached) | ~200β500ms network | ~0ms (SW cache) |
| JS/CSS files on revisit | Conditional request + 304 | 0ms (browser immutable cache) |
| Navigate with Turbo | ~100β300ms network | 0ms (Turbo DOM cache + SW page cache) |
| Offline visit | Error page | Cached page or /offline |
| Google Fonts | CDN request | 0ms after first load |
The result: pages appear instantly on every visit after the first. The network is only used to silently keep the cache fresh in the background.
Written June 2026 Β· SeriousDesign Β· Symfony 7.2 + AssetMapper + Turbo Drive