Building Progressive Web Apps (PWA) with Laravel

· 15 min read

Introduction

Progressive Web Apps (PWA) is a technology that allows web applications to work like native apps - installable on the home screen, works offline, receives push notifications, and much more.

Why PWA?

Feature Regular Website PWA Native App
Installation
Offline
Push Notifications
Camera access Limited
App Store Not required Required
Updates Automatic Automatic User must update
Development Fast Fast Slower
Cross-platform Multiple codebases

PWA Requirements

  1. HTTPS: Required (except localhost)
  2. Service Worker: Handles caching and offline
  3. Web App Manifest: Metadata for installation

Step 1: Create Web App Manifest

The Web App Manifest is a JSON file containing app information:

// public/manifest.json
{
    "name": "My Laravel PWA",
    "short_name": "LaravelPWA",
    "description": "A Progressive Web App built with Laravel",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#ffffff",
    "theme_color": "#4f46e5",
    "orientation": "portrait-primary",
    "icons": [
        {
            "src": "/images/icons/icon-72x72.png",
            "sizes": "72x72",
            "type": "image/png",
            "purpose": "maskable any"
        },
        {
            "src": "/images/icons/icon-96x96.png",
            "sizes": "96x96",
            "type": "image/png",
            "purpose": "maskable any"
        },
        {
            "src": "/images/icons/icon-128x128.png",
            "sizes": "128x128",
            "type": "image/png",
            "purpose": "maskable any"
        },
        {
            "src": "/images/icons/icon-144x144.png",
            "sizes": "144x144",
            "type": "image/png",
            "purpose": "maskable any"
        },
        {
            "src": "/images/icons/icon-152x152.png",
            "sizes": "152x152",
            "type": "image/png",
            "purpose": "maskable any"
        },
        {
            "src": "/images/icons/icon-192x192.png",
            "sizes": "192x192",
            "type": "image/png",
            "purpose": "maskable any"
        },
        {
            "src": "/images/icons/icon-384x384.png",
            "sizes": "384x384",
            "type": "image/png",
            "purpose": "maskable any"
        },
        {
            "src": "/images/icons/icon-512x512.png",
            "sizes": "512x512",
            "type": "image/png",
            "purpose": "maskable any"
        }
    ],
    "screenshots": [
        {
            "src": "/images/screenshots/desktop.png",
            "sizes": "1280x720",
            "type": "image/png",
            "form_factor": "wide"
        },
        {
            "src": "/images/screenshots/mobile.png",
            "sizes": "750x1334",
            "type": "image/png",
            "form_factor": "narrow"
        }
    ],
    "categories": ["productivity", "utilities"],
    "shortcuts": [
        {
            "name": "New Post",
            "short_name": "New",
            "description": "Create a new post",
            "url": "/posts/create",
            "icons": [{"src": "/images/icons/new-post.png", "sizes": "192x192"}]
        }
    ]
}

Link in Blade layout:

{{-- resources/views/layouts/app.blade.php --}}
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    
    {{-- PWA Meta Tags --}}
    <link rel="manifest" href="/manifest.json">
    <meta name="theme-color" content="#4f46e5">
    <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="LaravelPWA">
    
    {{-- iOS Icons --}}
    <link rel="apple-touch-icon" href="/images/icons/icon-152x152.png">
    <link rel="apple-touch-icon" sizes="180x180" href="/images/icons/icon-192x192.png">
    
    {{-- Splash Screens for iOS --}}
    <link rel="apple-touch-startup-image" href="/images/splash/splash-640x1136.png" 
          media="(device-width: 320px) and (device-height: 568px)">
    <link rel="apple-touch-startup-image" href="/images/splash/splash-750x1334.png" 
          media="(device-width: 375px) and (device-height: 667px)">
    
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>

Step 2: Create Service Worker

The Service Worker is the core of PWA - it runs independently from the main thread and handles caching, offline mode, and push notifications.

Basic Service Worker

// public/sw.js

const CACHE_NAME = 'laravel-pwa-v1';
const OFFLINE_URL = '/offline';

// Files to cache immediately on install
const PRECACHE_ASSETS = [
    '/',
    '/offline',
    '/css/app.css',
    '/js/app.js',
    '/images/logo.png',
    '/images/icons/icon-192x192.png'
];

