Back

Loading…

Documentation

πŸ“± 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>

πŸ’‘ immutable tells 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:load instead of DOMContentLoaded on 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, and apple-mobile-web-app-status-bar-style tags 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 manifest display field, to determine standalone behaviour.
  • Does not support the beforeinstallprompt event β€” 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

Contents