PWA Symfony 2026 : tutoriel Progressive Web App complet

Transformer une application Symfony 7 en Progressive Web App en 2026 ne demande ni Node.js, ni framework JavaScript lourd. Avec AssetMapper, un service worker propulsé par Workbox 7 et un manifeste bien conçu, vous obtenez une PWA installable, performante, fonctionnant hors ligne et capable de recevoir des notifications push. Ce tutoriel pas-à-pas livre le code exact à copier dans votre projet.

En bref : Une PWA Symfony en 2026 = Symfony 7 + AssetMapper + manifest.webmanifest dans public/ + service worker Workbox 7 enregistré via JS. HTTPS obligatoire. 4 stratégies de cache (cache-first, network-first, stale-while-revalidate, network-only), une page offline servie par un contrôleur Twig, un header Service-Worker-Allowed sur Caddy/Nginx. Lighthouse PWA à 100 si tous les critères sont respectés.

Sommaire
  1. PWA en 2026 : ce qui a changé
  2. Prérequis Symfony 7 + AssetMapper
  3. Manifest.webmanifest : structure complète 2026
  4. Service worker : 4 stratégies de cache avec Workbox 7
  5. Mode offline : page de fallback servie par Symfony
  6. Background sync et Web Push
  7. Installation et engagement utilisateur
  8. Performance et Lighthouse PWA à 100
  9. Déploiement : Caddy, Nginx et headers HTTP
  10. Pièges classiques à éviter
  11. Questions fréquentes
Tutoriel PWA Symfony 2026

1. PWA en 2026 : ce qui a changé depuis 2020

Le concept de Progressive Web App n'est pas nouveau, mais l'écosystème de 2026 n'a plus rien à voir avec celui de 2020. Le support des PWA est désormais universel, y compris sur iOS depuis iOS 17 (push notifications, badging, installation depuis Safari). Cela change radicalement le retour sur investissement : une PWA correctement conçue cible aujourd'hui 100 % des navigateurs modernes.

Les nouveautés majeures : Workbox 7 (2024) a abandonné les anciennes APIs au profit de modules ESM, plus légers et tree-shakables ; les file handlers permettent à une PWA de s'enregistrer comme application par défaut pour certains types de fichiers ; le share target rend une PWA destination du partage système ; les shortcuts apparaissent au long-press sur l'icône ; le badging affiche un compteur sur l'icône sans ouvrir l'app. Toutes ces capacités se déclarent dans le manifest.webmanifest et s'activent côté service worker.

Pour le contexte historique et conceptuel, voyez l'article complémentaire Créer une PWA avec Symfony : guide complet. Le présent tutoriel est résolument pratique et code-first : chaque section produit un fichier ou un snippet directement utilisable.

2. Prérequis : Symfony 7 + AssetMapper + PHP 8.2+

Avant d'écrire la moindre ligne, vérifiez la pile technique. Symfony 7 exige PHP 8.2 minimum, et AssetMapper (la solution officielle d'asset management depuis Symfony 6.3) supprime la dépendance à Node.js. C'est exactement ce qui rend l'expérience PWA fluide en 2026 : tout le pipeline reste en PHP.

Si votre projet utilise encore Webpack Encore, la migration vers AssetMapper se fait en quelques heures pour une application moyenne. AssetMapper utilise les import maps natifs du navigateur et ne génère aucun bundle, ce qui simplifie la mise en cache du service worker (les fichiers ont un nom hashé stable).

Vérification de la pile

# Vérifier la version de PHP
php -v
# PHP 8.2.0 minimum requis

# Vérifier la version de Symfony
php bin/console --version
# Symfony 7.x

# Vérifier qu'AssetMapper est installé
php bin/console debug:asset-map
# Doit lister les assets de assets/

# Lancer le serveur Symfony en HTTPS local (obligatoire pour tester le SW)
symfony server:start