// Install Event - Cache static assets
self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(CACHE_NAME)
            .then((cache) => {
                console.log('Opened cache');
                return cache.addAll(PRECACHE_ASSETS);
            })
            .then(() => self.skipWaiting())
    );
});

// Activate Event - Clean old caches
self.addEventListener('activate', (event) => {
    event.waitUntil(
        caches.keys().then((cacheNames) => {
            return Promise.all(
                cacheNames
                    .filter((name) => name !== CACHE_NAME)
                    .map((name) => caches.delete(name))
            );
        }).then(() => self.clients.claim())
    );
});

// Fetch Event - Serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
    // Skip non-GET requests
    if (event.request.method !== 'GET') return;
    
    // Skip cross-origin requests
    if (!event.request.url.startsWith(self.location.origin)) return;
    
    event.respondWith(
        caches.match(event.request)
            .then((cachedResponse) => {
                if (cachedResponse) {
                    // Return cached version
                    return cachedResponse;
                }
                
                // Try network
                return fetch(event.request)
                    .then((response) => {
                        // Don't cache non-successful responses
                        if (!response || response.status !== 200) {
                            return response;
                        }
                        
                        // Clone and cache
                        const responseToCache = response.clone();
                        caches.open(CACHE_NAME)
                            .then((cache) => {
                                cache.put(event.request, responseToCache);
                            });
                        
                        return response;
                    })
                    .catch(() => {
                        // Network failed, try to return offline page for navigation
                        if (event.request.mode === 'navigate') {
                            return caches.match(OFFLINE_URL);
                        }
                    });
            })
    );
});

Register Service Worker

// resources/js/pwa.js

if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
        navigator.serviceWorker.register('/sw.js')
            .then((registration) => {
                console.log('SW registered:', registration.scope);
                
                // Check for updates
                registration.addEventListener('updatefound', () => {
                    const newWorker = registration.installing;
                    newWorker.addEventListener('statechange', () => {
                        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
                            // New version available
                            showUpdateNotification();
                        }
                    });
                });
            })
            .catch((error) => {
                console.error('SW registration failed:', error);
            });
    });
}

function showUpdateNotification() {
    if (confirm('New version available! Reload to update?')) {
        window.location.reload();
    }
}
// resources/js/app.js
import './bootstrap';
import './pwa';

Offline Page

// routes/web.php
Route::get('/offline', function () {
    return view('offline');
})->name('offline');
{{-- resources/views/offline.blade.php --}}
@extends('layouts.app')

@section('content')
<div class="min-h-screen flex items-center justify-center">
    <div class="text-center">
        <svg class="mx-auto h-24 w-24 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 
                  d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414"/>
        </svg>
        <h1 class="mt-4 text-2xl font-bold text-gray-900 dark:text-white">
            You are offline
        </h1>
        <p class="mt-2 text-gray-600 dark:text-gray-400">
            Check your internet connection and try again.
        </p>
        <button onclick="window.location.reload()" 
                class="mt-6 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
            Retry
        </button>
    </div>
</div>
@endsection

Step 3: Advanced Caching Strategies

Strategy 1: Cache First (Static Assets)

// Good for: CSS, JS, images, fonts
function cacheFirst(event) {
    event.respondWith(
        caches.match(event.request)
            .then((cached) => cached || fetch(event.request))
    );
}

Strategy 2: Network First (API/Dynamic Content)

// Good for: API responses, user-specific content
function networkFirst(event) {
    event.respondWith(
        fetch(event.request)
            .then((response) => {
                // Cache the fresh response
                const clone = response.clone();
                caches.open(CACHE_NAME).then((cache) => {
                    cache.put(event.request, clone);
                });
                return response;
            })
            .catch(() => caches.match(event.request))
    );
}

Strategy 3: Stale While Revalidate

// Good for: Content that doesn't change frequently
function staleWhileRevalidate(event) {
    event.respondWith(
        caches.open(CACHE_NAME).then((cache) => {
            return cache.match(event.request).then((cached) => {
                const fetchPromise = fetch(event.request).then((response) => {
                    cache.put(event.request, response.clone());
                    return response;
                });
                return cached || fetchPromise;
            });
        })
    );
}

