package.json and vite.config.js
resources/ts/*, resources/js/* and resources/css/*
npm run dev and npm run build, building to public/build
@vite('resources/js/app.js')
public/build is in .gitignore, so you will need to build for/to production
It's advisory to use npm from our Docker env, but mind the following.
$ docker compose exec -u 1000:1000 php-web bash
node -v
> v18.20.3
$ docker compose down
$ docker compose build --no-cache php-web
$ docker compose up
$ docker compose exec php-web bash
mkdir /.npm
chown -R 1000:1000 "/.npm"
exit
$ docker compose exec -u 1000:1000 php-web bash
npm install
npm run build
HTML
<script src="/path/to/htmx.min.js" defer></script>
…
<!-- Button that triggers an HTML fragment replacement -->
<button hx-get="/counter" hx-target="#count" hx-swap="outerHTML">Increment</button>
<div id="count">0</div>
URL /counter - server response (to request containing HX-Request header)
<!-- Response is HTML that replaces the #count element -->
<div id="count">1</div>
Vue-component /resources/js/Pages/Post/index.vue
<template>
<div>
<h1>My Inertia CRUD</h1>
<Link href="posts/create">Create new Post</Link>
<table>
<thead>
<tr>
<th v-for="header in headers" :key="header">
{{ header }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="post in posts" :key="post.id">
<td>{{ post.title }}</td>
<td>{{ post.body }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
import { Link } from "@inertiajs/vue3";
defineProps({
posts: {
type: Array,
default: () => [],
},
});
const headers = ["posts", "body"];
</script>
Laravel controller /routes/web.php
use Inertia\Inertia;
Route::get('/posts', function () {
$posts = Post::all();
return Inertia::render('Post/Index', ['posts' => $posts]);
});
/posts returns full HTML unless it contains a X-Inertia: true header, than it returns JSON:
{
"component": "Post/Index",
"props": {
"posts": [
{
"id": 1,
"title": "First Post",
"body": "This is the first post.",
"created_at": "2025-06-01T12:00:00.000000Z",
"updated_at": "2025-06-01T12:00:00.000000Z"
},
{
"id": 2,
"title": "Second Post",
"body": "This is the second post.",
"created_at": "2025-06-01T13:00:00.000000Z",
"updated_at": "2025-06-01T13:00:00.000000Z"
}
]
},
"url": "/posts",
"version": null
}
<php>
new class {
public string $name;
public function mount()
{
$this->name = Auth::user()->name;
}
}
</php>
<template>
Hello {{ name }}!
</template>
livewire.laravel.com (url since v3)
livewire/update) with component's state and actioncomposer require livewire/livewire
php artisan livewire:layout
resources/views/layouts/app.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title ?? config('app.name') }}</title>
@livewireStyles
</head>
<body>
{{ $slot }}
@livewireScripts
</body>
</html>
@livewireStyles and @livewireScripts load the necessary CSS/JS assets for Livewire to work
php artisan make:livewire pages::counter
resources/pages/⚡counter.blade.php
<?php
use Livewire\Component;
new class extends Component {
public int $count = 1;
public function increment(): void {
$this->count++;
}
public function decrement(): void {
$this->count--;
}
};
?>
<div>
<h1>{{ $count }}</h1>
<button wire:click="increment">+</button>
<button wire:click="decrement">-</button>
</div>
wire:click listens for a click event and calls a method on the Livewire component. Livewire then updates the component on the server and refreshes the DOM with any changes.
routes/web.php if there is a default layout
Route::livewire('/counter', 'pages::counter');
URL: /livewire/message/counter
{
"components": [
{
"snapshot": "{...}", // component state ("count" : 2) + metadata + checksum
"effects": {
"returns": [null], // as returned by the method increment()
"html": "<div wire:id=\"...\">...</div>" // the updated HTML to be patched into the DOM.
}
}
],
"assets": [] // for dynamic injection of scripts, styles, ...
}
mount() is called when the component is first rendered (e.g. for initial data loading)
public Post $post;
public function mount($id): void { // $id passed from route parameter
$this->post = Post::findOrFail($id);
}
render() is called on initial render and after every update (e.g. to return the view with the updated data)
public int $count;
public function render(): void {
return $this->view([
'count' => $this->count, // default behavior
'currentTime' => now(), // custom data
]);
}
<livewire:create-post />
<livewire:create-post title="Initial Title" />
<livewire:create-post :title="$initialTitle" />
public function mount($title = null) in the component
Route::livewire('/posts/{id}', 'pages::post.show');
$id will be accepted by the mount(string $id) method
Route::livewire('/posts/{post}', 'pages::post.show');
$post will be accepted by the mount(Post $post) method
$slot of the default layout
new #[Layout('layouts::dashboard')] class extends Component {
or
public function render() {
return $this->view()
->layout('layouts::dashboard');
}
wire:key to @foreach loops$posts in the viewwire:key (for internal element matching)
<div>
@foreach ($posts as $post)
<div wire:key="{{ $post->id }}">
<!-- ... -->
</div>
@endforeach
</div>
wire:model
<form>
<label for="title">Title:</label>
<input type="text" id="title" wire:model="title">
</form>
wire:model.livewire:click,
wire:mouseenter,
wire:submit to actions (= methods of your component)
<?php
use Livewire\Component;
use App\Models\Post;
new class extends Component {
public $title = '';
public $content = '';
public function save()
{
Post::create([
'title' => $this->title,
'content' => $this->content,
]);
return $this->redirect('/posts', navigate: true);
}
};
?>
<form wire:submit="save">
<input type="text" wire:model="title">
<textarea wire:model="content"></textarea>
<button type="submit">Save</button>
</form>
wire:keydown.enter="searchPosts"wire:click="delete({{ $post->id }})", with model binding: public function delete(Post $post)Wireable interfacepublic function addTodo()
{
$this->todos[] = $this->todo;
$this->reset('todo');
// all at once: $this->todos[] = $this->pull('todo');
}
$id property on a component for updating a blogpost
#[Locked]
public $id;
public function save()
{
$validated = $this->validate([
'title' => 'required|min:3',
'content' => 'required|min:3',
]);
…
<form wire:submit="save">
<input type="text" wire:model="title">
<div>@error('title') {{ $message }} @enderror</div>
<textarea wire:model="content"></textarea>
<div>@error('content') {{ $message }} @enderror</div>
<button type="submit">Save</button>
</form>
wire:model.live and wire:model.blur and optimize with
wire:model.live.debounce.150ms or wire:model.live.throttle.150ms
<livewire:revenue lazy />
<livewire:todo-count :todos="$todos" label="Todo Count:" inline/>
when prop name = variable name:
<livewire:todo-count :$todos />
:key prop when rendering children in a loop:
@foreach ($todos as $todo)
<livewire:todo-item :$todo :key="$todo->id" />
@endforeach
<livewire:todo-count :$todos />
class TodoCount extends Component
{
#[Reactive]
public $todos;
class TodoList extends Component
{
public $todo = '';
<livewire:todo-input wire:model="todo" />
class TodoInput extends Component
{
#[Modelable]
public $value = '';
wire:navigate (loading bar + DOM patching)
<nav>
<a href="/" wire:navigate>Dashboard</a>
<a href="/posts" wire:navigate>Posts</a>
<a href="/users" wire:navigate>Users</a>
</nav>
return $this->redirect('/posts', navigate: true);
<div>
<h1>{{ $post->title }}</h1>
<div x-data="{ expanded: false }">
<button type="button" x-on:click="expanded = ! expanded">
<span x-show="! expanded">Show post content...</span>
<span x-show="expanded">Hide post content...</span>
</button>
<div x-show="expanded">
{{ $post->content }}
</div>
</div>
</div>
$wire object
<form wire:submit="save">
<input wire:model="title" type="text" x-on:blur="$wire.save()">
<button type="button" x-on:click="$wire.title = ''">Clear</button>
Character count: <span x-text="$wire.content.length"></span>
git checkout 90cea521 git branch livewire git checkout livewire composer install| Technology | Rendering | JS Involved | API Needed? | Server Response Type | Description | Key Tech |
|---|---|---|---|---|---|---|
| Traditional SPA | Client-side | High | ✅ Yes | JSON (via REST or GraphQL) | Decoupled frontend/backend; communicates via API. | Vue/React + REST/GraphQL |
| HTMX | Server-rendered partials | Very Light | ❌ No | HTML fragments | HTML-over-the-wire; backend renders HTML snippets to be swapped in the DOM. | Any backend + HTMX |
| Livewire | Server-side (reactive) | Light | ❌ No | HTML + JSON (diffs) | Server renders Blade views; diffs sent via JSON for DOM patching. | PHP (Livewire) |
| Inertia.js | Client-side SPA with server routing | Medium | ❌ No | JSON (page props & component name) | Server sends page data as JSON; frontend renders via Vue/React. | Laravel + Vue/React/Svelte + Inertia |
| Fusion | Client-side SPA with inline PHP logic | Medium-High | ❌ No | JSON (via inline PHP execution) | Write PHP directly inside Vue/React components; no API/controller needed. | Vue/React + PHP (Fusion) |