JavaScript views
Vue
Write a component's view as a Vue single-file component. The component stays a PHP class: its properties, its methods compiled to run in the browser, its #[Server] methods and their rules. The template reads them by their PHP names, v-model writes them, and Vue renders.
On this page
A Vue view is a .vue file your Vite builds with @vitejs/plugin-vue, like any other
Vue component. Nitro gives it the component: every property and #[Computed] value, every
method, and what the component knows about itself (validation messages, unsaved changes, uploads, a call's
status). You write templates, v-model, <script setup> and composables as you
always do. Nitro takes care of state, the server and navigation.
Vue 3
Nitro's Vue adapter uses Vue 3's public API only: createApp, createSSRApp, refs, computed, watch, provide/inject. A Vue 3 release needs nothing from Nitro.
Setting it up
-
Install Vue and its Vite plugin
npm install vue @vitejs/plugin-vue -
Add the plugins, and register your views
Nitro's Vite plugin goes before Vue's.
nitroVue()takes your views asimport.meta.glob()gives them; each view loads when a page first shows it.// vite.config.js import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; import vue from '@vitejs/plugin-vue'; import nitro from './vendor/nitro/nitro/js/vite.js'; export default defineConfig({ plugins: [ laravel({ input: ['resources/js/app.js'], refresh: true }), nitro(), vue(), ], }); // resources/js/app.js import { nitroVue } from 'nitro/vue'; nitroVue(import.meta.glob(['../views/vue/**/*.vue', './vue/**/*.vue'])); -
Load them before Nitro starts
Put
@vitebefore@nitroScriptsin your layout, so the views are registered when the components start.<head> {{-- Your views first: they register with Nitro before its components start. --}} @vite('resources/js/app.js') </head> <body> {{ $slot }} @nitroScripts </body>
Views live in resources/views/vue or resources/js/vue, whichever you prefer
(both are in the nitro.views config). Subfolders work: #[View(vue: 'blog/editor')] is
blog/editor.vue.
A component with a Vue view
The class is an ordinary Nitro component. #[View(vue: 'post-editor')] says its view is
post-editor.vue:
<?php
namespace App\Nitro\Components\Vue;
use App\Models\Post;
use Illuminate\Support\Facades\Gate;
use Nitro\Attributes\Computed;
use Nitro\Attributes\Locked;
use Nitro\Attributes\Server;
use Nitro\Attributes\Validate;
use Nitro\Attributes\View;
use Nitro\Component;
use Nitro\Upload;
#[View(vue: 'post-editor')]
class PostEditor extends Component
{
#[Locked]
public int $postId;
#[Validate('required|min:3|max:120')]
public string $title = '';
public string $body = '';
/** @var list<string> */
public array $tags = [];
#[Validate('nullable|image|max:2048')]
public ?Upload $cover = null;
public string $savedAt = '';
public function mount(Post $post): void
{
$this->postId = $post->id;
$this->title = $post->title;
$this->body = $post->body;
$this->tags = $post->tags;
}
#[Computed]
public function words(): int
{
return str_word_count($this->body);
}
public function addTag(string $tag): void
{
$tag = strtolower(trim($tag));
if ($tag !== '' && ! in_array($tag, $this->tags, true)) {
$this->tags[] = $tag;
}
}
public function removeTag(int $index): void
{
array_splice($this->tags, $index, 1);
}
#[Server]
public function save(): string
{
$this->validate();
$post = Post::findOrFail($this->postId);
Gate::authorize('update', $post);
$post->update([
'title' => $this->title,
'body' => $this->body,
'tags' => $this->tags,
'cover' => $this->cover?->store('covers', 'public') ?? $post->cover,
]);
$this->cover = null;
$this->savedAt = now()->format('H:i');
$this->dispatch('post-saved', id: $post->id);
return "Saved at {$this->savedAt}";
}
}
The template uses the component's names as they are, as a Blade view does:
<template>
<form class="editor" @submit.prevent="save">
<input v-model="title" placeholder="Title">
<p v-if="errors.title" class="error">{{ errors.title[0] }}</p>
<textarea v-model="body" rows="8"></textarea>
<p class="muted">{{ words }} words</p>
<ul class="tags">
<li v-for="(tag, index) in tags" :key="tag">
{{ tag }} <button type="button" @click="removeTag(index)">×</button>
</li>
</ul>
<input placeholder="Add a tag" @keydown.enter.prevent="addTag($event.target.value); $event.target.value = ''">
<button :disabled="save.processing">{{ save.slow ? 'Saving…' : 'Save' }}</button>
<span v-if="unsaved.length" class="muted">Unsaved changes</span>
<span v-if="savedAt" class="muted">Saved at {{ savedAt }}</span>
</form>
</template>
Place it like any component, from a Blade page or layout. The class's folder is part of its name:
<nitro:vue.post-editor :post="$post" />
Nothing else is needed: no props to declare, no store, no API. v-model="title" writes the
property. addTag() runs in the browser, compiled from the PHP. save() runs on the
server, checks its rules there, and comes back with what it changed.
No script, nothing to import
The file above is the whole view: a<template> and nothing else. There is no
<script> to write and nothing to import from Nitro, because the template reads the
component's names directly. Add a <script setup> only when your own script needs them,
for example to combine them with your own ref()s, computed()s or
watch()es. Then you import useNitro from nitro/vue
(see below).
What the view can use
| Example | What it is | |
|---|---|---|
| Properties |
title
,
tags
|
Read and written by their PHP names. Arrays are arrays or objects, models are objects with their attributes, uploads are
Upload
objects
|
| #[Computed] values |
words
|
Read only. Worked out again when what they read changes |
| Browser methods |
addTag(tag)
|
Run in the browser. They return the method's value |
| #[Server] methods |
save()
|
Run on the server. They return a promise of the method's value, and carry the call's status |
errors
|
errors.title?.[0]
|
The validation messages, by field, as
$errors
has them in Blade
|
unsaved
|
unsaved.length
|
The properties changed in the browser that the server hasn't seen yet |
uploads
|
uploads.cover?.progress
|
Each property's upload while it runs:
uploading
,
progress
(0 to 100) and
error
|
set(path, value)
|
set('form.email', value)
|
Writes a property, or a key under it |
upload(path, files)
|
upload('cover', files)
|
Uploads files into an
Upload
property
|
dispatch(event, ...)
|
dispatch('saved', id)
|
Sends an event to the components listening for it |
link(href, ...modifiers)
|
v-bind="link('/posts')"
|
The attributes of a
nitro:navigate
link
|
visit(url)
|
visit('/posts')
|
Goes to a page without reloading |
A method is called with the arguments you give it: @click="save" works as well as
@click="save()". The event Vue passes on its own is left out, since a PHP method can't take one.
In <script setup>
A view doesn't need a script. Add a <script setup> when your own code needs the
component, and import useNitro from nitro/vue there.
useNitro() gives the same names to your script. Properties and #[Computed] values
are refs, so you use .value in the script and the bare name in the template, as with any ref.
Assigning a property's ref writes the property. errors, unsaved and
uploads are refs too; the methods are plain functions.
<script setup>
import { computed, ref } from 'vue';
import { useNitro } from 'nitro/vue';
const { title, tags, words, addTag, save, errors } = useNitro();
const tag = ref('');
const said = ref('');
/** Refs, as anything reactive in Vue: .value in the script, bare in the template. */
const tooLong = computed(() => title.value.length > 100);
const readingTime = computed(() => Math.max(1, Math.round(words.value / 200)));
function add() {
addTag(tag.value);
tag.value = '';
}
async function submit() {
said.value = (await save()) ?? '';
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="title" :class="{ warn: tooLong }">
<p v-if="errors.title" class="error">{{ errors.title[0] }}</p>
<p>{{ words }} words, about {{ readingTime }} min to read</p>
<input v-model="tag" @keydown.enter.prevent="add">
<span v-for="name in tags" :key="name" class="tag">{{ name }}</span>
<button :disabled="save.processing">Save</button>
<p>{{ said }}</p>
</form>
</template>
Your own ref()s, computed()s and watch()es work alongside: here
tag and said are the view's own state, and readingTime follows the
component's words.
Changing the component
There are three ways, and they all write the property at once, in the browser:
v-modelon a property, or deep inside one:v-model="form.email",v-model="lines[i].quantity". Its modifiers (.number,.trim,.lazy) are Vue's own.- Assigning its ref in the script:
title.value = 'Draft',tags.value.push('vue'). - A browser method, which is the PHP you wrote, compiled:
addTag(tag).
The server sees these changes with the next #[Server] call, which sends them along. Until then
they are unsaved. A browser method that changes several properties gives Vue one update, not one
per property.
Calling the server
A #[Server] method returns a promise of its return value. While the call runs, the method itself
carries its status, so a button can follow it without any state of your own:
| True while | In Blade | |
|---|---|---|
save.processing
|
The call is running |
nitro:loading
|
save.slow
|
It has been running for 200 ms |
nitro:loading.delay
|
save.failed
|
The request itself failed: an error on the server, or no connection | |
save.error
|
Why it failed, as a message |
<script setup>
import { ref } from 'vue';
import { useNitro } from 'nitro/vue';
const { save } = useNitro();
const notice = ref('');
async function submit() {
try {
/** The method's return value; null when a rule failed (errors has the messages). */
const said = await save();
notice.value = said ?? 'Please fix the highlighted fields.';
} catch {
/** The request itself failed: the server's error, or no connection. save.error says why. */
notice.value = `Not saved: ${save.error}`;
}
}
</script>
<template>
<button :disabled="save.processing" @click="submit">
<span v-if="save.slow">Still saving…</span>
<span v-else-if="save.processing">Saving</span>
<span v-else>Save</span>
</button>
<p v-if="save.failed" class="error">{{ save.error }}</p>
<p>{{ notice }}</p>
</template>
Rules that fail are not a failed call. When $this->validate() refuses the data,
the promise resolves with null, and errors has the messages by field. Only a request
that fails, such as a 500, a 403 or no network, rejects the promise and sets failed.
Validation messages
errors holds the messages by field, including nested ones (errors['lines.0.qty']).
They come from the server when a #[Server] method validates, and are cleared for a field once
it passes. Show the first one with errors.title?.[0].
Unsaved changes
unsaved lists the properties changed in the browser that the server hasn't seen yet. It is empty
again once a #[Server] call has taken them. Use it to warn before leaving, or to show that a
draft needs saving: <span v-if="unsaved.length">Unsaved changes</span>.
Uploading files
A property of type Upload (or an array with #[Uploads], for several files) takes
files. upload('cover', files) sends them as soon as they are chosen, and the property becomes the
uploaded file. uploads.cover follows the upload while it runs:
<template>
<label class="cover">
<span>Cover image</span>
<input type="file" accept="image/*" @change="upload('cover', $event.target.files)">
</label>
<progress v-if="uploads.cover?.uploading" max="100" :value="uploads.cover.progress"></progress>
<p v-if="errors.cover" class="error">{{ errors.cover[0] }}</p>
<figure v-if="cover">
<img :src="cover.temporaryUrl()" alt="">
<figcaption>{{ cover.getClientOriginalName() }}, {{ Math.round(cover.getSize() / 1024) }} KB</figcaption>
</figure>
</template>
- A preview:
cover.temporaryUrl()is a URL the browser can show for an image. - What it is:
getClientOriginalName(),getClientOriginalExtension(),getSize()andgetMimeType(), as on the server. - Refused files: a file the
nitro.uploads.rulesrefuse puts the message inerrors.cover, and the property keeps what it had. The promise resolves withnull. - Several files: for an
#[Uploads]array,upload('photos', files)sets the list. - Keeping them: the file is stored for good only when a
#[Server]method calls$this->cover->store(...), after its rules (image,max:2048) pass.
Links and visits
link(href, ...modifiers) gives the attributes of a nitro:navigate link, so the visit
happens without a reload. Bind them with v-bind. The modifiers are the ones a Blade link takes:
'keypress', 'preserve-scroll', 'transition', and 'prefetch' or 'prefetch.visible' (with its times: 'prefetch.150ms'), which adds nitro:prefetch. To go somewhere from code,
call visit(url):
<template>
<nav>
<a v-bind="link('/posts')">All posts</a>
<a v-bind="link(`/posts/${postId}/preview`, 'prefetch.visible')">Preview</a>
</nav>
<select @change="visit(`/posts?status=${$event.target.value}`)">
<option value="draft">Drafts</option>
<option value="published">Published</option>
</select>
</template>
Events
dispatch('event', ...params) sends an event to every component that listens for it with
#[On('event')], whatever its view is written in: Blade, React, Solid, Svelte or Vue. To react to
an event, add an #[On] method to the component's class. It runs in the browser, changes the
properties, and the view follows.
An event dispatched without ->to() also reaches the window, so the view's own code can hear
it too. That's useful for a notification or an animation that needs no PHP:
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useNitro } from 'nitro/vue';
const { dispatch } = useNitro();
const lastSaved = ref(null);
/** An event another component dispatched (without ->to()), heard in the view's own code: its values in order. */
const heard = (event) => (lastSaved.value = event.detail.params[0]);
onMounted(() => window.addEventListener('post-saved', heard));
onBeforeUnmount(() => window.removeEventListener('post-saved', heard));
</script>
<template>
<button @click="dispatch('preview-requested', 42)">Preview</button>
<p v-if="lastSaved">Post {{ lastSaved }} was just saved.</p>
</template>
Vue components inside the view
The view is the root of a Vue app, so it can use any Vue component: your own, or a library's. A component
inside the view reaches the Nitro component with useNitro(), without passing props down:
<!-- resources/views/vue/post-editor.vue -->
<script setup>
import TagList from '../../js/components/TagList.vue';
</script>
<template>
<TagList />
</template>
<!-- resources/js/components/TagList.vue: an ordinary Vue component, inside the view -->
<script setup>
import { useNitro } from 'nitro/vue';
const { tags, removeTag } = useNitro();
</script>
<template>
<ul>
<li v-for="(tag, index) in tags" :key="tag">{{ tag }} <button @click="removeTag(index)">×</button></li>
</ul>
</template>
Nitro components go around a Vue view, not inside it
A Vue view can't place <nitro:...> components. Compose them from Blade: a Blade page or component can hold several components with Vue, React, Solid, Svelte and Blade views side by side, and they talk through events and stores.
Plugins: Pinia, i18n, a UI library
Each component with a Vue view is its own Vue app. nitroVue()'s setup(app) runs for
each one before it mounts, so install your plugins there. Pass the same Pinia instance to all of them, and
their stores are shared across the page:
import { createPinia } from 'pinia';
import { createI18n } from 'vue-i18n';
import { nitroVue } from 'nitro/vue';
import messages from './lang/messages.js';
/** One Pinia for every view on the page, so their stores are shared. */
const pinia = createPinia();
const i18n = createI18n({ locale: document.documentElement.lang, messages });
nitroVue(import.meta.glob(['../views/vue/**/*.vue', './vue/**/*.vue']), {
/** Called for each view's app before it mounts: the plugins it uses. */
setup(app) {
app.use(pinia);
app.use(i18n);
},
});
The PHP in the .vue file
A small component can live in one file. Put its class in a <php> block at the top of the
.vue file. Nitro makes it a class named after the view, and Nitro's Vite plugin leaves the block
out of what Vue compiles. Component and Nitro's attributes need no use; anything
else does.
<php>
use App\Models\Subscriber;
new class extends Component {
#[Validate('required|email')]
public string $email = '';
public bool $done = false;
#[Server]
public function subscribe(): void
{
$this->validate();
Subscriber::firstOrCreate(['email' => $this->email]);
$this->done = true;
}
};
</php>
<template>
<p v-if="done">Thanks: you're on the list.</p>
<form v-else @submit.prevent="subscribe">
<input v-model="email" type="email" placeholder="you@example.com">
<button :disabled="subscribe.processing">Subscribe</button>
<p v-if="errors.email" class="error">{{ errors.email[0] }}</p>
</form>
</template>
Place it by the view's name, <nitro:newsletter />. An error in the block names the line in
the .vue file.
Server rendering
With server rendering on, the page's first HTML has the Vue view rendered in it. The browser then hydrates it, taking over the elements that are there. Add the view to your render service, with the same plugins:
// resources/js/ssr.js
import { createPinia } from 'pinia';
import { serve } from 'nitro/ssr';
import { renderVue } from 'nitro/vue/server';
serve({
vue: renderVue(import.meta.glob(['../views/vue/**/*.vue', './vue/**/*.vue'], { eager: true }), {
/** Called for each render: the service renders for every visitor, so nothing is shared between them. */
setup(app) {
app.use(createPinia());
},
}),
});
- Code that needs the browser (
window,localStorage, measuring an element) goes inonMounted(), as in any server-rendered Vue app. - On the server, a view only reads. It can't call methods or write properties while it renders.
- A view that fails to render on the server is left to the browser, which renders it as it would without server rendering.
Coming from Blade
| Blade | Vue |
|---|---|
nitro:model="title"
|
v-model="title"
|
nitro:model.blur="title"
|
v-model.lazy="title"
|
nitro:click="addTag('vue')"
|
@click="addTag('vue')"
|
nitro:submit="save"
|
@submit.prevent="save"
|
nitro:loading
(
nitro:target="save"
)
|
v-if="save.processing"
|
nitro:loading.delay
|
v-if="save.slow"
|
nitro:loading.attr="disabled"
|
:disabled="save.processing"
|
nitro:dirty
|
v-if="unsaved.length"
|
@error('title') {{ $message }} @enderror
|
{{ errors.title?.[0] }}
|
nitro:model
on a file input
|
@change="upload('cover', $event.target.files)"
|
<a href="/posts" nitro:navigate>
|
<a v-bind="link('/posts')">
|
$dispatch('saved', $id)
|
dispatch('saved', id)
|
$visit('/posts')
|
visit('/posts')
|
{{ $this->words }}
|
{{ words }}
|
Good to know
- The view's root is rendered inside the component's element, a
<div>Nitro puts on the page. Style it from the inside. - Several Vue views on a page are separate Vue apps. Share state between them with Nitro's stores or events, or with one Pinia.
- TypeScript:
<script setup lang="ts">works as usual. WhatuseNitro()returns isn't typed yet. - A visit to a page loads its Vue views' code before the page shows, so the page appears with them rendered, not filling in after it.