Advanced Service Worker

// public/sw.js

const CACHE_NAME = 'laravel-pwa-v1';
const API_CACHE = 'api-cache-v1';
const IMAGE_CACHE = 'image-cache-v1';

// Routes and strategies
const STATIC_ASSETS = [
    '/',
    '/offline',
    '/manifest.json'
];

const CACHE_STRATEGIES = {
    // Static assets: Cache First
    static: /\.(css|js|woff2?|ttf|eot)$/,
    // Images: Cache First with size limit
    images: /\.(png|jpg|jpeg|gif|svg|webp|ico)$/,
    // API: Network First
    api: /^\/api\//,
    // Pages: Stale While Revalidate
    pages: /^\/(?!api)/
};

self.addEventListener('fetch', (event) => {
    const url = new URL(event.request.url);
    
    // Static assets
    if (CACHE_STRATEGIES.static.test(url.pathname)) {
        event.respondWith(cacheFirst(event.request, CACHE_NAME));
        return;
    }
    
    // Images with cache limit
    if (CACHE_STRATEGIES.images.test(url.pathname)) {
        event.respondWith(cacheFirstWithLimit(event.request, IMAGE_CACHE, 50));
        return;
    }
    
    // API calls
    if (CACHE_STRATEGIES.api.test(url.pathname)) {
        event.respondWith(networkFirst(event.request, API_CACHE));
        return;
    }
    
    // Pages
    if (event.request.mode === 'navigate') {
        event.respondWith(staleWhileRevalidate(event.request, CACHE_NAME));
        return;
    }
});

async function cacheFirst(request, cacheName) {
    const cached = await caches.match(request);
    if (cached) return cached;
    
    try {
        const response = await fetch(request);
        const cache = await caches.open(cacheName);
        cache.put(request, response.clone());
        return response;
    } catch {
        return new Response('Offline', { status: 503 });
    }
}

async function cacheFirstWithLimit(request, cacheName, maxItems) {
    const cached = await caches.match(request);
    if (cached) return cached;
    
    try {
        const response = await fetch(request);
        const cache = await caches.open(cacheName);
        
        // Limit cache size
        const keys = await cache.keys();
        if (keys.length >= maxItems) {
            await cache.delete(keys[0]);
        }
        
        cache.put(request, response.clone());
        return response;
    } catch {
        return new Response('', { status: 404 });
    }
}

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

async function staleWhileRevalidate(request, cacheName) {
    const cache = await caches.open(cacheName);
    const cached = await cache.match(request);
    
    const fetchPromise = fetch(request)
        .then((response) => {
            cache.put(request, response.clone());
            return response;
        })
        .catch(() => cached);
    
    return cached || fetchPromise;
}

Step 4: Push Notifications

Server Setup (Laravel)

composer require laravel-notification-channels/webpush
php artisan vendor:publish --provider="NotificationChannels\WebPush\WebPushServiceProvider"
php artisan migrate
// config/webpush.php
return [
    'vapid' => [
        'subject' => env('VAPID_SUBJECT'),
        'public_key' => env('VAPID_PUBLIC_KEY'),
        'private_key' => env('VAPID_PRIVATE_KEY'),
    ],
];

Generate VAPID keys:

php artisan webpush:vapid
# .env
VAPID_SUBJECT=mailto:your@email.com
VAPID_PUBLIC_KEY=your_public_key
VAPID_PRIVATE_KEY=your_private_key

User Model

// app/Models/User.php
use NotificationChannels\WebPush\HasPushSubscriptions;

class User extends Authenticatable
{
    use HasPushSubscriptions;
    
    // ...
}

Subscribe to Notifications (Client)

// resources/js/push-notifications.js

const VAPID_PUBLIC_KEY = document.querySelector('meta[name="vapid-public-key"]')?.content;

export async function subscribeToPush() {
    if (!('PushManager' in window)) {
        console.warn('Push messaging is not supported');
        return;
    }
    
    try {
        const registration = await navigator.serviceWorker.ready;
        
        // Check existing subscription
        let subscription = await registration.pushManager.getSubscription();
        
        if (!subscription) {
            subscription = await registration.pushManager.subscribe({
                userVisibleOnly: true,
                applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
            });
        }
        
        // Send subscription to server
        await saveSubscription(subscription);
        
        console.log('Push subscription successful');
        return subscription;
        
    } catch (error) {
        console.error('Failed to subscribe:', error);
        throw error;
    }
}

