mxent / pwax
Write Vue components as Blade views. Ship them as a progressive web app.
Requires
- php: ^8.2
- illuminate/cache: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/filesystem: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/view: ^12.0|^13.0
- matthiasmullie/minify: ^1.3
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0|^11.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0|^12.0
This package is auto-updated.
Last update: 2026-08-17 08:06:31 UTC
README
Write Vue components as Blade views. Ship them as a progressive web app.
Pwax lets you write a Vue single-file component as a Blade view — <template>,
<script> and <style> in one file — and serves it to the browser, where Vue compiles
it at runtime. You get an SPA with client-side routing, a service worker and an
installable app manifest, and you never leave PHP or run a frontend build.
{{-- resources/views/pages/home.blade.php --}} <template> <div class="home"> <h1>@{{ greeting }}</h1> <button type="button" @click="count++">Clicked @{{ count }} times</button> </div> </template> <script> export default { data() { return { greeting: @json($greeting), count: 0 }; }, }; </script> <style scoped> .home { padding: 2rem; } </style>
Route::get('/', fn () => pwaxRender('pages.home', [ 'greeting' => 'Hello, ' . auth()->user()?->name, ]))->name('index');
That is the whole application. No npm run build, no .vue files, no separate route
table in JavaScript.
Contents
- How it works
- Is this the right tool?
- Requirements
- Installation
- Writing components
- Blade and Vue together
- Passing data
- Routing
- Navigating
- Redirects and errors
- Importing components
- Scoped styles
- Plugins, directives and middleware
- Progressive web app
- Being opened by the operating system
- Push notifications
- Extending the service worker
- Frontend assets
- Performance
- Precompiling templates
- Security
- Configuration reference
- Artisan commands
- JavaScript API
- Upgrading
- Testing
- Contributing
How it works
A component is a Blade view. Pwax renders it, splits it into its parts, and returns them to a small client runtime that hands the template to Vue's in-browser compiler.
Browser Laravel
│ │
│ GET /profile │
├──────────────────────────────►│ Route → pwaxRender('pages.profile')
│ │ render Blade → split blocks → scope styles
│ ◄── HTML shell ──────────────┤ → embed template + script + style in the shell
│ │
│ (in parallel, static, cacheable, no component request at all)
├── vue.global.prod.js ────────►│
├── vue-router / pinia ────────►│
├── pwax.js ───────────────────►│
│ │
│ first paint
│ │
│ a @pwaxImport('components.modal') first renders
├── GET /__pwax__/c/{id}.js ───►│ the component, as a real ES module:
│ ◄── module ──────────────────┤ template + script + styles + scope in one file
│ │
│ click <RouterLink to="/settings">
├── GET /settings ─────────────►│ X-Pwax-Component: true
│ ◄── JSON payload ────────────┤ → same route, JSON instead of the shell
│ │
The same Laravel route answers both a browser navigation and an SPA navigation. Which
one you get is decided by the X-Pwax-Component header, and every response says so in
Vary.
Two shapes of component, for one reason. A page is rendered with controller data, so it cannot be re-derived from its view name alone — it travels inside the page response, script and all. An imported component takes no controller data, so it is addressable at a stable signed URL, which makes it HTTP-cacheable and importable as a real module.
Is this the right tool?
Pwax makes one trade, and everything else follows from it: templates are compiled in the browser instead of at build time.
What you get for it:
- No build step. No
npm install, no Vite config, nodist/to deploy, no build that can be out of date. Editing a Blade view is deploying a component. - One language for the whole page. A component is a Blade view, so
@if,@json,auth(), policies, translations and route helpers all work inside it — server-rendered where it makes sense, reactive where it does not. - One list of routes. The Laravel route table is the application's route table; there is no second one in JavaScript to keep in step with it.
- Installable and offline by default. A service worker, an app manifest and a precached shell come with the package rather than being assembled afterwards.
What it costs:
- 20 kB gzipped, over and above the runtime-only build, for Vue's compiler.
script-src 'unsafe-eval'in your Content-Security-Policy, because compiling a template in the browser means calling theFunctionconstructor. See Security.- No
<script setup>, no single-file-component TypeScript, no tree-shaking of your own components — all three are build-time features, and there is no build.
That trade is a good one for internal tools, admin panels, dashboards, prototypes, and anything that has to be installable and work offline without a frontend pipeline. It is the wrong one if your team already runs a frontend toolchain happily and wants what a build step buys.
If you have CI and the first two costs are the ones that bite — a policy that forbids
unsafe-eval, or a first paint over a slow connection — you can buy them back without
giving up the model. pwax:compile compiles the templates
ahead of time and is entirely opt-in; the default path stays exactly as described above.
Requirements
- PHP 8.2 or higher (8.3+ if you are on Laravel 13)
- Laravel 12 or 13
Node is not required to use Pwax. It is only needed to contribute to the client runtime.
Installation
composer require mxent/pwax php artisan pwax:install
pwax:install publishes config/pwax.php and copies Vue, Vue Router and Pinia into
public/vendor/pwax. Add --views to publish the Blade views as well.
Flags
| Flag | Purpose |
|---|---|
--force |
Overwrite anything that already exists in the publish targets |
--views |
Also publish the Blade views (the SPA shell, the loader and error templates) |
--ai |
Also publish the Pwax skill for AI assistants |
--no-assets |
Skip the framework copy; the application serves Vue itself |
Publish tags
pwax:install is a thin wrapper around vendor:publish, and the same tags are
available at any time:
| Tag | Publishes |
|---|---|
pwax-config |
config/pwax.php |
pwax-assets |
Vue, Vue Router, Pinia into public/vendor/pwax |
pwax-views |
The shell, loader, error and offline Blade views |
pwax-service-worker |
The offline document the worker serves |
pwax-ai |
The Pwax skill into .ai/skills/pwax/SKILL.md |
pwax-push |
The annotated push-endpoint view pwax:push-endpoint emits |
Then point a route at a component and create it:
php artisan pwax:component pages.home
// routes/web.php Route::get('/', fn () => pwaxRender('pages.home'))->name('index');
Check your setup at any time:
php artisan pwax:doctor
After every
composer update, re-runphp artisan vendor:publish --tag=pwax-assets --forceso the published Vue build stays in step with the package.pwax:doctorwill tell you if it drifts.
Writing components
A component is a Blade view containing any of <template>, <script> and <style>.
<template> <article class="post"> <h1>@{{ post.title }}</h1> <p>@{{ post.body }}</p> </article> </template> <script> export default { props: { post: { type: Object, required: true } }, }; </script> <style scoped> .post { max-width: 60ch; } </style>
Rules worth knowing:
<template>is the root. Nested<template v-if>and<template #slot>work; the parser matches closing tags by depth.<script>is an ES module.export defaultis your component;importstatements resolve relative to the component's URL; named exports are importable by other components.- Multiple blocks are allowed. Several
<script>blocks are concatenated, several<style>blocks are merged. <script src>and<link rel="stylesheet">are treated as external assets and loaded before the component renders, rather than inlined.- One HTML rule applies: a literal
</script>inside a JavaScript string ends the block. Write<\/script>, exactly as in a plain HTML page.
Blade and Vue together
Both languages use {{ }} and @. Two rules cover it:
Escape Vue's interpolation with @{{ }}. Blade renders @{{ x }} as {{ x }}, so
Vue receives it:
<h1>@{{ title }}</h1> {{-- Vue renders this --}} <h1>{{ $title }}</h1> {{-- Blade renders this, once, on the server --}}
Most @ attributes are safe, but a few collide. Blade only compiles @name when
name is a registered directive. @click, @submit, @input and friends pass
through untouched — but Laravel ships directives called @error, @class, @style,
@checked, @disabled, @selected and @readonly. Writing @error="onError" in a
Vue template invokes Blade's @error directive instead.
Use the v-on: longhand for those:
<video v-on:error="onError"></video> {{-- not @error --}} <input v-on:change="save"> {{-- @change is fine, but be consistent --}}
For a larger block, @verbatim disables Blade entirely:
@verbatim <template> <p>{{ message }}</p> <video @error="onError"></video> </template> @endverbatim
Note that @verbatim also disables @json() and {{ $phpVariable }}, so keep
server-injected values outside it.
Passing data
Pass an array as the second argument. It becomes ordinary Blade view data:
Route::get('/posts/{post}', fn (Post $post) => pwaxRender('pages.post', [ 'post' => $post, 'canEdit' => auth()->user()?->can('update', $post), ]))->name('posts.show');
<script> export default { data() { return { post: @json($post), canEdit: @json($canEdit), }; }, }; </script>
@json() escapes for a JavaScript context — always prefer it to {!! json_encode(...) !!}.
Because components render inside your middleware stack, auth(), session(),
request() and policies all work exactly as they do in any other Blade view.
Routing
Routes stay in routes/web.php. There is no second route table to maintain. Vue Router
hands every path to Pwax, which asks the server what to render.
Route::get('/', fn () => pwaxRender('pages.home'))->name('index'); Route::get('/about', fn () => pwaxRender('pages.about'))->name('about'); Route::middleware('auth')->group(function () { Route::get('/settings', fn () => pwaxRender('pages.settings'))->name('settings'); });
Link between pages with <RouterLink>, using pwaxRoute() to resolve named routes:
<template> <nav> <RouterLink to="{{ pwaxRoute('index') }}">Home</RouterLink> <RouterLink to="{{ pwaxRoute('posts.show', ['post' => 1]) }}">First post</RouterLink> </nav> </template>
pwaxRoute() returns a path (/posts/1); pass true as the third argument for an
absolute URL. Unlike 1.x's router(), an unknown route name throws when
APP_DEBUG is on rather than silently sending the link to your home page. With
debug off, pwaxRoute() logs and falls back to the route named in pwax.home,
which itself falls back to / if it is missing — so a typo never breaks a build,
it just sends everyone home until you notice in the log.
History mode
Pwax uses the History API by default, so URLs have no #. Your web server must send
unknown paths to index.php — Laravel's default nginx/Apache configuration already
does. If yours cannot, set hash_route => true.
Redirects and errors
fetch follows redirects transparently, which would normally turn a 302 from your
auth middleware into an unparseable HTML body. Pwax's middleware translates them:
| Server response | What the client does |
|---|---|
return redirect('/somewhere') |
SPA navigation, no page reload |
return redirect()->away(...) |
full page navigation |
auth middleware rejects the request |
full page load of your login screen |
419 expired CSRF token |
one full page reload to pick up a fresh token |
404, 403, 401, 5xx |
renders the error template |
The first two are translated by Pwax's middleware. The next two cannot be — auth and
VerifyCsrfToken throw, so their redirects are produced by the exception handler
outside the middleware pipeline — so the client handles them instead, by treating a
followed redirect that returns HTML as an instruction to reload.
One reload for a 419, per tab. The reload only helps if it returns a different
document, and under navigation_strategy => 'cache-first' it does not: the worker answers
that navigation from disk, so the same expired token comes back and the page would reload
forever. A second 419 renders the error template instead.
So this just works:
Route::middleware('auth')->get('/settings', fn () => pwaxRender('pages.settings'));
An unauthenticated visitor is taken to your login page by the SPA router.
Customise the error and loading markup by publishing the views, or by pointing
pwax.blade.error / pwax.blade.loader at your own:
{{-- resources/views/pwax/error.blade.php --}} <div class="error" role="alert"> <h1 v-text="error.statusText"></h1> <p v-text="error.message"></p> <button type="button" @click="retry">Try again</button> </div>
error exposes status, statusText and message; retry() refetches the page.
Three screens share one design — the page that would not load, the runtime that would not start, and the service worker's offline document — because to a visitor they are the same event, and three different-looking apologies read as three different bugs. Each is centred, legible in light and dark, and offers the way out that applies.
They take your colours, not their own. The text inherits whatever your layout sets,
and the quieter parts are the same colour at lower opacity. That is deliberate: these
screens paint inside your page, on your background, so a rule that switched on
prefers-color-scheme would put near-white text on the light page of an app that has no
dark mode, the moment a visitor's system had one. They cannot know your background, so
they do not guess at it.
Override any of it in your own stylesheet, which loads after the shell's:
:root { --pwax-screen-accent: #0c83ff; /* buttons and focus rings; defaults to your spinner colour */ --pwax-screen-accent-fg: #fff; --pwax-screen-fg: #111; /* only if inheriting is not what you want */ --pwax-screen-muted: #666; --pwax-screen-muted-opacity: 1; /* set alongside --pwax-screen-muted */ --pwax-screen-line: #e5e5e5; --pwax-screen-bg: #fafafa; }
The markup hooks are .pwax-screen, .pwax-screen__code, .pwax-screen__title,
.pwax-screen__message, .pwax-screen__actions and .pwax-button (plus
.pwax-button--quiet). The worker's offline document carries its own copy of the styles,
because it answers a navigation to a page that never loaded — there is no shell stylesheet
to inherit. Publish it with --tag=pwax-service-worker to change it.
Navigating
A navigation does not unmount the page you are on. The current page stays rendered while the next one is fetched, compiled and has its styles applied; only then do the two swap, with a fade. Nothing collapses to a spinner in between, and a navigation that fails leaves you where you were.
The one thing that moves while you wait is a progress bar across the top of the window. It waits 250 ms before appearing — most navigations finish well inside that, and a bar that flashes on and off for each of them reads as jitter — then eases towards a ceiling it never reaches, because the payload has no length the browser can know in advance. Claiming to be finished and then waiting is what makes a progress bar feel like a lie.
There are two waits and they get different answers, because they are different waits:
first load document arrives ─► spinner ─► app mounts ─► page appears
navigation bar starts ─► payload ─► bar completes ─► page fades in
A document arriving is the browser's own wait — the address bar moves, the tab spins — and the shell's centred spinner covers the gap between the HTML landing and the runtime mounting. A navigation has none of that, which is what the bar is for. It is not rendered by the shell: the runtime creates it on the first navigation slow enough to need one, so an application whose navigations are all fast never puts it in the document.
customization.init_spinner => false turns the spinner off, for an application that
renders its own skeleton into the mount element instead.
'progress' => [ 'enabled' => true, 'color' => null, // defaults to customization.init_spinner_color 'height' => 3, 'delay' => 250, 'trickle' => true, ], 'transition' => [ 'duration' => 150, // cross-fade length in milliseconds ],
The page swap is wrapped in the browser's View Transitions API:
the outgoing page is snapshotted, the new page is committed in the same frame, and the
browser cross-fades between them. The bundled transition fades with opacity alone —
anything that changes an element's size or position is a second kind of movement to
follow. transition.duration is the cross-fade length; duration: 0 is an instant swap
and means the new page is in the DOM before the screen has a chance to draw the empty
router-view in between. Browsers without the API fall back to a synchronous swap, which
is the previous behaviour preserved. prefers-reduced-motion is honoured by the browser
itself: the transition runs but the cross-fade is replaced with a synchronous swap.
Wrap your own slow work in the same indicator:
window.pwax.progress.start(); await fetch('/api/report'); window.pwax.progress.done();
The loader view is now only for the case with nothing to keep — the first paint of an
application whose landing page was not inlined. It ships silent, speaking only to screen
readers, which have no progress bar to look at. Point pwax.blade.loader at a skeleton if
you would rather show one.
Use
v-text, notv-html. Part oferrorderives from the HTTP response, and rendering that as HTML would make reflected content executable.
Importing components
Use the @pwax directive to reference another component:
<template> <div> <Modal v-if="open" @close="open = false" /> </div> </template> <script> export default { components: { Modal: @pwaxImport('components.modal'), }, data() { return { open: false }; }, }; </script>
To pick a named export instead of the default one:
Backdrop: @pwaxImport('Backdrop from components.modal'),
@pwax returns a Vue async component, resolved the first time it renders. That means:
- Circular imports work. Two components can reference each other freely.
- Nothing loads until it is needed. A modal behind a
v-ifis never fetched until the modal opens. - Each component is fetched once per session, and cached by the browser after that.
Do not wrap it in an arrow function.
Modal: @pwaxImport('components.modal'), {{-- correct --}} Modal: () => @pwaxImport('components.modal'), {{-- renders as [object Object] --}}
() => Componentis the Vue 2 idiom for lazy components and was dropped in Vue 3. Vue 3 sees a function and treats the entry as a functional component, calls it during render, and gets a component object back where it expected vnodes.@pwaxis already lazy — the arrow adds nothing. If you do write it, Pwax renders an explanation on the page instead of[object Object].
The directive is
@pwaxImport, and it can never be@import. A Blade directive namedimportalso matches the CSS at-rule@import url(...)inside every<style>block in the application — see Upgrading. You can rename it withpwax.components.directive; the nameimportis rejected at boot.
Scoped styles
Add scoped to a <style> block and its rules only apply to that component:
<template> <div class="card"><p class="title">Hi</p></div> </template> <style scoped> .card { border: 1px solid #ddd; } .title { font-weight: 600; } </style>
Pwax rewrites the selectors to .card[data-pwax-a1b2c3d4] and stamps the template's
elements with the matching attribute, so a rule can only ever reach the component it was
written in. The scope id is derived from the view's contents, so it is stable between
requests and changes when the component does.
Two escape hatches, keeping the names Vue authors already type:
.wrapper :deep(.child-component-class) { color: red; } /* reach into a child */ :global(.body-modifier) { overflow: hidden; } /* opt out entirely */
@keyframes, @font-face and @import are left untouched. Turn the whole feature off
with pwax.components.scoped_styles => false.
Plugins, directives and middleware
Register Vue plugins, directives and route middleware in config/pwax.php. Each value
is either a component reference or a dotted path to a global:
'vue' => [ 'plugins' => [ // A Pwax component whose default export is a Vue plugin. 'toast' => "@pwaxImport('plugins.toast')", // A UMD library already loaded by a <script> tag. 'i18n' => 'VueI18n.createI18n', ], 'directives' => [ 'focus' => "@pwaxImport('directives.focus')", ], 'middleware' => [ 'confirmed' => "@pwaxImport('middleware.confirmed')", ], ],
These values are never evaluated as code. A component reference is imported; a dotted path is looked up on
window. In 1.x they were interpolated straight into the page inside{!! !!}, so a stray quote broke the whole application and any path by which config could be influenced was remote code execution.
Why the vue group
The package has two configs that share the word "middleware": pwax.middleware (the
Laravel middleware groups a page route runs through, set by HandlePwaxRequests)
and the client's route middleware. The two were always going to be confused.
Putting the three Vue extensions under pwax.vue.* lets the client-side one
keep its natural name — vue.middleware — and groups all client-side Vue
configuration in one place.
{{-- resources/views/middleware/confirmed.blade.php --}} <script> export default async function ({ component, meta, redirect }) { if (meta.requiresConfirmation && !window.localStorage.getItem('confirmed')) { redirect('/confirm'); } }; </script>
Opt a page in from its own script:
export default { middleware: ['confirmed'], meta: { requiresConfirmation: true }, };
Client middleware is for user experience, not for access control. It runs in the browser and can be bypassed. Enforce authorisation with Laravel middleware and policies.
pwax.import,@pwaxImport, andpwax.importModuleare the same thing. The Blade directive@pwaxImport('foo.bar')is the only way to write a component reference in a config value — you cannot reach for animport('foo')expression instead. At runtime it resolves to the client-sidepwax.component(url)(sync) orpwax.load(url)(async with options). The public runtime exposes this aspwax.import(url);importModuleis the internal function name in the bundle, which the service worker also calls. One operation, three spellings for three places you might meet it.
Progressive web app
Manifest
Served from /manifest.json and configured in config/pwax.php. Browsers require
a 192×192 and a 512×512 icon before offering to install:
'manifest' => [ // Set this once and never change it. Without it a browser identifies the installed // app by start_url, so changing start_url later orphans every existing install. 'id' => '/', 'name' => 'My Application', 'short_name' => 'MyApp', 'theme_color' => '#0c83ff', 'icons' => [ ['src' => '/images/icon-192.png', 'sizes' => '192x192', 'type' => 'image/png'], ['src' => '/images/icon-512.png', 'sizes' => '512x512', 'type' => 'image/png'], ['src' => '/images/maskable.png', 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'maskable'], ], 'screenshots' => [ ['src' => '/images/wide.png', 'sizes' => '1280x720', 'type' => 'image/png', 'form_factor' => 'wide'], ], ],
Every member of the Web App Manifest specification
is passed through, so display_override, shortcuts, launch_handler, share_target,
protocol_handlers and the rest work by adding them to the array. Empty values are
dropped — false and 0 are not, so prefer_related_applications => false survives.
Pwax also emits the tags iOS needs, which are not in the manifest at all:
apple-touch-icon (chosen from your non-maskable icons), apple-mobile-web-app-capable,
apple-mobile-web-app-title and application-name.
Installed-app capabilities
An installed app can do things a tab cannot. Each of these is exposed and none of them happens on its own — when to prompt, when to badge, when to ask for storage are all decisions the application makes.
// Your own install button, shown when the browser says it is possible. document.addEventListener('pwax:installable', () => showMyInstallButton()); await window.pwax.install.prompt(); // 'accepted' | 'dismissed' | 'unavailable' window.pwax.install.standalone; // running as an installed app? await window.pwax.badge.set(3); // the count on the app icon await window.pwax.storage.persist(); // ask to be exempt from eviction await window.pwax.push.subscribe(); // Web Push — see Push notifications await window.pwax.sync.enqueue('/notes', { method: 'POST', body: note }); await window.pwax.share({ title, url }); // the platform share sheet
storage.persist() is worth a thought in this package specifically. Pwax precaches the
whole application, and a browser under storage pressure evicts whole origins — so the
failure mode is not a slow page, it is "it stopped working offline" with nothing in the app
to explain it. Persistence is never requested for you: on some platforms it is a real
prompt, and spending it is the application's decision.
sync.enqueue() queues nothing automatically. Intercepting failed writes would replay a
payment as readily as a draft, and only the application knows which of its requests repeat
safely.
Being opened by the operating system
Three manifest members hand your application an entry point from outside the browser, and all three arrive as one thing — a launch:
'manifest' => [ // Opened when the user picks your app from the share sheet. 'share_target' => [ 'action' => '/share', 'method' => 'POST', 'enctype' => 'multipart/form-data', 'params' => ['title' => 'title', 'text' => 'text', 'url' => 'url'], ], // Opened when the user opens a file your app claims. 'file_handlers' => [ ['action' => '/import', 'accept' => ['text/csv' => ['.csv']]], ], // Opened when something follows a web+invoice: link. 'protocol_handlers' => [ ['protocol' => 'web+invoice', 'url' => '/invoices/open?ref=%s'], ], ],
The routes are yours to write. The manifest tells the operating system to send the app
to those URLs; nothing in the package answers them. php artisan pwax:doctor resolves every
declared target against your real route table, with the method the browser will use, and
fails if one matches no route, refuses that method, or sits outside scope — because
otherwise the first you hear of it is a user who shared a photo to your app and got a 404.
A file handler and a protocol handler deliver through the launch queue, which the runtime consumes for you:
window.pwax.launch.consume(({ files, targetURL }) => { for (const handle of files) { readAndImport(handle); // FileSystemFileHandle } });
A launch carrying a URL and no files — a protocol handler, a shared link — is routed to for
you. Under launch_handler: focus-existing the browser only brings the window forward, so
without that, following a web+invoice: link would open the app on whatever page it was
left on. Return false from the consumer, or call preventDefault() on the cancelable
pwax:launch event, to route it yourself.
A POST share target is not a launch — it is a real form POST from outside your
application, so its route needs CSRF exemption and its own validation. The service worker
leaves every non-GET request to the network, which means a share received offline fails
rather than being queued. That is deliberate: replaying an arbitrary POST is not something
a package can decide on your behalf. pwax.sync.enqueue() is there if your application
decides it.
Offline
Off by default. Turn it on:
'service_worker' => ['enabled' => true],
That is the whole configuration for a fully offline-capable app. Pwax generates an asset
manifest at /sw.json, listing every URL the application is made of with a content
hash:
{
"configVersion": 2,
"hash": "9c41f0be2a7d5581",
"version": "v1",
"shellUrl": "/__pwax__/shell",
"navigationStrategy": "network-first",
"pageHeaders": {
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-Pwax-Component": "true"
},
"assetGroups": [
{ "name": "app", "installMode": "prefetch", "kind": "asset", "urls": [
"/vendor/pwax/vue.global.prod.js?v=3.5.41",
"/vendor/pwax/vue-router.global.prod.js?v=5.2.0",
"/__pwax__/pwax.js?v=8f2a41c0d5e7",
"/manifest.json",
"/favicon.ico",
"/__pwax__/shell"
]},
{ "name": "components", "installMode": "prefetch", "kind": "asset", "urls": [
"/__pwax__/c/Y29tcG9uZW50cy5tb2RhbA3f9a1c0d.js"
]},
{ "name": "assets", "installMode": "lazy", "kind": "asset",
"urls": ["/images/logo.svg"], "patterns": ["^\\/images\\/.*$"] },
{ "name": "pages", "installMode": "prefetch", "kind": "page",
"strategy": "network-first", "credentials": "omit", "urls": ["/", "/about"] }
],
"dataGroups": [],
"hashTable": {
"/__pwax__/c/Y29tcG9uZW50cy5tb2RhbA3f9a1c0d.js": "a41c9b02f7de5163",
"/__pwax__/pwax.js?v=8f2a41c0d5e7": "8f2a41c0d5e7b193"
},
"critical": ["/__pwax__/pwax.js?v=8f2a41c0d5e7", "/__pwax__/shell"],
"warnings": []
}
pageHeaders is the one entry worth understanding. Page responses vary on those three
headers, so the worker both fetches with them and keys its cache on them — when the two
sides disagreed, every page lookup missed silently, which is the defect this release
exists to fix.
sw.js fetches that on install and caches the lot in one pass. Nothing has
to be visited first to be available: a visitor who loaded one page can go offline and
still reach every route and every component in the application.
Components are discovered by scanning your view paths for Blade files with a <template>
block, or a <script> block that exports — which also picks up the script-only views used
for plugins, directives and client middleware. Include all of them, or pick:
'service_worker' => [ 'enabled' => true, 'components' => 'all', // every component (default) 'components' => ['components.*', 'ui.*'], // only these 'components' => false, // none; cached lazily as they load 'exclude' => ['vendor.pwax.*', 'admin.*'], ],
Your own static files — images, fonts, stylesheets, build output — come from asset groups, which take globs:
'service_worker' => [ 'asset_groups' => [ [ 'name' => 'app', 'install_mode' => 'prefetch', // fetched at install 'files' => ['/favicon.ico', '/css/**.css', '/js/**.js', '/build/**'], ], [ 'name' => 'assets', 'install_mode' => 'lazy', // fetched on first use, then kept 'files' => ['/images/**', '/fonts/**'], ], ], ],
** crosses directories, * does not, {a,b} and (a|b) alternate, and a leading !
excludes. public/storage is never walked — it is a symlink to user uploads — and .php
files, source maps and anything hidden are never matched whatever the patterns say
(including files inside a hidden directory, so a stray .git under public/ cannot be
precached). max_files and max_bytes cap a runaway glob and report what they truncated.
Requests to an API get their own groups, which are about responses rather than files:
'service_worker' => [ 'data_groups' => [ [ 'name' => 'posts', 'urls' => ['/api/posts', '/api/posts/**'], 'strategy' => 'network-first', // or 'cache-first' to serve the cache first 'max_entries' => 50, 'max_age' => 3600, // seconds, for 'cache-first' 'timeout' => 3000, // ms before 'network-first' falls back to the cache ], ], ],
These are responses, not files, and they can hold one person's data. They are cached
normally, and caches are shared across visitors — anyone with the device sees the same
API responses as the last user. Do not add an authenticated endpoint here without deciding
that is acceptable, or guarding the response with X-Pwax-Cache: none server-side.
Only your own view paths are scanned. Package view namespaces are not — every package
that calls loadViewsFrom() registers one, Laravel's own exception-page renderer
included, and none of those are components your application imports. Opt in by name if
you publish components from a package of your own:
'service_worker' => ['namespaces' => ['acme-ui']], // finds @pwaxImport('acme-ui::button')
See exactly what that resolves to:
php artisan pwax:precache # what will be available offline php artisan pwax:precache --verify # …and which components actually render php artisan pwax:precache --json # the manifest itself
Developing against
php artisan serve. PHP's built-in server handles one request at a time. Pwax precaches at most six assets at once for that reason, but an install is still the largest burst of requests your app will ever make, and it will feel slow. Give the server some workers:PHP_CLI_SERVER_WORKERS=8 php artisan serveOr develop against Herd, Valet, Octane or nginx. Nothing breaks either way — a precache entry that fails is retried on the next install — but the difference is noticeable.
Cache busting
Each entry is content-addressed and the digest of the whole table names the cache, so
shipping a change is just deploying it. A release that changed one component
re-downloads that one file and copies the rest from the previous cache; a client can never
end up with a mix of two builds. version is still there, but only for when you want to
discard everything deliberately:
'service_worker' => ['version' => 'v2'],
The manifest hash is embedded in sw.js itself. That is what makes a deploy
reach an existing install: a browser only treats a worker as new if its bytes differ, so a
worker whose source never changed would leave clients on the build they first installed.
The offline shell
Caches are shared across visitors, so a page that stores nothing user-specific ends up
in the same place for whoever comes next. The Cache API ignores HTTP cache directives,
so a worker that kept what it fetched would persist to disk whatever document the server
returned — which is exactly the document the server marked Cache-Control: no-store, private for, a signed-in user's rendered page. A page that must not reach disk at all
opts out with ->offline(false); the server says so on the response, and the worker
honours it.
The documents that install with the build are anonymous by construction: they are fetched
at install time with cookies passed through, but a route behind auth answers that
request with a login screen rather than a page, and the worker detects that and refuses
to store it. Pages whose guest and signed-in renderings are the same — the typical
application page — end up cached; pages whose aren't don't, and fall back to the shell
when offline.
The shell is /__pwax__/shell: the same SPA shell rendered with no session, no CSRF token
and no page component, identical for every visitor. When a navigation cannot reach the
network and there is no document for that route, the worker serves the shell, the runtime
boots, and client-side routing carries on as normal.
service_worker.pages.runtime => false turns off both halves of runtime page caching, the
payload and the document alike. The precached shell and documents are unaffected — they
are what the build installed, not what a visitor left behind.
The page content for a route is a separate question, and it is the one that decides whether the application is genuinely usable offline or merely renders a shell. Two mechanisms cover it.
Precached pages. Every route that renders a page is found automatically and fetched at install, so the whole application works offline before any of it has been visited:
Route::get('/settings', fn () => pwaxRender('pages.settings')); // found
Each precached page is stored twice: the JSON payload for client-side routing, and the
rendered HTML for a direct navigation — the document with the component already inlined in
its pwax-initial island, so an offline navigation paints at once rather than showing a
spinner while the runtime fetches something it already has.
Discovery reads the route's action for a literal view name given to pwaxRender(),
Pwax::render() or pwax(). That view name is the point — service_worker.components
scopes pages the same way it scopes components, so 'all' takes every page and
['pages.*'] takes only the ones whose view matches. One setting, both halves.
What it cannot read statically — a computed view name, a render behind a service, or a
parameterised route like /posts/{post}, which is a template rather than a page — goes in
the list by hand:
'service_worker' => ['pages' => ['urls' => ['/posts/hello-world']]],
php artisan pwax:precache prints exactly what was found. Turn discovery off with
pages.discover => false.
The worker asks for these without cookies, so what it stores is the guest rendering. A
route behind auth answers that request with a login screen rather than a payload, which
is refused and reported — listing one is harmless, it just will not be there before
sign-in.
->cacheable() is a separate matter: it relaxes the HTTP caching headers on a page
payload, letting the browser and any shared proxy hold it too. It is not needed for
offline.
Route::get('/docs/{page}', fn ($page) => pwaxRender('pages.docs', [...]) ->cacheable(86400, shared: true));
Visited pages. On by default, and what makes an authenticated application work offline rather than only its public routes: every page a visitor opens is cached as they go. The cache is shared across visitors, so the document the server returned for a URL is what the next visitor gets offline — which is fine for a page that renders the same for everyone, and a deliberate decision otherwise.
'service_worker' => ['pages' => ['runtime' => true]],
Mark anything that must never reach disk at all:
Route::get('/recovery-codes', fn () => pwaxRender('pages.codes')->offline(false));
Point offline_url at your own page to replace the fallback, publish the worker to change
its behaviour:
php artisan vendor:publish --tag=pwax-service-worker
Update prompts
When a new version is waiting, Pwax fires an event instead of silently leaving the visitor on the old build:
document.addEventListener('pwax:update-available', (event) => { if (confirm('A new version is available. Reload now?')) { event.detail.activate(); // page reloads once the new worker takes over } });
The page reloads only when it asked for the update. A worker that activated for any other reason will not restart your users' tabs and discard what they were typing.
An open tab re-checks for a new build hourly and when it regains focus. pwax.sw.update()
checks on demand.
A new build does not take over on its own. It installs, then waits until every tab of the application is closed. That is the point — taking over immediately would reload those tabs and discard whatever was being typed — but it does mean an application that ignores the event above can look as though a deploy did nothing: the old worker is still answering every request, out of the old caches.
The runtime logs one line to the console when a build is waiting.
pwax.sw.applyUpdate()lets it through immediately and reloads, which is what to call from a prompt of your own, and what to paste into the console when you are wondering why a fix has not appeared.
Going offline and back
document.addEventListener('pwax:offline', () => banner.hidden = false); document.addEventListener('pwax:online', () => banner.hidden = true);
Push notifications
Five steps, and the first one is the one every other guide assumes you already have.
0. Scaffold the endpoint. pwax:push-endpoint writes the controller — the
subscribe and unsubscribe actions, the validation, the upsert by endpoint. It
prints the routes to add to routes/web.php and a starter migration. Reading the
file next to the shape below is the difference between "I think I understand" and
"I have something concrete to read."
php artisan pwax:push-endpoint
1. Generate a VAPID key pair. No Node needed — this uses ext-openssl, which Laravel
already requires.
php artisan pwax:vapid
VAPID_PUBLIC_KEY=BE1cBl7BxUtZ… VAPID_PRIVATE_KEY=BBD3vKF0zkf…
The public key goes to the browser at subscribe time, so it is safe in a page. The private one never leaves the server. Rotating them invalidates every existing subscription, so generate once and keep them.
2. Point the config at the key and at an endpoint of yours.
'push' => [ 'public_key' => env('VAPID_PUBLIC_KEY'), 'private_key' => env('VAPID_PRIVATE_KEY'), 'endpoint' => '/push/subscriptions', 'title' => config('app.name'), // used when a message sends no title 'icon' => '/images/icons/icon-192.png', 'badge' => '/images/icons/badge-72.png', ],
3. Write the endpoint. Pwax posts the browser's subscription there and deletes it on
unsubscribe. It never invents the shape — the body is PushSubscription.toJSON() verbatim:
{
"endpoint": "https://fcm.googleapis.com/fcm/send/…",
"expirationTime": null,
"keys": { "p256dh": "BN…", "auth": "k9…" }
}
Route::post('/push/subscriptions', function (Request $request) { $data = $request->validate([ 'endpoint' => ['required', 'url'], 'keys.p256dh' => ['required', 'string'], 'keys.auth' => ['required', 'string'], ]); $request->user()->pushSubscriptions()->updateOrCreate( ['endpoint' => $data['endpoint']], ['p256dh' => $data['keys']['p256dh'], 'auth' => $data['keys']['auth']], ); return response()->noContent(); })->middleware('auth'); Route::delete('/push/subscriptions', function (Request $request) { $request->user()->pushSubscriptions() ->where('endpoint', $request->input('endpoint')) ->delete(); return response()->noContent(); })->middleware('auth');
Both requests carry the session cookie and Pwax's CSRF token, so auth and web work
normally.
4. Subscribe, from a user gesture.
if (window.pwax.push.supported && window.pwax.push.permission === 'default') { await window.pwax.push.subscribe(); // asks permission, then posts to your endpoint }
subscribe() resolves to the subscription or null if permission was refused.
unsubscribe() tells your endpoint first and drops the local subscription second, so a
failure leaves you with a subscription that still exists on both sides rather than one the
server will keep pushing to forever.
Sending
Deliberately not part of this package. Storing subscriptions and signing VAPID requests is
what laravel-notification-channels/webpush
already does well, and a second implementation inside a PWA package would be a worse one.
Install it, point it at the same keys, and Pwax's worker will render what it sends.
What the worker does with a message
The payload shape is the Notification API's own, so anything that library sends works untranslated:
{
"title": "Invoice paid",
"body": "#1043 — £240.00",
"icon": "/images/icons/icon-192.png",
"tag": "invoice-1043",
"data": { "url": "/invoices/1043" }
}
title falls back to push.title, and icon/badge to their config values. A push that
arrives with no payload at all still shows something: every browser requires
userVisibleOnly, and showing nothing is how an origin loses its push permission.
A click closes the notification and goes to data.url. If a window is already open on that
URL it is focused; if a window is open elsewhere it is navigated; only if neither is true is
a new one opened. Opening a second tab on a page the user already has open is the thing
they notice and dislike.
Extending the service worker
The worker is a built bundle rather than a Blade view since 4.1, so it is no longer edited by publishing it. Two ways in, and the first is almost always the right one.
Append your own handlers. Each entry is a view name or an absolute path. The contents
are appended after the worker and share its scope, so CONFIG, PREFIX and the cache
helpers are all in reach:
'service_worker' => [ 'extend' => ['js.analytics-sync'], ],
{{-- resources/views/js/analytics-sync.blade.php --}}
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'refresh-feed') {
event.waitUntil(fetch('/feed/refresh'));
}
});
It is a Blade view rendered with no variables, so reach for config() and @json() —
the value is resolved at request time rather than baked into a published file. Everything
the worker itself was given is on self.__PWAX_SW__. The worker is served no-cache, so a
change reaches clients on their next update check.
This is the supported seam and the reason to prefer it: you get every future fix to the 1,600 lines you did not fork.
Replace it outright. Still supported, and always will be:
'service_worker' => [ 'blade' => 'js.my-worker', ],
Your view receives $manifest — the whole asset manifest, the same array /sw.json
serves. Nothing else in the package is involved after that, including the fixes.
Either way php artisan pwax:precache shows what the manifest will tell your worker to
install, and /sw.js in a browser shows exactly what is being served.
Scope
A service worker can only control paths at or below its own URL. pwax:doctor checks
the obvious misconfigurations — a worker served at /static/sw.js cannot control
/, and the browser will quietly leave the worker uninstalled — but the design
principle is the same: the worker's URL sets the upper bound of what it can claim,
and service_worker.scope cannot extend that.
The default (/) covers everything. Set it to /admin only when the worker is
itself served at /admin/sw.js; otherwise the browser truncates the scope to the
worker's path and the symptoms are the same as if scope were wrong. Run
php artisan pwax:routes to see which Pwax routes the worker can claim.
Frontend assets
Vue, Vue Router and Pinia are self-hosted by default, published to
public/vendor/pwax:
| Package | Version |
|---|---|
| vue | 3.5.41 |
| vue-router | 5.2.0 |
| pinia | 4.0.2 |
Self-hosting is the default because a progressive web app that fetches its framework from a third-party CDN cannot start offline — which defeats the purpose — and discloses every visitor's IP address to that CDN.
To use a CDN anyway, with subresource integrity:
'assets' => ['source' => 'cdn'],
Pinia can be dropped if you do not use a store:
'assets' => ['pinia' => false],
Pwax serves the full Vue build (vue.global.prod.js) by default, because it is the
one that can compile a template string. The runtime-only build is also published, and is
served instead when you opt into precompiling.
To update Vue, see resources/vendor/README.md.
Performance
First paint costs one round trip. The shell arrives with the current component fully embedded — template, styles and script — so there is no follow-up request for the page at all. Only the framework and the runtime are fetched, in parallel, and both are static and cacheable. In 1.x the browser made six sequential requests before it could render anything.
Anything the first render still has to import is named in the head with
<link rel="modulepreload">: the components this page imports with @pwaxImport, the
configured plugins and directives the runtime awaits before mounting, and any external
script or stylesheet the component declares. All of those are known to the server while it
is writing the document, and none are discoverable by the browser until Vue has loaded and
rendered — a serial round trip that the hints remove.
Repeat navigation compiles each component once. Compiled modules are cached on the content hash the server sends, so returning to a page reuses the module rather than building another one.
Everything is cached at the right layer:
| Response | Caching |
|---|---|
| Page HTML | no-store, private — carries the CSRF token |
| Page JSON | no-store, private, unless the route calls ->cacheable() (an HTTP-cache hint; the service worker stores it either way unless ->offline(false)) |
Component module (/__pwax__/c/{id}.js) |
private, max-age, with an ETag → 304 |
pwax.js |
public, max-age=31536000, immutable |
| Web manifest | public, max-age=86400, with an ETag |
Offline shell (/__pwax__/shell) |
public, must-revalidate, with an ETag |
Asset manifest (/sw.json) |
no-cache, must-revalidate — how updates are discovered |
Compilation is memoised on a digest of the rendered output, so a component that has not changed is not re-parsed, re-scoped or re-minified. Because the key is the output itself, the cache can never go stale. What is memoised is the parse — splitting the blocks, scoping the styles, stamping the template, minifying — never the Blade render, which has to run every request because rendering is where a page's data enters.
For the same reason, only renders that take no data are stored. A page rendered with
controller data produces different output for every visitor, so an entry would be written
once and read never; ->cacheable() opts a page back in, since a page whose payload may
sit in a shared HTTP cache has already been declared visitor-independent. cache.ttl
bounds how long an entry lives — null keeps it forever.
Minification runs in production only, and its results are cached by content. If your web server already applies gzip or brotli, turn it off — you recover most of the bytes with no CPU cost and no risk of a regex-based minifier mangling valid JavaScript:
'minify' => ['enabled' => false],
Precompiling templates
Optional, off by default, and the only part of Pwax that needs Node. It exists for teams who already run CI and would rather spend a build minute than the bytes and the CSP allowance.
npm install --save-dev @vue/compiler-dom@3.5.41 # the version Pwax vendors
php artisan pwax:compile
'assets' => ['vue_build' => 'runtime'],
Every template is compiled to a Vue render function server-side and stored in
storage/app/pwax/render-functions.php — a plain PHP array literal, so OPcache holds the
parsed form and a lookup costs a hash-table hit. The functions are emitted into the
component module, as source rather than as a string, so the module loader evaluates
them and nothing calls the Function constructor.
Three things follow:
| Default | vue_build => 'runtime' |
|
|---|---|---|
| Vue | 60.7 kB gzipped | 40.6 kB gzipped |
script-src |
'self' blob: 'unsafe-eval' |
'self' blob: |
| Per navigation | Template compiled in the browser | Render function already compiled |
Run it on every deploy that changes a component. This is a real deploy step, not a
nice-to-have — put it in the same script as php artisan config:cache:
php artisan pwax:compile
Never having compiled is not an outage: the store is empty, Pwax serves the full build,
and the application behaves exactly as if you had not opted in. Compiling and then
changing a component is one — the store is non-empty, so the runtime-only build is
served, and the changed component has no render function under its new key. Both states
are reported by php artisan pwax:doctor, the second as an error naming the components
affected.
One constraint comes with it. A template has to be the same for every visitor, because
it is compiled once, at deploy time, with no request in flight. Keep controller data in
<script> and out of <template>:
<template> <h1>@{{ user.name }}</h1> {{-- fine: Vue renders this in the browser --}} <h1>{{ $user->name }}</h1> {{-- not precompilable: differs per visitor --}} </template> <script> export default { data: () => ({ user: @json($user) }), }; </script>
That is the idiomatic split anyway, and pwax:compile names any view that breaks it —
such a view raises on the undefined variable when rendered with no data, and the command
reports it and exits non-zero rather than writing a store that looks complete.
php artisan pwax:compile --clear removes the store and goes back to compiling in the
browser. assets.node sets the Node binary if it is not on PATH;
assets.render_functions moves the store, which is worth doing if you build the artifact
in CI and ship it with the release.
Security
Component identifiers are signed
Component URLs carry an HMAC derived from your APP_KEY:
/__pwax__/c/cGFnZXMuaG9tZQ3f9a1c0d4b8e2a67.js
Only identifiers your application itself emitted will resolve, so these endpoints cannot
be used to render arbitrary Blade views. Signatures are compared with hash_equals.
For defence in depth, restrict which views may be served at all:
'components' => ['allowed' => ['pages.*', 'components.*']],
Rotating
APP_KEYinvalidates every previously emitted identifier. Clients recover on their next full page load.
Content-Security-Policy
By default Vue compiles templates in the browser with the Function constructor, so
script-src 'unsafe-eval' is required. That is inherent to compiling templates on the
client rather than at build time — but it is not inherent to the package:
pwax:compile moves the compilation to deploy time and lets
you drop 'unsafe-eval' entirely. If your policy forbids it, that is the configuration
to use.
Imported components are fetched from real same-origin URLs. A page component cannot
be — it is rendered with controller data, so it ships its script inline and the runtime
imports it from a blob: URL. That needs blob: in script-src, with or without
precompiling. (data: is never used: unlike blob:, a data: URL in script-src makes
any injected string executable.)
Content-Security-Policy:
default-src 'self';
script-src 'self' blob: 'unsafe-eval'; /* drop 'unsafe-eval' with pwax:compile */
worker-src 'self'; /* the service worker is registered same-origin */
style-src 'self' 'nonce-{NONCE}';
connect-src 'self';
img-src 'self' data:;
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
object-src 'none'
A worker-src 'self' is the line most policies forget to add. Without it, a strict
policy refuses to register the service worker at all and the application is offline
as far as the worker is concerned without anything in the dev console to point at
the policy.
Supply the nonce for Pwax's inline <style> and JSON blocks:
'csp' => ['nonce' => fn () => request()->attributes->get('csp-nonce')],
Security headers
Pwax applies its own set of response headers, so its hardening does not depend on the application's own middleware and cannot be silently lost. The HTML shell gets them in full; assets get the smaller set that fits their shape.
| Header | Asset response | HTML shell | Configurable |
|---|---|---|---|
X-Content-Type-Options: nosniff |
yes | yes | no |
Referrer-Policy: no-referrer |
yes | yes | pwax.security.referrer_policy |
X-Frame-Options: SAMEORIGIN |
no | yes | pwax.security.frame_options |
Permissions-Policy |
no | yes | pwax.security.permissions_policy |
Cross-Origin-Opener-Policy: same-origin |
no | yes | pwax.security.cross_origin_opener_policy |
Cross-Origin-Embedder-Policy: require-corp |
no | yes | pwax.security.cross_origin_embedder_policy |
Cross-Origin-Embedder-Policy: require-corp puts the document into a cross-origin
isolation state — eligible for SharedArrayBuffer and high-resolution timers — at the
cost of every cross-origin asset needing an explicit crossorigin attribute. The
doctor flags any pwax.scripts or pwax.styles entry that points off-site without
one.
Every value is overridable; setting any to null (or '') drops the corresponding
header. The framework scripts (Vue, Vue Router, Pinia) always carry crossorigin
when loaded from the CDN, so the default policy does not break them.
What the service worker will and will not store
The Cache Storage API ignores HTTP cache directives, so a worker that stores whatever it
fetches writes signed-in users' rendered pages to disk — where the next person to use that
device is served them offline. Assets carrying Cache-Control: no-store are refused
outright.
A page has two representations and both are stored, under different rules. The runtime asks for the payload and gets JSON; a reload, a bookmark or a link from outside the app is a navigation and gets HTML with the component already inlined. Caches are shared across visitors: the payload or document the server returned for a URL is what the next visitor gets on the same device.
For most pages this is fine — a page that renders the same for everyone ends up cached
the same way, and a small staleness between deploys is unnoticeable. For pages whose
guest and signed-in renderings differ, that sharing is a deliberate decision. ->offline(false)
refuses outright for content that must never reach disk at all — a one-time code, a
recovery key, a record on a shared terminal.
When a stored page is used
By default a page goes to the network first and falls back to what is stored — not only
when the network is gone, but when the origin cannot be reached through. A proxy that
cannot get an answer out of the application, or an application mid-deploy, replies, so
waiting for fetch to throw would show an error while a copy of the page sat on the device
unread.
Three statuses, and not one more:
502 |
a proxy could not get an answer out of the application at all |
503 |
the application or the proxy is refusing for now — php artisan down answers this |
504 |
a proxy waited and gave up |
From the device none of those is distinguishable from a bad connection, which is the case the fallback exists for.
A 500 is shown, like a 404. It is not the origin being unreachable — it is the
application running and throwing, in a real route, and answering it from cache hides the
bug twice over: the visitor sees a page that works and reports nothing, and whoever
deployed it has no idea that route is broken until somebody eventually notices. A stale
page is worse than an error page when the error is the thing you needed to know.
The same rule holds for a full page load, not only for a navigation inside the app: a reload while the origin is unreachable is answered from the stored document for that route — the one visited earlier, or the one installed with the build. It holds for data groups and for the runtime cache too.
Whenever a stored copy does stand in for a failing origin, the worker says so on the console with the status and the URL. Silence there is how an outage goes unnoticed: the application looks healthy because everybody is being served yesterday's copy of it.
To prefer the stored copy even when the network is fine, make pages cache-first:
'service_worker' => ['pages' => ['strategy' => 'cache-first']],
That is faster and can be a version behind. 'network-first' — the default — goes to the
network but gives up on it after pages.timeout (2000 ms) and uses the copy.
Two things this does not cover, and both are worth deciding about rather than discovering:
- A page that varies by visitor is shared. Caches are not partitioned, so the document
one visitor sees offline is the document the server last returned for that URL. For a
page whose guest and signed-in renderings differ —
/dashboard,/account— this is wrong by design, and->offline(false)is the answer. - A component can still vary by user — an admin-only branch, a localised string. The
precache is shared too. For content that must not reach a stranger's device, the
component payload carries
Cache-Control: no-store, private, and the worker respects it.
pwax.sw.clearCaches() is the heavier hammer — it discards everything, including the
framework, so the next visitor downloads the application again. Reach for it when a
component renders differently for an administrator and you want no trace left.
One thing to avoid entirely: never sign a view name that came from a request.
pwaxRender($request->input('view')) would mint a valid signed identifier for whatever it
was handed, and signing is what the component endpoints trust. View names belong in your
code.
Your responsibilities
- Never put user input in
pwax.vue.*. They describe what the page loads. - Never interpolate unescaped user input into a
<template>. Blade's{{ }}escapes;{!! !!}and Vue'sv-htmldo not. - Authorise on the server. Client middleware is a UX affordance, not access control.
- Keep
APP_KEYsecret and stable.
CSRF tokens are read from the <meta name="csrf-token"> tag and sent with every runtime
request automatically.
Report vulnerabilities privately — see SECURITY.md.
Configuration reference
| Key | Default | Purpose |
|---|---|---|
hash_route |
false |
Use #/ URLs instead of the History API |
home |
'index' |
Named route used as a fallback target |
route_prefix |
'__pwax__' |
URL prefix for component endpoints |
shell |
'pwax::layouts.shell' |
Blade view used as the SPA shell |
middleware |
['web'] |
Middleware for component routes |
routes.register |
true |
Register package routes automatically |
routes.domain |
null |
Restrict package routes to a domain |
routes.static_middleware |
['throttle:300,1'] |
Middleware for runtime/manifest/worker — outside web, so outside its rate limiting too |
components.directive |
'pwaxImport' |
Blade directive name (import is rejected) |
components.allowed |
[] |
Allowlist of servable view patterns |
components.scoped_styles |
true |
Honour <style scoped> |
blade.* |
null |
Override bundled partials |
assets.source |
'local' |
local or cdn (was assets.strategy) |
assets.local_path |
'/vendor/pwax' |
Where published assets live |
assets.versions |
see config | Pinned Vue / Router / Pinia versions |
assets.pinia |
true |
Load Pinia at all |
styles, scripts |
[] |
Extra tags; string or attribute array |
vue.plugins, vue.directives, vue.middleware |
[] |
Vue extensions |
minify.enabled |
production only | Minify component sources |
minify.store, minify.ttl |
null |
Cache for minified output |
cache.asset_ttl |
3600 |
max-age for component assets |
cache.components |
true |
Memoise compiled components |
cache.ttl |
604800 |
How long a compiled component is stored; null for forever |
csp.nonce |
null |
Nonce (or callable) for inline blocks |
customization.init_spinner |
true |
The centred spinner covering the first load |
customization.* |
see config | Preloader colours |
progress.enabled |
true |
Navigation progress bar |
progress.color, .height |
null, 3 |
Colour falls back to the spinner's; height in px |
progress.delay, .trickle |
250, true |
Silence before it appears, and whether it eases while waiting |
transition.name, .duration |
pwax-page, 150 |
Page transition name (kept for back-compat) and cross-fade length in ms |
manifest_path, manifest |
see config | Web App Manifest (all spec members) |
head.title, .title_template |
null |
Document title and its wrapper |
head.description, .icon |
null |
Fall back to the manifest's |
head.base |
null |
<base href>; off because it rewrites every relative URL |
head.color_scheme, .theme_color_dark |
null |
Dark-mode head hints |
Service worker
| Key | Default | Purpose |
|---|---|---|
service_worker.enabled |
false |
Register and serve the worker |
service_worker.path, .scope |
/sw.js, / |
Where it lives and what it controls |
service_worker.version |
'v1' |
Mixed into the manifest hash; bump to discard everything |
service_worker.runtime_strategy |
network-only |
What happens to a same-origin GET nothing in the manifest claims |
service_worker.max_entries |
60 |
Cap on the runtime cache; precached entries are never evicted |
service_worker.max_entry_bytes |
5 MB |
Largest single response the runtime cache will keep |
service_worker.asset_manifest.path |
/sw.json |
Where the asset manifest is served |
service_worker.asset_manifest.ttl |
60 |
Seconds the built manifest is memoised |
service_worker.shell.enabled, .path |
true, /__pwax__/shell |
The session-free offline shell |
service_worker.assets |
true |
Precache Vue, the runtime and the web manifest |
service_worker.components |
'all' |
'all', false, or a list of view patterns |
service_worker.exclude |
['vendor.pwax.*'] |
Never precached, whatever components says |
service_worker.paths |
[] |
Extra directories to scan for components |
service_worker.asset_groups |
see config | Glob-resolved static assets, prefetch or lazy |
service_worker.max_files, .max_bytes |
2000, 64 MB |
Ceilings on what a glob may pull in |
service_worker.source_maps |
false |
Precache .map files |
service_worker.exclude_files |
[] |
Globs never precached |
service_worker.pages.urls |
[] |
Extra routes to precache, beyond the discovered ones |
service_worker.pages.discover |
true |
Find every route that renders a page, scoped by components |
service_worker.pages.runtime |
true |
Cache pages as they are visited — payload, and HTML when anonymous |
service_worker.pages.documents |
true |
Precache each page's HTML alongside its JSON payload at install |
service_worker.pages.strategy, .timeout |
network-first, 2000 |
How a page payload is fetched |
service_worker.pages.credentials |
'omit' |
Precache the guest rendering, not one visitor's |
service_worker.pages.as_components |
false |
Also precache page views as importable modules |
service_worker.data_groups |
[] |
API response caching |
service_worker.navigation_strategy |
network-first |
Or cache-first for zero-round-trip navigation |
service_worker.navigation_urls |
see config | Which navigations the worker claims |
service_worker.offline_url |
null |
Page shown offline; defaults to the shell |
service_worker.navigation_preload |
true |
Start the network request before the worker boots |
Artisan commands
| Command | Purpose |
|---|---|
pwax:install |
Publish config and frontend assets (--views, --push, --service-worker, --ai, --force, --no-assets) |
pwax:component <name> |
Scaffold a component view (--plain, --force, --plugin, --directive, --middleware) |
pwax:precache |
List everything available offline (--verify, --json) — --verify renders every component and probes every page |
pwax:compile |
Precompile templates to render functions — optional, needs Node (--clear, --json) |
pwax:vapid |
Generate a VAPID key pair for Web Push (--json) |
pwax:push-endpoint |
Scaffold the push-subscription controller (--force) |
pwax:routes |
List every Pwax-served route (--all includes application routes) |
pwax:skill |
Publish the Pwax skill for AI assistants (--path, --force) |
pwax:doctor |
Check for common misconfigurations |
pwax:clear |
Flush compiled caches and the offline manifest |
JavaScript API
The runtime publishes window.pwax:
| Member | Description |
|---|---|
pwax.component(url, export?) |
Vue async component for a component URL |
pwax.load(url, export?) |
Promise of the component's options |
pwax.import(url) |
Import a component module by URL (the runtime-side @pwaxImport) |
pwax.start() |
Reboot the runtime — unmount and re-initialise; returns a Promise |
pwax.http.json(url, options?) |
Fetch JSON with Pwax's headers and CSRF token |
pwax.styles |
The reference-counted style manager |
pwax.sw.controller |
The ServiceWorker controlling this page, or null |
pwax.sw.registration() |
The ServiceWorkerRegistration, or null |
pwax.sw.update() |
Check for a new build now |
pwax.sw.applyUpdate() |
Let a waiting build take over now, and reload |
pwax.sw.clearCaches() |
Delete every Pwax cache, framework included |
pwax.sw.unregister() |
Remove the service worker entirely |
pwax.install.* |
available, installed, standalone, prompt() |
pwax.badge.set(n) / .clear() |
The count on the installed app's icon |
pwax.storage.* |
estimate(), persisted(), persist() |
pwax.push.* |
subscribe(), unsubscribe(), subscription(), permission |
pwax.sync.* |
enqueue(url, options), pending(), flush() |
pwax.launch.consume(fn) |
Files and URLs the operating system launched the app with |
pwax.share(data) |
The platform share sheet |
pwax.prefetch(path) |
Fetch a page's payload before it is asked for |
pwax.progress |
The navigation progress bar, for your own long tasks |
pwax.app, pwax.router |
The Vue app and router instances |
pwax.config, pwax.version |
Runtime configuration and package version |
Events on document:
| Event | Fired when |
|---|---|
pwax:ready |
The app has mounted |
pwax:navigating |
A navigation has started |
pwax:navigated |
A page component has mounted |
pwax:error |
A page failed to load |
pwax:update-available |
A new service worker is waiting |
pwax:online, pwax:offline |
The connection came back or went away |
pwax:installable, pwax:installed |
The browser will offer installation, or it happened |
pwax:push-subscribed, pwax:push-unsubscribed |
A push subscription changed |
pwax:queued |
A write went to the background-sync queue |
pwax:launch |
The OS opened the app with files or a URL (cancelable) |
Upgrading
UPGRADE.md has a checklist for each major version. php artisan pwax:doctor
reads your published config and names any key that no longer does anything, along with
what replaced it — start there.
Where the API stands today, against every version that has shipped:
| Was | Now |
|---|---|
@import('view'), @pwax('view') |
@pwaxImport('view') |
vue(...), pwax_component(...) |
pwaxRender($view, $data) |
router(...), pwax_route(...) |
pwaxRoute($name, $params) |
/__pwax__/{name}.json |
/__pwax__/c/{signed-id}.js |
/service-worker.js, /manifest.webmanifest |
/sw.js, /manifest.json |
service_worker.files, .precache |
service_worker.asset_groups, .pages |
service_worker.strategy |
service_worker.runtime_strategy |
pwax.sw.registration |
pwax.sw.controller, pwax.sw.registration() |
One from 1.x is worth repeating because it was silent: 1.x registered a Blade directive
named import, and Blade matches a directive even with no arguments — so
@import url("fonts.css") inside any <style> block in the application, Pwax component
or not, was replaced with JavaScript. If a stylesheet ever stopped working for no visible
reason, that was why.
Testing
composer install composer check # Pint, PHPStan and PHPUnit composer test
For the client runtime:
npm ci npm run lint npm test npm run build # dist/pwax.js is committed
Contributing
See CONTRIBUTING.md. Bug reports, questions and pull requests are all welcome. Please report security issues privately — see SECURITY.md.
Changelog
See CHANGELOG.md.
License
MIT — see LICENSE.
Vue, Vue Router and Pinia are redistributed unchanged under their own MIT licenses; see
resources/vendor/README.md.