La Symfony CLI génère automatiquement un certificat TLS local quand vous lancez symfony server:start, ce qui évite l'erreur SecurityError: Failed to register a ServiceWorker liée à HTTP. En production, HTTPS est non négociable : le service worker est purement et simplement ignoré sur HTTP.

3. Manifest.webmanifest : structure complète 2026

Le manifeste est le fichier que le navigateur lit pour proposer l'installation, configurer l'icône, le splash screen, les couleurs et les capacités avancées de la PWA. En 2026, un manifeste minimal n'est plus suffisant : pour passer Lighthouse à 100 et exploiter les nouveautés, voici la structure complète à placer dans public/manifest.webmanifest.

{
  "name": "Code Your Web",
  "short_name": "CodeYourWeb",
  "description": "Tutoriels Symfony, PHP et JavaScript pour développeurs.",
  "start_url": "/?utm_source=pwa",
  "scope": "/",
  "display": "standalone",
  "display_override": ["window-controls-overlay", "standalone"],
  "orientation": "portrait-primary",
  "background_color": "#0d1117",
  "theme_color": "#0d1117",
  "lang": "fr-FR",
  "dir": "ltr",
  "categories": ["education", "developer", "productivity"],
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
    { "src": "/icons/icon-monochrome-512.png", "sizes": "512x512", "type": "image/png", "purpose": "monochrome" }
  ],
  "screenshots": [
    { "src": "/screenshots/desktop.webp", "sizes": "1920x1080", "type": "image/webp", "form_factor": "wide" },
    { "src": "/screenshots/mobile.webp", "sizes": "750x1334", "type": "image/webp", "form_factor": "narrow" }
  ],
  "shortcuts": [
    { "name": "Articles Symfony", "url": "/symfony/", "icons": [{ "src": "/icons/sf.png", "sizes": "96x96" }] },
    { "name": "Articles Laravel", "url": "/laravel/", "icons": [{ "src": "/icons/lv.png", "sizes": "96x96" }] }
  ],
  "share_target": {
    "action": "/share",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": { "title": "title", "text": "text", "url": "url" }
  },
  "file_handlers": [
    { "action": "/import", "accept": { "text/markdown": [".md"] } }
  ],
  "protocol_handlers": [
    { "protocol": "web+codeyourweb", "url": "/handle?type=%s" }
  ]
}

Trois points cruciaux. Les icônes maskable : indispensables sur Android pour éviter qu'iOS-comme-Android n'applique un masque circulaire qui rogne le logo ; prévoyez une zone de sécurité de 80 % au centre. Les screenshots avec form_factor : Chrome les affiche dans l'install prompt enrichi depuis 2023 ; sans eux, vous obtenez le mini-prompt minimaliste. Le display_override : permet d'utiliser des modes avancés comme window-controls-overlay tout en gardant un fallback standalone.

Lier le manifeste depuis Twig

Dans templates/base.html.twig, ajoutez les balises de liaison :

