Building Progressive Web Apps (PWA) with Laravel
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
- HTTPS: Required (except localhost)
- Service Worker: Handles caching and offline
- 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
- Application tab: View Service Worker, Manifest, Cache Storage
- Network tab: Select "Offline" to test
- 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.