Seamless two-way reactive bridge between Livewire 4 and Vue 3 / React.
State persistence, Filament 5 support, artisan generators, and Vite auto-import included.
Compatible with: Laravel 11 / 12 / 13 · Livewire 4 · Filament 5 · PHP 8.3+
Modern Laravel applications are built on Livewire. It is powerful, productive, and keeps you in PHP. But sometimes you need a rich interactive component — a chart library, a drag-and-drop board, a complex form widget — and the best implementation exists in Vue or React.
The usual answer is "pick one stack." Either go all-in on Livewire + Alpine, or abandon Livewire for Inertia. WireBridge rejects that tradeoff.
The core idea: Livewire owns the page, the routing, the server state, and the lifecycle. Vue or React owns a region of the DOM where rich interactivity lives. The bridge makes Livewire public properties behave like native framework state inside that region — two-way bound, reactive, and persisted — so the JS component feels like it is running in a normal Vue or React application.
You write a normal Vue <script setup> or a normal React function component. The only difference is one import: useLivewire() instead of defining your own state. Everything else — child components, third-party libraries, CSS frameworks, local ref() or useState() — works exactly as it would in a standalone JS app.
Without WireBridge, embedding Vue or React inside Livewire means:
@json() and data attributes, then parsing them in JS.wire:ignore to prevent Livewire from destroying your JS mount, but losing all reactivity between the two worlds.wire:navigate, parent re-render, or page reload.WireBridge eliminates all of this. One composable gives you reactive access to every Livewire public property, a way to call any PHP method, and a local state layer that persists across re-mounts — all without writing any glue code.
Livewire public properties are automatically synced into your Vue or React component. Change a property in Vue and it pushes to Livewire. Change it in PHP and it appears in Vue. No events, no watchers, no manual synchronization.
<!-- Vue: state.count is always in sync with the PHP $count property -->
<button @click="state.count++">{{ state.count }}</button>
// React: same thing, immutable convention
<button onClick={() => set('count', state.count + 1)}>{state.count}</button>
Any public method on your Livewire component is callable from the JS side via wire. Return values come back as promises.
// Vue or React — identical syntax
wire.addItem('Buy groceries');
wire.save();
wire.deletePost(42).then(() => console.log('deleted'));
UI concerns like sidebar toggles, form drafts, scroll positions, and active tabs live in local. This state is never sent to PHP but survives page reloads, wire:navigate, and parent Livewire re-renders via sessionStorage.
<!-- Vue -->
<textarea v-model="local.draft" placeholder="This survives reloads…"></textarea>
// React
<textarea value={local.draft ?? ''} onChange={e => setLocal('draft', e.target.value)} />
Both state (Livewire properties) and local (JS-only) are persisted to sessionStorage on every change. When the component re-mounts — after a page reload, wire:navigate, or a parent Livewire re-render — the bridge restores the previous state instantly, before the server round-trip completes.
On the PHP side, #[Session] keeps Livewire properties in the server session as well, giving you double persistence (client + server).
One command scaffolds the Livewire class, Blade view, and Vue/React component with useLivewire() already wired up.
php artisan make:wire-bridge TodoList --vue
php artisan make:wire-bridge Dashboard --react
php artisan make:wire-bridge RevenueChart --vue --filament
A Vite plugin scans your components directory and auto-registers everything. Drop a new .vue or .jsx file into the folder, and it is immediately available in Blade — no manual registration, no touching app.js.
A WireBridgeWidget base class lets you embed Vue or React components inside Filament dashboards, panels, and resource pages with zero Blade files.
The bridge is three layers: a framework-agnostic core (core.js) that talks to $wire, and thin adapters for Vue (vue.js) and React (react.js). You can mix frameworks on the same page — Vue for the sidebar, React for the main panel — because each mount is independent.
composer require mwguerra/wire-bridge
php artisan wire-bridge:install --vue # Vue only
php artisan wire-bridge:install --react # React only
php artisan wire-bridge:install --both # Both frameworks
This publishes the JS bridge files into resources/js/livewire-bridge/, the mount script, example components, and installs the npm dependencies.
Add --no-deps to skip the npm install step if you prefer to manage dependencies yourself.
Add the appropriate framework plugins and (optionally) the auto-import plugin to your vite.config.js:
// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue'; // if using Vue
import react from '@vitejs/plugin-react'; // if using React
import { wireBridgeAutoImport } from './resources/js/livewire-bridge/vite-plugin'; // optional
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
vue(), // if using Vue
react(), // if using React
wireBridgeAutoImport(), // optional: auto-discovers components
],
resolve: {
alias: {
vue: 'vue/dist/vue.esm-bundler.js', // if using Vue
},
},
});
With auto-import (recommended):
// resources/js/app.js
import './bootstrap';
import 'virtual:wire-bridge'; // auto-registers all Vue/React components
Without auto-import (manual registration):
// resources/js/app.js
import './bootstrap';
import { registerComponent } from './livewire-mount';
import ChatApp from './components/ChatApp/ChatApp.vue';
registerComponent('chat-app', { framework: 'vue', component: ChatApp });
import Dashboard from './components/Dashboard/Dashboard';
registerComponent('dashboard', { framework: 'react', component: Dashboard });
php artisan make:wire-bridge TodoList --vue
This creates three files:
app/Livewire/TodoList.php — the Livewire component:
<?php
namespace App\Livewire;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Session;
use Livewire\Component;
class TodoList extends Component
{
#[Session]
public string $title = '';
#[Session]
public int $count = 0;
public function increment(): void
{
$this->count++;
}
#[Layout('layouts.app')]
public function render()
{
return view('livewire.todo-list');
}
}
resources/views/livewire/todo-list.blade.php — the Blade view:
<div>
<div wire:ignore id="js-app"></div>
@include('wire-bridge::mount', ['component' => 'todo-list', 'el' => '#js-app'])
</div>
resources/js/components/TodoList/TodoList.vue — the Vue component:
<template>
<div>
<h2>TodoList</h2>
<input v-model="state.title" placeholder="Type here…" />
<button @click="state.count--">−</button>
<span>{{ state.count }}</span>
<button @click="wire.increment()">+ (PHP)</button>
<textarea v-model="local.notes" placeholder="Survives reloads…" rows="2"></textarea>
</div>
</template>
<script setup>
import { useLivewire } from '../../livewire-bridge/vue';
const { state, local, wire } = useLivewire();
if (local.notes === undefined) local.notes = '';
</script>
// routes/web.php
use App\Livewire\TodoList;
Route::get('/todos', TodoList::class);
npm run dev
php artisan serve
Visit http://localhost:8000/todos. The Vue component renders inside the Livewire page. Type in the input — the Livewire $title property updates in real time. Click the buttons — one mutates state client-side, the other calls PHP. Reload the page — everything is restored.
A full todo application showing all features: two-way binding, PHP method calls, local state, and CRUD operations.
<?php
// app/Livewire/TodoApp.php
namespace App\Livewire;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Session;
use Livewire\Component;
class TodoApp extends Component
{
#[Session]
public string $title = 'My Todos';
#[Session]
public array $items = [];
#[Session]
public string $filter = 'all'; // all, active, done
public function addItem(string $label): void
{
$this->items[] = [
'id' => uniqid(),
'label' => $label,
'done' => false,
];
}
public function toggleItem(string $id): void
{
foreach ($this->items as &$item) {
if ($item['id'] === $id) {
$item['done'] = ! $item['done'];
}
}
}
public function removeItem(string $id): void
{
$this->items = array_values(
array_filter($this->items, fn ($item) => $item['id'] !== $id)
);
}
public function clearDone(): void
{
$this->items = array_values(
array_filter($this->items, fn ($item) => ! $item['done'])
);
}
#[Layout('layouts.app')]
public function render()
{
return view('livewire.todo-app');
}
}
{{-- resources/views/livewire/todo-app.blade.php --}}
<div>
<div wire:ignore id="js-app"></div>
@include('wire-bridge::mount', ['component' => 'todo-app', 'el' => '#js-app'])
</div>
<!-- resources/js/components/TodoApp/TodoApp.vue -->
<template>
<div class="max-w-md mx-auto p-6">
<!-- Title: two-way bound to Livewire $title -->
<input
v-model="state.title"
class="text-2xl font-bold w-full border-none outline-none"
/>
<!-- New item input: local state (never sent to PHP) -->
<div class="flex gap-2 mt-4">
<input
v-model="local.newItem"
@keyup.enter="add"
placeholder="What needs to be done?"
class="flex-1 border rounded px-3 py-2"
/>
<button @click="add" class="px-4 py-2 bg-blue-500 text-white rounded">
Add
</button>
</div>
<!-- Filter tabs: Livewire property -->
<div class="flex gap-2 mt-4">
<button
v-for="f in ['all', 'active', 'done']"
:key="f"
@click="state.filter = f"
:class="state.filter === f ? 'font-bold underline' : 'text-gray-500'"
>
{{ f }}
</button>
</div>
<!-- Item list: Livewire property, CRUD via PHP methods -->
<ul class="mt-4 space-y-2">
<li
v-for="item in filteredItems"
:key="item.id"
class="flex items-center gap-2"
>
<input
type="checkbox"
:checked="item.done"
@change="wire.toggleItem(item.id)"
/>
<span :class="{ 'line-through text-gray-400': item.done }">
{{ item.label }}
</span>
<button @click="wire.removeItem(item.id)" class="ml-auto text-red-400">
✕
</button>
</li>
</ul>
<!-- Summary -->
<div class="mt-4 text-sm text-gray-500 flex justify-between">
<span>{{ remaining }} items left</span>
<button
v-if="state.items.some(i => i.done)"
@click="wire.clearDone()"
class="text-red-500"
>
Clear done
</button>
</div>
<!-- Sidebar toggle: local state (persisted, never sent to PHP) -->
<button
@click="local.showStats = !local.showStats"
class="mt-4 text-sm text-blue-500"
>
{{ local.showStats ? 'Hide' : 'Show' }} stats
</button>
<div v-if="local.showStats" class="mt-2 p-3 bg-gray-50 rounded text-sm">
Total: {{ state.items.length }} ·
Done: {{ state.items.filter(i => i.done).length }} ·
Active: {{ remaining }}
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useLivewire } from '../../livewire-bridge/vue';
const { state, local, wire } = useLivewire();
// Local state defaults
if (local.newItem === undefined) local.newItem = '';
if (local.showStats === undefined) local.showStats = false;
// Computed property — works exactly like in any Vue app
const filteredItems = computed(() => {
if (state.filter === 'active') return state.items.filter(i => !i.done);
if (state.filter === 'done') return state.items.filter(i => i.done);
return state.items;
});
const remaining = computed(() => state.items.filter(i => !i.done).length);
function add() {
const label = local.newItem.trim();
if (!label) return;
wire.addItem(label); // calls PHP → Livewire pushes updated items back
local.newItem = '';
}
</script>
Notice how the Vue component uses computed, v-model, v-for, scoped event handlers, and conditional rendering — all standard Vue. The only WireBridge-specific code is the useLivewire() import and the wire.method() calls.
The same todo application in React.
Use the identical TodoApp.php from the Vue example above. The PHP side is framework-agnostic.
// resources/js/components/TodoApp/TodoApp.jsx
import React, { useEffect, useMemo, useState } from 'react';
import { useLivewire } from '../../livewire-bridge/react';
export default function TodoApp() {
const { state, local, wire, set, setLocal } = useLivewire();
const [newItem, setNewItem] = useState('');
// Local state defaults
useEffect(() => {
if (local.showStats === undefined) setLocal('showStats', false);
}, []);
// Computed values — standard React
const filteredItems = useMemo(() => {
if (state.filter === 'active') return state.items.filter(i => !i.done);
if (state.filter === 'done') return state.items.filter(i => i.done);
return state.items;
}, [state.items, state.filter]);
const remaining = useMemo(
() => state.items.filter(i => !i.done).length,
[state.items]
);
function add() {
const label = newItem.trim();
if (!label) return;
wire.addItem(label);
setNewItem('');
}
return (
<div style={{ maxWidth: 448, margin: '0 auto', padding: 24 }}>
{/* Title: two-way bound to Livewire $title */}
<input
value={state.title}
onChange={e => set('title', e.target.value)}
style={{ fontSize: '1.5rem', fontWeight: 'bold', width: '100%', border: 'none' }}
/>
{/* New item input: local React state */}
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<input
value={newItem}
onChange={e => setNewItem(e.target.value)}
onKeyUp={e => e.key === 'Enter' && add()}
placeholder="What needs to be done?"
style={{ flex: 1, border: '1px solid #ccc', borderRadius: 4, padding: '8px 12px' }}
/>
<button onClick={add}>Add</button>
</div>
{/* Filter tabs: Livewire property */}
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
{['all', 'active', 'done'].map(f => (
<button
key={f}
onClick={() => set('filter', f)}
style={{
fontWeight: state.filter === f ? 'bold' : 'normal',
textDecoration: state.filter === f ? 'underline' : 'none',
}}
>
{f}
</button>
))}
</div>
{/* Item list */}
<ul style={{ listStyle: 'none', padding: 0, marginTop: 16 }}>
{filteredItems.map(item => (
<li key={item.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
<input
type="checkbox"
checked={item.done}
onChange={() => wire.toggleItem(item.id)}
/>
<span style={item.done ? { textDecoration: 'line-through', color: '#999' } : undefined}>
{item.label}
</span>
<button onClick={() => wire.removeItem(item.id)} style={{ marginLeft: 'auto', color: 'red' }}>
✕
</button>
</li>
))}
</ul>
{/* Summary */}
<div style={{ marginTop: 16, fontSize: '0.875rem', color: '#666', display: 'flex', justifyContent: 'space-between' }}>
<span>{remaining} items left</span>
{state.items.some(i => i.done) && (
<button onClick={() => wire.clearDone()} style={{ color: 'red' }}>
Clear done
</button>
)}
</div>
{/* Sidebar toggle: persisted local state */}
<button
onClick={() => setLocal('showStats', !local.showStats)}
style={{ marginTop: 16, fontSize: '0.875rem', color: '#3b82f6' }}
>
{local.showStats ? 'Hide' : 'Show'} stats
</button>
{local.showStats && (
<div style={{ marginTop: 8, padding: 12, background: '#f9fafb', borderRadius: 4, fontSize: '0.875rem' }}>
Total: {state.items.length} · Done: {state.items.filter(i => i.done).length} · Active: {remaining}
</div>
)}
</div>
);
}
The React component uses useMemo, useState, useEffect, conditional rendering, and .map() — all standard React patterns. The only WireBridge-specific code is useLivewire() and wire.method() / set() / setLocal().
The hook signature and behavior are intentionally parallel. The only real difference is Vue's mutable reactivity (state.x = y) versus React's immutable convention (set('x', y)).
| Concern | Vue | React |
|---|---|---|
| Import | from '../../livewire-bridge/vue' |
from '../../livewire-bridge/react' |
| Hook | useLivewire() |
useLivewire() |
| Read Livewire state | state.count |
state.count |
| Write Livewire state | state.count = 5 |
set('count', 5) |
| Read local state | local.draft |
local.draft |
| Write local state | local.draft = 'hi' |
setLocal('draft', 'hi') |
| Two-way input binding | v-model="state.title" |
value={state.title} onChange={e => set('title', e.target.value)} |
| Call PHP method | wire.save() |
wire.save() |
| Call with args | wire.addItem('x') |
wire.addItem('x') |
| Await return value | wire.compute(5).then(r => ...) |
wire.compute(5).then(r => ...) |
| Local component state | ref(), reactive(), computed() |
useState(), useMemo() |
| Clear persistence | clearPersisted() |
clearPersisted() |
wire-bridge:installPublishes JS bridge files, mount script, config, and example components. Optionally installs npm dependencies.
php artisan wire-bridge:install --vue # Vue deps
php artisan wire-bridge:install --react # React deps
php artisan wire-bridge:install --both # Both
php artisan wire-bridge:install --no-deps # Skip npm install
make:wire-bridgeScaffolds a complete WireBridge component: PHP class, Blade view, and JS component.
php artisan make:wire-bridge ChatApp # Vue (default)
php artisan make:wire-bridge ChatApp --vue # Vue (explicit)
php artisan make:wire-bridge Dashboard --react # React
php artisan make:wire-bridge RevenueChart --vue --filament # Filament widget + Vue
php artisan make:wire-bridge Admin/Analytics --react # Nested namespace
php artisan make:wire-bridge ChatApp --force # Overwrite existing
Generated files for make:wire-bridge ChatApp --vue:
| File | Purpose |
|---|---|
app/Livewire/ChatApp.php |
Livewire component with #[Session] properties |
resources/views/livewire/chat-app.blade.php |
Blade with wire:ignore + mount partial |
resources/js/components/ChatApp/ChatApp.vue |
Vue SFC with useLivewire() |
For --filament, the PHP class extends WireBridgeWidget and goes into app/Filament/Widgets/. No Blade file is generated because the base class provides it.
The Vite plugin eliminates manual component registration. It scans directories for .vue, .jsx, and .tsx files and generates a virtual module that registers them all.
// vite.config.js
import { wireBridgeAutoImport } from './resources/js/livewire-bridge/vite-plugin';
export default defineConfig({
plugins: [
// ... laravel(), vue(), react(),
wireBridgeAutoImport(),
],
});
// resources/js/app.js
import 'virtual:wire-bridge'; // that's it — all components registered
Component names are derived from file paths using kebab-case:
| File path | Registered name | Blade usage |
|---|---|---|
components/ChatApp/ChatApp.vue |
chat-app |
['component' => 'chat-app'] |
components/Dashboard.jsx |
dashboard |
['component' => 'dashboard'] |
components/Admin/Revenue.vue |
admin-revenue |
['component' => 'admin-revenue'] |
components/Charts/LineChart.tsx |
charts-line-chart |
['component' => 'charts-line-chart'] |
When a file is inside a directory with the same name (e.g., ChatApp/ChatApp.vue), the duplicate is collapsed to just chat-app.
wireBridgeAutoImport({
dirs: ['resources/js/components'], // directories to scan (default)
mountImport: './livewire-mount', // path to mount script (default)
})
During development, the plugin watches the component directories. When you add or remove a .vue/.jsx/.tsx file, Vite triggers a full reload so the new component is immediately available — no restart needed.
WireBridge integrates with Filament dashboards, panels, and resource pages. There are three ways to use it.
Extend WireBridgeWidget instead of Filament\Widgets\Widget. The base class provides the Blade view automatically — you only write PHP and JS.
<?php
namespace App\Filament\Widgets;
use Livewire\Attributes\Session;
use MWGuerra\WireBridge\Filament\WireBridgeWidget;
class RevenueChart extends WireBridgeWidget
{
// Must match the registered JS component name
protected static string $bridgeComponent = 'revenue-chart';
// Optional: customize mount element, persistence, column span
protected static string $bridgeEl = '#js-app';
protected static bool $bridgePersist = true;
protected int | string | array $columnSpan = 'full';
#[Session]
public array $data = [];
#[Session]
public string $period = 'month';
public function loadData(): array
{
return \App\Models\Revenue::forPeriod($this->period)->get()->toArray();
}
public function setPeriod(string $period): void
{
$this->period = $period;
}
}
Then create the Vue/React component as usual:
<!-- resources/js/components/RevenueChart/RevenueChart.vue -->
<template>
<div>
<select v-model="state.period" @change="wire.loadData()">
<option value="week">Week</option>
<option value="month">Month</option>
<option value="year">Year</option>
</select>
<!-- Use any chart library: Chart.js, Recharts, etc. -->
<canvas ref="chartEl"></canvas>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import { useLivewire } from '../../livewire-bridge/vue';
const { state, wire } = useLivewire();
const chartEl = ref(null);
watch(() => state.data, (data) => {
// Update your chart library here
}, { deep: true });
</script>
php artisan make:wire-bridge RevenueChart --vue --filament
Generates app/Filament/Widgets/RevenueChart.php extending WireBridgeWidget and the Vue component. No Blade file needed.
Add wire:ignore and the mount partial to any existing Filament widget Blade view:
<x-filament-widgets::widget>
<x-filament::section>
<div wire:ignore id="js-app"></div>
@include('wire-bridge::mount', ['component' => 'my-widget', 'el' => '#js-app'])
</x-filament::section>
</x-filament-widgets::widget>
WireBridge persists state at two layers to ensure nothing is lost during re-mounts.
state)Persisted both server-side (via Livewire's #[Session] attribute) and client-side (via sessionStorage). The client-side cache provides instant restoration before the server round-trip completes. The #[Session] attribute is optional but recommended for properties that should survive full page reloads.
local)Persisted only client-side in sessionStorage. Never sent to PHP. Use for UI concerns: form drafts, sidebar toggles, scroll positions, active tabs, accordion states.
| Scenario | state (Livewire) |
local (JS-only) |
|---|---|---|
| Livewire server round-trip | ✅ Kept (snapshot) | ✅ Kept (in-memory) |
| Parent Livewire re-render | ✅ Restored from session | ✅ Restored from sessionStorage |
wire:navigate |
✅ Restored | ✅ Restored |
| Full page reload (F5) | ✅ Restored | ✅ Restored |
| New browser tab | ❌ Fresh start | ❌ Fresh start |
| Tab closed and reopened | ❌ Fresh start | ❌ Fresh start |
Per-component in Blade:
@include('wire-bridge::mount', ['component' => 'ephemeral', 'persist' => false])
At registration time:
registerComponent('ephemeral', { framework: 'vue', component: App, persist: false });
Globally in config:
// config/wire-bridge.php
'persist' => false,
From within a Vue or React component:
// Vue
const { clearPersisted } = useLivewire();
clearPersisted(); // wipes sessionStorage for this component
// React
const { clearPersisted } = useLivewire();
clearPersisted();
Mount different Vue/React components on separate wire:ignore containers within the same Livewire component:
<div>
<div wire:ignore id="sidebar"></div>
<div wire:ignore id="main-panel"></div>
@assets
@vite(['resources/js/app.js'])
@endassets
@script
<script>
window.__wirebridge = window.__wirebridge || {};
window.__wirebridge[$wire.$id] = $wire;
['sidebar', 'main-panel'].forEach(name => {
window.dispatchEvent(new CustomEvent('wirebridge:mount', {
detail: {
id: $wire.$id,
el: $wire.$el.querySelector(`#${name}`),
component: name,
}
}));
});
</script>
@endscript
</div>
Both components share the same Livewire $wire proxy, so they see the same server state and can call the same PHP methods. You can even mix frameworks — Vue for the sidebar, React for the main panel.
Publish the config file:
php artisan vendor:publish --tag=wire-bridge-config
// config/wire-bridge.php
return [
// Global default for state persistence (true = sessionStorage enabled)
'persist' => true,
// Where JS bridge files are published
'js_path' => 'resources/js/livewire-bridge',
];
┌────────────────────────────────────────────────────┐
│ Livewire 4 PHP Component │
│ public $title, $count, $items │
│ #[Session] for server-side persistence │
│ increment(), addItem(), toggleItem() │
│ │
│ ┌──── wire:ignore ─────────────────────────┐ │
│ │ Vue 3 or React 18 App │ │
│ │ │ │
│ │ state.title ←→ $wire.title │ │
│ │ state.count ←→ $wire.count │ │
│ │ state.items ←→ $wire.items │ │
│ │ │ │
│ │ local.draft ←→ sessionStorage │ │
│ │ local.sidebar ←→ sessionStorage │ │
│ │ │ │
│ │ wire.increment() → PHP method │ │
│ │ wire.addItem('x') → PHP method │ │
│ └──────────────────────────────────────────┘ │
│ ↕ │
│ livewire-bridge/core.js │
│ ├── $wire.$watch() (LW → JS) │
│ ├── $wire.$set() (JS → LW) │
│ └── sessionStorage (persistence) │
└────────────────────────────────────────────────────┘
| Layer | File | Role |
|---|---|---|
| Core | core.js |
Framework-agnostic. Discovers Livewire properties via $wire.__instance(), syncs via $wire.$watch and $wire.$set, manages sessionStorage persistence. |
| Adapter | vue.js / react.js |
Thin wrappers. Vue uses reactive() + watch() + provide/inject. React uses useSyncExternalStore + context. Both expose useLivewire(). |
| Mount | livewire-mount.js |
Listens for wirebridge:mount events from Blade, looks up the registered component, mounts the correct framework. |
| Direction | Mechanism | When |
|---|---|---|
| Livewire → JS | $wire.$watch(key, callback) |
After any server round-trip that changes the property |
| JS → Livewire | $wire.$set(key, value) |
When you mutate state.* (Vue) or call set() (React) |
| JS → PHP method | wire.methodName(args) |
On demand (button click, form submit, etc.) |
| Persist (client) | sessionStorage.setItem() |
On every state or local change |
| Persist (server) | #[Session] attribute |
Managed by Livewire automatically |
| Restore (client) | sessionStorage.getItem() |
On component mount, before server round-trip |
useLivewire()import { useLivewire } from '../../livewire-bridge/vue';
const { state, local, wire, clearPersisted } = useLivewire();
| Property | Type | Description |
|---|---|---|
state |
Reactive<object> |
Livewire public properties. Read and write directly. Changes sync to PHP and are persisted. |
local |
Reactive<object> |
JS-only state. Read and write directly. Persisted to sessionStorage, never sent to PHP. |
wire |
$wire proxy |
Raw Livewire proxy. Call any public PHP method: wire.save(), wire.delete(id). Returns promises. |
clearPersisted |
() => void |
Removes all persisted state for this component from sessionStorage. |
useLivewire()import { useLivewire } from '../../livewire-bridge/react';
const { state, local, wire, set, setLocal, clearPersisted } = useLivewire();
| Property | Type | Description |
|---|---|---|
state |
object |
Livewire public properties. Read-only snapshot (immutable React convention). |
local |
object |
JS-only state. Read-only snapshot. |
wire |
$wire proxy |
Raw Livewire proxy. Call any public PHP method. Returns promises. |
set |
(key: string, value: any) => void |
Write a Livewire property. Syncs to PHP and triggers React re-render. |
setLocal |
(key: string, value: any) => void |
Write a JS-only local property. Persisted, triggers re-render. |
clearPersisted |
() => void |
Removes all persisted state for this component from sessionStorage. |
@include('wire-bridge::mount', [
'component' => 'chat-app', // registered component name (required)
'el' => '#js-app', // CSS selector for mount element (default: '#js-app')
'persist' => true, // enable/disable persistence (default: true)
])
use MWGuerra\WireBridge\Filament\WireBridgeWidget;
class MyWidget extends WireBridgeWidget
{
protected static string $bridgeComponent = 'my-widget'; // JS component name
protected static string $bridgeEl = '#js-app'; // mount selector
protected static bool $bridgePersist = true; // persistence
protected int | string | array $columnSpan = 'full'; // Filament column span
}
import { wireBridgeAutoImport } from './resources/js/livewire-bridge/vite-plugin';
wireBridgeAutoImport({
dirs: ['resources/js/components'], // directories to scan
mountImport: './livewire-mount', // path to mount script
});
After php artisan wire-bridge:install:
resources/js/
├── livewire-bridge/
│ ├── core.js ← framework-agnostic $wire ↔ state sync + persistence
│ ├── vue.js ← Vue 3 adapter (reactive + provide/inject)
│ ├── react.js ← React 18 adapter (useSyncExternalStore + context)
│ ├── index.js ← barrel export
│ └── vite-plugin.js ← Vite auto-import plugin
├── livewire-mount.js ← universal mount script (event listener + framework router)
└── components/ ← your Vue/React components go here
├── ExampleVue.vue ← example (if --vue)
└── ExampleReact.jsx ← example (if --react)
These files are published into your project and have no runtime dependency on the Composer package. You can edit them freely.
Component not mounting: Make sure the registered component name in app.js (or the auto-import derived name) matches the component value in your Blade @include('wire-bridge::mount', ['component' => 'name']).
State not syncing: Verify your Livewire properties are public. Private and protected properties are not exposed to $wire.
State lost on reload: Add #[Session] to your Livewire properties for server-side persistence. The client-side sessionStorage cache restores state instantly, but #[Session] ensures the server also remembers.
Vue/React not rendering: Check that wire:ignore is on the mount container element. Without it, Livewire's DOM morphing will destroy the JS app on every server round-trip.
Vite auto-import not finding components: Ensure the component file is inside the configured dirs (default: resources/js/components/) and has a .vue, .jsx, or .tsx extension. Files starting with _ or named index are skipped.
WireBridge was inspired by Mingle by Patricio.
MIT — see LICENSE for details.
How can I help you explore Laravel packages today?