{# templates/base.html.twig #}
<link rel="manifest" href="{{ asset('manifest.webmanifest') }}">
<meta name="theme-color" content="#0d1117">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="CodeYourWeb">
<link rel="apple-touch-icon" href="{{ asset('icons/icon-192.png') }}">

Les balises apple-* restent nécessaires pour iOS : Safari ne lit pas tous les champs du manifeste, en particulier le theme_color, qui doit être dupliqué en meta-tag classique.

4. Service worker : 4 stratégies de cache avec Workbox 7

Le service worker est le cœur de la PWA. C'est lui qui intercepte les requêtes réseau, décide quoi mettre en cache et quand servir une réponse hors ligne. Avec Workbox 7, le code reste compact et lisible. Créez le fichier public/service-worker.js à la racine du dossier public :

// public/service-worker.js
// Service worker Workbox 7 pour Code Your Web

importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');

// Versionning du cache (incrémenter pour invalider)
const CACHE_VERSION = 'v3';
workbox.core.setCacheNameDetails({
  prefix: 'cyw',
  suffix: CACHE_VERSION,
});

// Activation immédiate
workbox.core.skipWaiting();
workbox.core.clientsClaim();

// Précache des assets critiques (injecté par Workbox CLI ou manuel)
workbox.precaching.precacheAndRoute([
  { url: '/', revision: CACHE_VERSION },
  { url: '/offline', revision: CACHE_VERSION },
  { url: '/manifest.webmanifest', revision: CACHE_VERSION },
]);

Stratégie 1 : cache-first pour les assets statiques

Les fichiers CSS, JS, polices et icônes ne changent pas entre deux déploiements (grâce au hash AssetMapper). On les sert depuis le cache en priorité, en allant au réseau uniquement si le cache est vide :

// Cache-first : assets statiques (CSS, JS, polices, images)
workbox.routing.registerRoute(
  ({ request }) =>
    request.destination === 'style' ||
    request.destination === 'script' ||
    request.destination === 'font' ||
    request.destination === 'image',
  new workbox.strategies.CacheFirst({
    cacheName: `cyw-static-${CACHE_VERSION}`,
    plugins: [
      new workbox.expiration.ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 jours
      }),
      new workbox.cacheableResponse.CacheableResponsePlugin({
        statuses: [0, 200],
      }),
    ],
  })
);

Stratégie 2 : network-first pour les pages HTML

Les pages produites par Symfony peuvent évoluer (nouveau commentaire, contenu mis à jour). On veut la version fraîche en priorité mais on bascule sur le cache si le réseau échoue :

// Network-first : pages HTML (navigations Symfony)
workbox.routing.registerRoute(
  ({ request }) => request.mode === 'navigate',
  new workbox.strategies.NetworkFirst({
    cacheName: `cyw-pages-${CACHE_VERSION}`,
    networkTimeoutSeconds: 3,
    plugins: [
      new workbox.expiration.ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 7 * 24 * 60 * 60, // 7 jours
      }),
    ],
  })
);

// Fallback offline si tout échoue
workbox.routing.setCatchHandler(async ({ event }) => {
  if (event.request.destination === 'document') {
    return workbox.precaching.matchPrecache('/offline');
  }
  return Response.error();
});

Stratégie 3 : stale-while-revalidate pour les API JSON

Pour les endpoints API qui retournent du JSON (liste d'articles, recherche), on sert immédiatement la réponse cachée tout en lançant en parallèle une requête réseau qui mettra à jour le cache pour la prochaine fois :

// Stale-while-revalidate : API JSON Symfony
workbox.routing.registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new workbox.strategies.StaleWhileRevalidate({
    cacheName: `cyw-api-${CACHE_VERSION}`,
    plugins: [
      new workbox.cacheableResponse.CacheableResponsePlugin({
        statuses: [200],
      }),
      new workbox.expiration.ExpirationPlugin({
        maxEntries: 200,
        maxAgeSeconds: 60 * 60, // 1 heure
      }),
    ],
  })
);

Stratégie 4 : network-only pour les mutations

Les requêtes POST, PUT, DELETE ne doivent jamais être cachées : ce sont des mutations qui doivent atteindre le serveur Symfony pour être persistées. On peut tout de même les mettre en file d'attente quand l'utilisateur est hors ligne via le module Background Sync :

// Network-only + queue offline pour les mutations
const bgSyncPlugin = new workbox.backgroundSync.BackgroundSyncPlugin('cyw-mutations', {
  maxRetentionTime: 24 * 60, // 24 heures
});

workbox.routing.registerRoute(
  ({ request, url }) =>
    ['POST', 'PUT', 'DELETE'].includes(request.method) &&
    url.pathname.startsWith('/api/'),
  new workbox.strategies.NetworkOnly({
    plugins: [bgSyncPlugin],
  }),
  // Important : préciser la méthode pour que le router reconnaisse les non-GET
  'POST'
);
Service worker Symfony et stratégies de cache