async function saveSubscription(subscription) {
    const response = await fetch('/api/push-subscriptions', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
        },
        body: JSON.stringify(subscription)
    });
    
    if (!response.ok) {
        throw new Error('Failed to save subscription');
    }
}

function urlBase64ToUint8Array(base64String) {
    const padding = '='.repeat((4 - base64String.length % 4) % 4);
    const base64 = (base64String + padding)
        .replace(/-/g, '+')
        .replace(/_/g, '/');
    
    const rawData = window.atob(base64);
    const outputArray = new Uint8Array(rawData.length);
    
    for (let i = 0; i < rawData.length; ++i) {
        outputArray[i] = rawData.charCodeAt(i);
    }
    return outputArray;
}

Handle Push in Service Worker

// public/sw.js (add to existing file)

self.addEventListener('push', (event) => {
    const data = event.data?.json() ?? {};
    
    const options = {
        body: data.body || 'You have a new notification',
        icon: data.icon || '/images/icons/icon-192x192.png',
        badge: '/images/icons/badge-72x72.png',
        image: data.image,
        data: {
            url: data.url || '/'
        },
        actions: data.actions || [
            { action: 'open', title: 'Open' },
            { action: 'close', title: 'Close' }
        ],
        vibrate: [200, 100, 200],
        tag: data.tag || 'default',
        renotify: true
    };
    
    event.waitUntil(
        self.registration.showNotification(data.title || 'Notification', options)
    );
});

self.addEventListener('notificationclick', (event) => {
    event.notification.close();
    
    if (event.action === 'close') return;
    
    const urlToOpen = event.notification.data?.url || '/';
    
    event.waitUntil(
        clients.matchAll({ type: 'window', includeUncontrolled: true })
            .then((windowClients) => {
                // Check if there is already a window/tab open
                for (const client of windowClients) {
                    if (client.url === urlToOpen && 'focus' in client) {
                        return client.focus();
                    }
                }
                // Open new window
                if (clients.openWindow) {
                    return clients.openWindow(urlToOpen);
                }
            })
    );
});

Send Notification (Laravel)

// app/Notifications/NewPostNotification.php
namespace App\Notifications;

use Illuminate\Notifications\Notification;
use NotificationChannels\WebPush\WebPushChannel;
use NotificationChannels\WebPush\WebPushMessage;

class NewPostNotification extends Notification
{
    public function __construct(
        private Post $post
    ) {}
    
    public function via($notifiable): array
    {
        return [WebPushChannel::class];
    }
    
    public function toWebPush($notifiable, $notification): WebPushMessage
    {
        return (new WebPushMessage)
            ->title('New Post!')
            ->body($this->post->title)
            ->icon('/images/icons/icon-192x192.png')
            ->image($this->post->cover_url)
            ->data([
                'url' => route('posts.show', $this->post),
                'id' => $this->post->id
            ])
            ->action('Read Now', 'open');
    }
}
// Send notification
$user->notify(new NewPostNotification($post));

Step 5: Background Sync

Background Sync allows sending requests even when offline:

// resources/js/background-sync.js

export async function syncData(tag, data) {
    if ('serviceWorker' in navigator && 'SyncManager' in window) {
        const registration = await navigator.serviceWorker.ready;
        
        // Store data in IndexedDB
        await storeForSync(tag, data);
        
        // Register sync
        await registration.sync.register(tag);
        
        console.log('Sync registered:', tag);
    } else {
        // Fallback: send immediately
        await sendData(data);
    }
}

async function storeForSync(tag, data) {
    const db = await openDB();
    const tx = db.transaction('sync-queue', 'readwrite');
    await tx.store.put({ tag, data, timestamp: Date.now() });
    await tx.done;
}

