Xây Dựng Progressive Web App (PWA) với Laravel
Giới Thiệu
Progressive Web Apps (PWA) là một công nghệ cho phép ứng dụng web hoạt động như ứng dụng native - có thể cài đặt trên home screen, hoạt động offline, nhận push notifications, và nhiều hơn nữa.
Tại Sao Cần PWA?
| Tính năng | Website thường | PWA | Native App |
|---|---|---|---|
| Cài đặt | ❌ | ✅ | ✅ |
| Offline | ❌ | ✅ | ✅ |
| Push Notifications | ❌ | ✅ | ✅ |
| Camera access | Hạn chế | ✅ | ✅ |
| App Store | ❌ | Không cần | Bắt buộc |
| Update | Tự động | Tự động | User phải update |
| Phát triển | Nhanh | Nhanh | Chậm hơn |
| Cross-platform | ✅ | ✅ | Cần nhiều codebases |
PWA Requirements
- HTTPS: Bắt buộc (trừ localhost)
- Service Worker: Xử lý caching và offline
- Web App Manifest: Metadata cho việc cài đặt
Bước 1: Tạo Web App Manifest
Web App Manifest là file JSON chứa thông tin về ứng dụng:
// 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 trong 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>
Bước 2: Tạo Service Worker
Service Worker là core của PWA - nó chạy độc lập với main thread và xử lý caching, offline, push notifications.
Service Worker Cơ Bản
// public/sw.js
const CACHE_NAME = 'laravel-pwa-v1';
const OFFLINE_URL = '/offline';
// Các files cần cache ngay khi 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);
}
});
})
);
});
Đăng Ký 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('Có phiên bản mới! Tải lại trang để cập nhật?')) {
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">
Bạn đang offline
</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Kiểm tra kết nối internet và thử lại.
</p>
<button onclick="window.location.reload()"
class="mt-6 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Thử lại
</button>
</div>
</div>
@endsection
Bước 3: Caching Strategies Nâng Cao
Strategy 1: Cache First (Static Assets)
// Tốt cho: 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)
// Tốt cho: 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
// Tốt cho: Content thay đổi không thường xuyên
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 và 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;
}
Bước 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 (thêm vào)
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
const options = {
body: data.body || 'Bạn có thông báo mới',
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: 'Mở' },
{ action: 'close', title: 'Đóng' }
],
vibrate: [200, 100, 200],
tag: data.tag || 'default',
renotify: true
};
event.waitUntil(
self.registration.showNotification(data.title || 'Thông báo', 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('Bài viết mới!')
->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('Đọc ngay', 'open');
}
}
// Gửi notification
$user->notify(new NewPostNotification($post));
Bước 5: Background Sync
Background Sync cho phép gửi requests ngay cả khi 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 (thêm vào)
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);
});
}
Bước 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>
Cài đặt ứng dụng
</span>
</button>
Bước 7: Testing và Debug
Chrome DevTools
- Application tab: Xem Service Worker, Manifest, Cache Storage
- Network tab: Chọn "Offline" để test
- Lighthouse: Audit PWA compliance
Checklist PWA
# 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 (Tùy chọn)
Có thể sử dụng package để tự động hóa:
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',
// ...
],
],
];
Kết Luận
PWA với Laravel mang lại trải nghiệm native app mà không cần phát triển riêng cho từng platform:
- Installable: Người dùng có thể "cài" app lên home screen
- Offline-first: App hoạt động ngay cả khi mất mạng
- Push Notifications: Tương tác với users như native app
- Fast: Service worker caching tăng tốc đáng kể
PWA không thay thế hoàn toàn native apps trong mọi trường hợp, nhưng cho phần lớn use cases, đây là giải pháp hiệu quả về chi phí và thời gian phát triển.