Pour approfondir le rendu côté client moderne, consultez aussi notre comparatif des frameworks JavaScript en 2026 : certaines applications Symfony hybrides combinent une PWA et un mini-front Vue ou Stimulus pour les interactions riches.

5. Mode offline : page de fallback servie par Symfony

Quand l'utilisateur perd la connexion et tente de naviguer vers une page non cachée, Workbox bascule sur le setCatchHandler que nous avons déclaré plus haut. Cette ancre pointe vers une URL /offline qui doit elle-même être servie par Symfony et pré-cachée dès l'installation du service worker.

Contrôleur Symfony pour la page offline

<?php
// src/Controller/OfflineController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class OfflineController extends AbstractController
{
    #[Route('/offline', name: 'app_offline', methods: ['GET'])]
    public function index(): Response
    {
        // Page offline statique : pas de requête BDD,
        // pas de service externe, pure présentation Twig.
        $response = $this->render('offline/index.html.twig');

        // Cache navigateur court : la page peut changer entre deux deploys
        $response->setSharedMaxAge(60);
        $response->headers->set('Service-Worker-Allowed', '/');

        return $response;
    }
}

Template Twig minimaliste

{# templates/offline/index.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}Hors ligne | Code Your Web{% endblock %}

{% block body %}
<main class="offline-screen">
  <h1>Vous êtes hors ligne</h1>
  <p>Cette page n'est pas disponible sans connexion. Les articles déjà
     consultés restent accessibles depuis le menu.</p>
  <button onclick="location.reload()">Réessayer</button>
</main>
{% endblock %}

L'astuce : la page offline doit être auto-suffisante. Toutes ses ressources (CSS, polices, images) doivent également être dans le cache au moment où le service worker la sert. Si le CSS principal n'est pas pré-caché, l'utilisateur voit du HTML brut sans style. C'est pourquoi style.css et les polices Inter et Playfair Display sont déjà en CacheFirst dans la stratégie 1.

6. Background sync et Web Push avec Symfony

Deux options complémentaires pour les notifications : MinishlinkWebPushBundle pour les push notifications natives (utilise VAPID, abonnements stockés en base via Doctrine) et Symfony Mercure pour le real-time bidirectionnel (Server-Sent Events). Les deux peuvent coexister dans une même application.

Installation du bundle Web Push

# Installation du bundle
composer require minishlink/web-push-bundle

# Génération des clés VAPID (une seule fois, à conserver dans .env)
php bin/console webpush:generate:keys
# VAPID_PUBLIC_KEY=...
# VAPID_PRIVATE_KEY=...
# VAPID_SUBJECT=mailto:contact@codeyourweb.org

Côté serveur : envoyer une notification depuis Symfony

<?php
// src/Service/PushNotificationSender.php
namespace App\Service;

use Minishlink\Bundle\WebPushBundle\Model\Subscription;
use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription as PushSubscription;

readonly class PushNotificationSender
{
    public function __construct(private WebPush $webPush) {}

    public function sendArticlePublished(array $subscriptions, string $title, string $url): void
    {
        $payload = json_encode([
            'title' => 'Nouvel article publié',
            'body' => $title,
            'icon' => '/icons/icon-192.png',
            'badge' => '/icons/icon-monochrome-512.png',
            'data' => ['url' => $url],
            'actions' => [
                ['action' => 'open', 'title' => 'Lire'],
                ['action' => 'close', 'title' => 'Plus tard'],
            ],
        ]);

        foreach ($subscriptions as $sub) {
            $this->webPush->queueNotification(
                PushSubscription::create($sub->getSubscriptionHash()),
                $payload
            );
        }

        // Envoi en masse (HTTP/2 multiplexé)
        foreach ($this->webPush->flush() as $report) {
            if (!$report->isSuccess()) {
                // Logger ou supprimer l'abonnement obsolète
            }
        }
    }
}

Côté service worker : afficher la notification

// public/service-worker.js (ajouter en bas)

self.addEventListener('push', (event) => {
  if (!event.data) return;
  const payload = event.data.json();
  event.waitUntil(
    self.registration.showNotification(payload.title, {
      body: payload.body,
      icon: payload.icon,
      badge: payload.badge,
      data: payload.data,
      actions: payload.actions || [],
      vibrate: [200, 100, 200],
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const url = event.notification.data?.url || '/';
  event.waitUntil(clients.openWindow(url));
});

Pour le pendant real-time, Symfony Mercure offre une approche complémentaire (un hub qui pousse des messages aux clients via SSE), particulièrement utile pour les flux temps réel comme un chat ou un dashboard. Il s'intègre à UX Turbo pour des mises à jour automatiques de l'UI sans rechargement.

7. Installation et engagement utilisateur

L'installation d'une PWA peut être passive (l'utilisateur clique sur l'icône d'install dans la barre Chrome) ou active (un bouton "Installer l'application" dans votre interface). En 2026, l'approche active est nettement plus performante en taux de conversion.

Enregistrement du SW depuis AssetMapper

// assets/app.js (point d'entrée AssetMapper)
import './bootstrap.js';
import './styles/app.css';

// Enregistrement du service worker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker
      .register('/service-worker.js', { scope: '/' })
      .then((reg) => {
        console.log('SW enregistré, scope:', reg.scope);

        // Vérifier les updates toutes les heures
        setInterval(() => reg.update(), 60 * 60 * 1000);
      })
      .catch((err) => console.error('SW erreur:', err));
  });
}

// Capture du beforeinstallprompt pour bouton custom
let deferredPrompt = null;
const installBtn = document.getElementById('install-btn');

window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  deferredPrompt = e;
  if (installBtn) installBtn.hidden = false;
});