async function openDB() {
    return new Promise((resolve, reject) => {
        const request = indexedDB.open('pwa-sync', 1);
        
        request.onerror = () => reject(request.error);
        request.onsuccess = () => resolve(request.result);
        
        request.onupgradeneeded = (event) => {
            const db = event.target.result;
            if (!db.objectStoreNames.contains('sync-queue')) {
                db.createObjectStore('sync-queue', { keyPath: 'tag' });
            }
        };
    });
}
// public/sw.js (add to existing file)

self.addEventListener('sync', (event) => {
    console.log('Sync event:', event.tag);
    
    if (event.tag.startsWith('sync-')) {
        event.waitUntil(processSyncQueue(event.tag));
    }
});

async function processSyncQueue(tag) {
    const db = await openSyncDB();
    const tx = db.transaction('sync-queue', 'readwrite');
    const store = tx.objectStore('sync-queue');
    const item = await store.get(tag);
    
    if (!item) return;
    
    try {
        const response = await fetch('/api/sync', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(item.data)
        });
        
        if (response.ok) {
            await store.delete(tag);
            console.log('Sync successful:', tag);
        }
    } catch (error) {
        console.error('Sync failed:', error);
        // Will retry automatically
    }
}

function openSyncDB() {
    return new Promise((resolve) => {
        const request = indexedDB.open('pwa-sync', 1);
        request.onsuccess = () => resolve(request.result);
    });
}

Step 6: Install Prompt

// resources/js/install-prompt.js

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (e) => {
    // Prevent Chrome 67+ from automatically showing the prompt
    e.preventDefault();
    
    // Stash the event so it can be triggered later
    deferredPrompt = e;
    
    // Show custom install button
    showInstallButton();
});

function showInstallButton() {
    const installButton = document.getElementById('install-button');
    if (installButton) {
        installButton.classList.remove('hidden');
        installButton.addEventListener('click', installApp);
    }
}

async function installApp() {
    if (!deferredPrompt) return;
    
    // Show the install prompt
    deferredPrompt.prompt();
    
    // Wait for the user's response
    const { outcome } = await deferredPrompt.userChoice;
    
    console.log(`User response: ${outcome}`);
    
    // Clear the deferred prompt
    deferredPrompt = null;
    
    // Hide install button
    document.getElementById('install-button')?.classList.add('hidden');
}

window.addEventListener('appinstalled', () => {
    console.log('PWA was installed');
    // Analytics: track installation
});
{{-- Install button --}}
<button id="install-button" 
        class="hidden fixed bottom-4 right-4 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg">
    <span class="flex items-center gap-2">
        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 
                  d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
        </svg>
        Install App
    </span>
</button>

Step 7: Testing and Debugging

Chrome DevTools

  1. Application tab: View Service Worker, Manifest, Cache Storage
  2. Network tab: Select "Offline" to test
  3. Lighthouse: Audit PWA compliance

PWA Checklist

# Lighthouse CLI
npm install -g lighthouse
lighthouse https://your-app.test --view

PWA Criteria:

  • ✅ Responds with 200 when offline
  • ✅ Contains web app manifest
  • ✅ Registers a service worker
  • ✅ Uses HTTPS
  • ✅ Redirects HTTP to HTTPS
  • ✅ All app URLs are loadable offline
  • ✅ Manifest has icons

Laravel PWA Package (Optional)

You can use a package to automate setup:

composer require silviolleite/laravelpwa
php artisan vendor:publish --provider="LaravelPWA\Providers\LaravelPWAServiceProvider"
// config/laravelpwa.php
return [
    'name' => 'Laravel PWA',
    'manifest' => [
        'name' => env('APP_NAME', 'Laravel PWA'),
        'short_name' => 'PWA',
        'start_url' => '/',
        'background_color' => '#ffffff',
        'theme_color' => '#4f46e5',
        'display' => 'standalone',
        'orientation' => 'any',
        'icons' => [
            '72x72' => '/images/icons/icon-72x72.png',
            '96x96' => '/images/icons/icon-96x96.png',
            // ...
        ],
    ],
];

Conclusion

PWA with Laravel provides a native app experience without developing separately for each platform:

  • Installable: Users can "install" the app on their home screen
  • Offline-first: App works even without network
  • Push Notifications: Engage users like native apps
  • Fast: Service worker caching significantly improves speed

PWA doesn't completely replace native apps in all cases, but for most use cases, it's a cost-effective and time-efficient solution.

References

Comments