ladumor/laravel-pwa
Add Progressive Web App support to any Laravel app in minutes: generates the manifest and service worker, enables offline caching, push notifications, and an install prompt, and publishes configurable icons and meta tags via a simple artisan command, with no build tools required.
1. Install and publish
composer require ladumor/laravel-pwa
php artisan vendor:publish --provider="Ladumor\LaravelPwa\PWAServiceProvider"
Laravel auto-discovers the service provider. The publish step drops config/pwa.php (manifest metadata, icon map, offline toggle) and, depending on version, an offline view plus icon assets. From v1.0.1 there is also a command that publishes a debug asset on demand — handy when you want to inspect what the package thinks it's serving without reading vendor/. After publishing, run php artisan list | grep -i pwa — the package ships an artisan namespace (laravel-pwa:*) whose exact commands vary between releases, and that's your quickest inventory of what this version can generate for you.
2. Look at the config first
Everything you'll tune day-to-day lives in config/pwa.php:
return [
'manifest' => [
'name' => env('APP_NAME', 'Laravel PWA'),
'short_name' => env('APP_NAME', 'Laravel'),
'start_url' => '/',
'display' => 'standalone',
'background_color' => '#ffffff',
'theme_color' => '#0d6efd',
'icons' => [
'192x192' => ['path' => '/icons/icon-192x192.png', 'type' => 'image/png'],
'512x512' => ['path' => '/icons/icon-512x512.png', 'type' => 'image/png'],
],
],
'enable' => true,
'offline' => true,
];
This array is the single source of truth: it's serialised by the package's route into /manifest.json and echoed into the <head> by the Blade directive.
3. Wire it into your root layout
Add the two directives to the single layout every page inherits (resources/views/layouts/app.blade.php):
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
@PwaHead {{-- manifest link, theme-color, apple-touch-icon, meta tags --}}
</head>
<body>
@yield('content')
@PwaScript {{-- registers /serviceworker.js --}}
</body>
</html>
4. Verify before you build anything else
php artisan serve
curl -s http://localhost:8000/manifest.json | jq .
curl -sI http://localhost:8000/serviceworker.js
Then open Chrome DevTools → Application → Manifest (should render with zero warnings) and Service Workers (should show activated and is running). That 60-second check catches ~90% of PWA misconfiguration, and it's the first thing to do on any new project. Watch the console while you do it: v1.0.1 fixes a debug console error, so if you're coming from 1.0.0 and saw stray console.error noise on load, that's the release that clears it.
5. First real use case
The highest-value, lowest-effort win is a read-only shell: manifest + service worker + a precached offline fallback page. That alone gets you "Add to Home Screen" on Android, standalone launch, and a graceful offline experience — no custom caching logic required. Ship that first, measure installs, and only then start hand-tuning the service worker.
Config-driven manifest, environment-aware. Keep brand values in config/pwa.php but source the volatile bits from .env so staging and production don't share a manifest:
'theme_color' => env('PWA_THEME_COLOR', '#0d6efd'),
'start_url' => env('PWA_START_URL', '/'),
'name' => env('PWA_NAME', config('app.name')),
Note start_url should match your authenticated landing page (/dashboard), not / — a start_url that 302s to login makes the installed app feel broken on every cold launch.
Serve the manifest dynamically instead of committing a static file. The package's route approach wins over public/manifest.json because the payload follows config, and config follows env. For multi-tenant or white-label setups, override the route and build the array from the current tenant:
// routes/web.php — after the package's routes are registered
Route::get('/manifest.json', function () {
$tenant = app('tenant');
return response()->json([
'name' => $tenant->name,
'start_url' => '/?tenant=' . $tenant->slug,
'theme_color'=> $tenant->brand_color,
// ...
], 200, ['Content-Type' => 'application/manifest+json']);
});
Use the debug asset as a local diagnostics view (v1.0.1+). The new publish-debug command gives you a package-shipped debug asset you can drop into a dev environment and hit in the browser to confirm the manifest/service worker wiring — much faster than curl-and-squint when you're chasing a registration failure. Publish it in local/staging only; see the gotcha below about keeping it out of production.
Deploy-pipeline hook. The manifest and service worker must be regenerated whenever config changes, and config:cache silently freezes old values:
php artisan config:cache
php artisan route:cache
# if your version exposes a generator command, run it after config:cache
Pin the patch line. ladumor/laravel-pwa:^1.0.1 is safe to adopt — 1.0.1 is a patch release (a debug-asset publish command plus a console-error fix), with no config keys or routes changed. Because the package moves slowly, pin a caret constraint and read the release notes on each bump rather than floating *.
Cache versioning on every deploy. The single most common post-launch bug is users stuck on yesterday's JS. Whatever cache name the shipped service worker uses, treat it as a build stamp — derive it from a commit hash or APP_VERSION env var and bump it on each release. If the package doesn't expose that hook, copy serviceworker.js into your own controller/view and own the CACHE_NAME constant.
Cache strategy that matches a Laravel app. Default package behaviour caches the shell; for a real app you want network-first for HTML and cache-first for hashed assets:
/manifest.json, anything rendered by Blade./build/* (Vite output is content-hashed, so it's immutable), fonts, images./api/*, /login, /logout, anything with CSRF in the path.Never cache.put() a response to an authenticated route — one user's dashboard cached on a shared device is both a bug and a disclosure.
Install-prompt UX. There's no package API for this; handle it yourself in a small Alpine/Livewire component:
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
// reveal your "Install app" button
});
Dismiss permanently once declined (localStorage), and only show it after the user has had one meaningful interaction — showing it on first paint trains people to ignore it. Note beforeinstallprompt never fires on iOS; you'll need a manual "Share → Add to Home Screen" hint there.
Testing. Add two cheap guards to your CI:
public function test_manifest_is_valid(): void
{
$this->get('/manifest.json')
->assertOk()
->assertHeader('Content-Type', 'application/manifest+json')
->assertJsonPath('start_url', '/dashboard')
->assertJsonCount(2, 'icons');
}
Plus a Lighthouse CI budget (performance >= 0.9, pwa category checks) run against a built preview, not artisan serve.
Service workers only run over HTTPS or localhost. On a plain http://192.168.x.x dev box, registration silently fails with a console error most people filter out. Use valet secure, herd, or an ngrok tunnel for device testing — this is the #1 "the package doesn't work" report.
Cache the config, then forget you did. php artisan config:cache is standard in deploy scripts, and afterwards edits to config/pwa.php and .env do nothing until you re-cache. If the manifest looks stale on staging, run php artisan config:clear before debugging anything else.
The manifest file itself gets cached by the browser. Chrome caches /manifest.json aggressively. During development, DevTools → Application → Update on reload, and don't serve it with a long Cache-Control. In production give it a short TTL — a stale manifest means your icon and name changes never reach installed users.
Icons are where installability dies. Chrome requires a real 192×192 and 512×512 PNG; SVG is not accepted for installability, and a mislabelled .png that's actually a JPEG fails silently. Add "purpose": "maskable" to the 512 icon or Android will crop your logo into a circle and clip the edges. Verify with DevTools → Manifest → "Installability" — it names the exact missing piece.
Scope mismatches. The service worker's scope is determined by its URL. Served from /serviceworker.js, it controls the whole origin — good. If you ever move it to /js/sw.js (or let a CDN rewrite the path), it only controls /js/* and precaching breaks in ways that look like random network errors. Also ensure start_url falls inside that scope.
A stuck service worker will haunt you. If pages stop updating, unregister manually: DevTools → Application → Service Workers → Unregister, then Application → Storage → Clear site data. Hard-reload alone does not evict an active worker. Build a habit of doing this after every deploy-pipeline change.
skipWaiting is a trade-off, not a free win. Calling self.skipWaiting() activates the new worker immediately, but if it takes over mid-session with a different asset manifest, users can hit chunk-load errors on your Vite bundles. Safer default: skipWaiting() plus clients.claim(), and keep chunk hashes content-addressed so old chunks aren't orphaned.
Offline page must be fully self-contained. The fallback HTML is served when there's no network — no CDN fonts, no external CSS, no @vite. Inline the styles. Test it properly with DevTools → Network → Offline, and also test the cold-start case (offline and fresh cache) rather than only the warm one.
Don't let the worker intercept what Laravel owns. POST requests, /logout, file uploads, and anything with an X-CSRF-TOKEN should pass through untouched. A worker that responds to a POST from cache produces the most confusing 419s you'll ever debug.
Keep the debug asset out of production (v1.0.1+). The published debug asset is exactly that — a debug aid. Gate it behind APP_DEBUG or exclude it from your deploy artifact; an endpoint that echoes manifest config and service-worker state is a small but real information disclosure on a live host. If you publish it with the vendor:publish command, add it to your .gitignore/deploy excludes rather than committing it.
Extension points, in order of preference. (1) Config values — always try this first. (2) view()->composer / publishing and overriding the offline view. (3) Override the package routes with your own controller returning custom JSON — clean and upgrade-safe. (4) Publish and edit the service worker source directly — last resort, because you now own merges. The package is thin enough that options 2–3 cover essentially everything.
Know what you're depending on. MIT-licensed and small enough to read in one sitting, but it has effectively no downstream dependents and a very low release cadence. That's not a blocker for a package this size — the directives and the two routes are trivially reproducible — but it does mean you should skim vendor/ladumor/laravel-pwa/src once rather than assume the README matches the release you installed. v1.0.1 is a good example: the changelog is two lines and the useful detail (the debug-publish command's name) is only discoverable via php artisan list. If you find yourself fighting it, writing your own manifest route and a ~40-line service worker is a genuinely reasonable exit; you lose nothing but the Blade directives.
Debugging checklist, in order: DevTools → Application (registration + manifest) → Console (registration errors — and on 1.0.0, package-emitted console.error noise, fixed in 1.0.1) → chrome://serviceworker-internals (bypass DevTools quirks) → Network tab with "Disable cache" unchecked (to see what the worker actually serves). If the worker registered but nothing is cached, check the install event's cache.addAll() — one 404 rejects the whole batch and install fails wholesale, which is why a single broken icon path can take down the entire PWA.
How can I help you explore Laravel packages today?