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