installBtn?.addEventListener('click', async () => {
  if (!deferredPrompt) return;
  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;
  console.log('Install outcome:', outcome);
  deferredPrompt = null;
  installBtn.hidden = true;
});

// Badging API : afficher un compteur sur l'icône
if ('setAppBadge' in navigator) {
  navigator.setAppBadge(3); // ex : 3 articles non lus
}

Le beforeinstallprompt est un événement Chromium uniquement : sur iOS Safari, il faut afficher des instructions visuelles ("Ajouter à l'écran d'accueil" via le bouton de partage). Détectez iOS via navigator.standalone et le user-agent pour adapter le message.

PWA Symfony en mode offline sur mobile

8. Performance et Lighthouse PWA à 100

Une PWA mal optimée perd tout son intérêt. Lighthouse, l'audit officiel de Google, mesure quatre catégories clé : Performance, Accessibility, Best Practices, SEO. Une PWA installée doit viser 100 dans les quatre. Voici les leviers concrets.

Checklist Lighthouse PWA à 100 (criteres 2026)

  • Manifest : tous les champs requis présents (name, short_name, icons 192/512, start_url, display, background_color, theme_color)
  • Service worker : enregistré avec un scope / et une page offline pré-cachée
  • HTTPS : tout serté en HTTPS, redirection HTTP→HTTPS active, header HSTS
  • Viewport : meta-tag width=device-width, initial-scale=1
  • Performance : LCP < 2.5 s, INP < 200 ms, CLS < 0.1
  • Apple touch icon : link rel="apple-touch-icon" 180x180 en racine
  • Theme color : meta-tag présent ET cohérent avec theme_color du manifeste
  • Icones maskable : au moins une avec "purpose": "maskable"

Côté Symfony, deux optimisations changent radicalement les métriques : activer HTTP/2 push pour les assets critiques via les Web Link headers (Symfony gère cela nativement avec WebLink) et compiler le conteneur en mode prod pour éliminer le coût d'exécution. Le composant HttpClient de Symfony, utilisé en mode async, évite de bloquer le rendu lors d'appels externes.

