Xây Dựng Progressive Web App (PWA) với Laravel

· 16 min read

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

  1. HTTPS: Bắt buộc (trừ localhost)
  2. Service Worker: Xử lý caching và offline
  3. 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

  1. Application tab: Xem Service Worker, Manifest, Cache Storage
  2. Network tab: Chọn "Offline" để test
  3. 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.

Tài Liệu Tham Khảo

Bình luận