9. Déploiement : Caddy, Nginx et headers HTTP

L'erreur la plus fréquente en mise en production : le service worker ne s'enregistre pas, ou pire, il s'enregistre mais ne se met jamais à jour. La cause est presque toujours liée aux headers HTTP du fichier service-worker.js et à son chemin. Ce tutoriel suppose un déploiement avec Caddy ou Nginx en frontal de PHP-FPM.

Configuration Caddy (recommandé en 2026)

# Caddyfile
codeyourweb.org {
    root * /var/www/codeyourweb/public
    encode gzip zstd
    php_fastcgi unix//run/php/php8.3-fpm.sock

    # Service worker : cache court + scope racine
    @sw {
        path /service-worker.js
    }
    header @sw {
        Service-Worker-Allowed "/"
        Cache-Control "no-cache, max-age=0, must-revalidate"
    }

    # Manifest : cache long, mais révisable
    @manifest {
        path /manifest.webmanifest
    }
    header @manifest {
        Content-Type "application/manifest+json"
        Cache-Control "public, max-age=86400"
    }

    # Assets AssetMapper : cache un an (hash dans le nom)
    @assets {
        path /assets/*
    }
    header @assets {
        Cache-Control "public, max-age=31536000, immutable"
    }

    file_server
}

Configuration Nginx équivalente

# /etc/nginx/sites-available/codeyourweb.conf
server {
    listen 443 ssl http2;
    server_name codeyourweb.org;
    root /var/www/codeyourweb/public;

    ssl_certificate     /etc/letsencrypt/live/codeyourweb.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/codeyourweb.org/privkey.pem;

    # HSTS pour la PWA
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Service worker
    location = /service-worker.js {
        add_header Service-Worker-Allowed "/" always;
        add_header Cache-Control "no-cache, max-age=0, must-revalidate" always;
        try_files $uri =404;
    }

    # Manifest webmanifest
    location = /manifest.webmanifest {
        add_header Content-Type "application/manifest+json";
        add_header Cache-Control "public, max-age=86400";
        try_files $uri =404;
    }

    # Assets versionnés
    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }

    # Symfony front controller
    location / {
        try_files $uri /index.php$is_args$args;
    }

    location ~ ^/index\.php(/|$) {
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        internal;
    }
}

Le header Service-Worker-Allowed: / autorise un service worker placé n'importe où (par exemple /build/sw.js) à revendiquer un scope racine. Si le SW est à la racine (/service-worker.js), ce header n'est pas obligatoire mais le mettre par défaut évite des surprises lors d'une réorganisation des assets.

10. Pièges classiques à éviter

Après avoir déployé plusieurs PWA Symfony en production, voici les piages récurrents qui coûtent des heures de debug.

Top 7 des erreurs en PWA Symfony

  1. Cache du SW immortel : oublier d'incrémenter CACHE_VERSION à chaque deploy. Résultat : les utilisateurs voient l'ancienne version pendant des semaines. Solution : intégrer CACHE_VERSION dans votre pipeline CI/CD avec le hash du commit.
  2. Scope mal déclaré : enregistrer /build/sw.js sans Service-Worker-Allowed: / limite le scope au dossier /build/, donc le SW n'intercepte rien. Toujours mettre le SW à la racine ou ajouter le header.
  3. HTTPS oublié en pré-prod : les environnements de staging en HTTP n'enregistrent pas le SW, ce qui masque les bugs. Utiliser localhost ou Let's Encrypt sur la pré-prod.
  4. Mises à jour silencieuses : sans skipWaiting() et clientsClaim(), un nouveau SW attend que tous les onglets soient fermés. Résultat : les utilisateurs gardent l'ancienne version.
  5. Cache opaque pour les CDN : les requêtes cross-origin (CDN, fonts Google) retournent des réponses opaques (type: "opaque") qui consomment beaucoup d'espace cache. Filtrer avec CacheableResponsePlugin sur statut 200.
  6. Manifest mal servi : application/manifest+json est obligatoire (pas application/json). Lighthouse signale le problème sans pour autant empêcher l'install.
  7. Symfony route /service-worker.js : si vous oubliez de placer le fichier dans public/, Symfony tente de matcher la route et retourne une 404. Toujours vérifier avec curl https://votresite.com/service-worker.js après deploy.

Pour aller plus loin sur les bonnes pratiques officielles, la documentation Workbox sur developer.chrome.com reste la référence absolue, mise à jour avec chaque version de Chromium.

Avec ce socle, votre application Symfony 7 devient une vraie Progressive Web App : installable, fonctionnant hors ligne, capable de pousser des notifications et notable à 100 sur Lighthouse. Combiné aux bonnes pratiques de fond couvertes dans le guide complet Symfony 7, vous obtenez une stack moderne, performante et durable, sans concession sur l'expérience utilisateur.

Questions fréquentes

Symfony peut-il vraiment servir de base à une Progressive Web App en 2026 ?

Oui. Symfony 7 génère le HTML, le manifeste, les routes API et la page offline ; le service worker (généré par Workbox 7) tourne côté navigateur et intercepte les requêtes. Le couple PHP server-side + service worker client-side est parfaitement viable. Les seuls impondérables sont HTTPS obligatoire et la diffusion correcte du fichier service-worker.js à la racine du domaine.

Comment créer un service worker Symfony avec Workbox 7 ?

Créez un fichier public/service-worker.js qui importe Workbox 7 via importScripts ou un bundler, déclarez precacheAndRoute pour les assets statiques et registerRoute pour les requêtes dynamiques avec une stratégie StaleWhileRevalidate ou NetworkFirst. Enregistrez-le côté client via navigator.serviceWorker.register('/service-worker.js'). Workbox 7 supporte la pré-mise en cache, les routes dynamiques, le background sync et les notifications push.

Où placer manifest.json dans un projet Symfony ?

Placez manifest.webmanifest dans le dossier public/ à la racine du projet Symfony. Liez-le dans le template base.html.twig avec <link rel="manifest" href="/manifest.webmanifest">. Pour les valeurs dynamiques (nom du site, theme_color depuis les paramètres), vous pouvez aussi le servir via un contrôleur Symfony qui retourne du JSON avec le bon Content-Type application/manifest+json.

Comment gérer le mode offline d'une PWA Symfony ?

Le service worker pré-cache une page offline.html générée par Symfony lors de l'install. Quand une requête de navigation échoue (pas de réseau, serveur down), le service worker sert cette page de fallback via un setCatchHandler Workbox. Les assets statiques (CSS, JS, polices) sont en CacheFirst, donc disponibles immédiatement. Les API en NetworkFirst basculent sur leur dernière réponse cachée si l'utilisateur est hors ligne.

Quelle configuration serveur pour le service worker Symfony ?

Le fichier service-worker.js doit être servi depuis la racine du domaine avec le header Service-Worker-Allowed: / et un Cache-Control: no-cache (ou max-age=0) pour permettre les mises à jour silencieuses. Sur Caddy, ajoutez un bloc handle_path /service-worker.js avec header_response. Sur Nginx, location = /service-worker.js avec add_header. HTTPS est obligatoire en production (le service worker est bloqué sur HTTP, sauf localhost).

Comment envoyer des notifications push depuis Symfony ?

Deux options : MinishlinkWebPushBundle pour les notifications push natives (VAPID, abonnements stockés en base) ou Symfony Mercure pour le real-time bidirectionnel (Server-Sent Events). MinishlinkWebPushBundle expose un service WebPush qui envoie les notifications via le protocole standard du W3C ; Mercure utilise un hub dédié et est mieux adapté aux flux temps réel (chat, notifications in-app).