mwguerra / wire-bridge
Seamless two-way reactive bridge between Livewire 4 and Vue 3 / React components with automatic state persistence.
Requires
- php: ^8.3
- illuminate/support: ^11.0|^12.0|^13.0
- livewire/livewire: ^4.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
Suggests
- filament/filament: Required for Filament widget support (^5.0)
README
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+
Philosophy
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.
The problem it solves
Without WireBridge, embedding Vue or React inside Livewire means:
- Manually passing data through
@json()and data attributes, then parsing them in JS. - Using
wire:ignoreto prevent Livewire from destroying your JS mount, but losing all reactivity between the two worlds. - Writing custom event listeners to push data from JS back to Livewire and vice versa.
- Losing all JS state on every
wire:navigate, parent re-render, or page reload. - Duplicating state management: Livewire properties on the server, a separate store (Pinia, Redux, Zustand) on the client.
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.
Features
Two-way reactive data binding
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>
Call PHP methods from JS
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'));
JS-only local state with persistence
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)} />
Automatic state persistence
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).
Artisan generators
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
Vite auto-import
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.
Filament 5 support
A WireBridgeWidget base class lets you embed Vue or React components inside Filament dashboards, panels, and resource pages with zero Blade files.
Framework-agnostic core
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.
Installation
Step 1: Install the package
composer require mwguerra/wire-bridge
Step 2: Run the installer
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.
Step 3: Configure Vite
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 }, }, });
Step 4: Set up app.js
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 });
Quick start
Generate a component
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>
Add a route and run
// 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.
Complete Vue example
A full todo application showing all features: two-way binding, PHP method calls, local state, and CRUD operations.
PHP component
<?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'); } }
Blade view
{{-- 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>
Vue component
<!-- 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.
Complete React example
The same todo application in React.
PHP component
Use the identical TodoApp.php from the Vue example above. The PHP side is framework-agnostic.
React component
// 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().
Vue vs React — API comparison
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() |
Artisan commands
wire-bridge:install
Publishes 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-bridge
Scaffolds 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.
Vite auto-import
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.
Setup
// 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
Naming convention
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.
Options
wireBridgeAutoImport({ dirs: ['resources/js/components'], // directories to scan (default) mountImport: './livewire-mount', // path to mount script (default) })
Hot reload
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.
Filament 5 support
WireBridge integrates with Filament dashboards, panels, and resource pages. There are three ways to use it.
Option A: WireBridgeWidget base class (recommended)
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>
Option B: Artisan generator
php artisan make:wire-bridge RevenueChart --vue --filament
Generates app/Filament/Widgets/RevenueChart.php extending WireBridgeWidget and the Vue component. No Blade file needed.
Option C: Manual integration
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>
State persistence
WireBridge persists state at two layers to ensure nothing is lost during re-mounts.
Livewire properties (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.
JS-only state (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.
Persistence behavior by scenario
| 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 |
Disabling persistence
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,
Clearing persisted state
From within a Vue or React component:
// Vue const { clearPersisted } = useLivewire(); clearPersisted(); // wipes sessionStorage for this component // React const { clearPersisted } = useLivewire(); clearPersisted();
Multiple components on one page
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.
Configuration
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', ];
Architecture
┌────────────────────────────────────────────────────┐
│ 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) │
└────────────────────────────────────────────────────┘
Three-layer design
| 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. |
Data flow
| 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 |
API reference
Vue — 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. |
React — 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. |
Blade mount partial
@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) ])
WireBridgeWidget (Filament)
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 }
Vite plugin
import { wireBridgeAutoImport } from './resources/js/livewire-bridge/vite-plugin'; wireBridgeAutoImport({ dirs: ['resources/js/components'], // directories to scan mountImport: './livewire-mount', // path to mount script });
Published file structure
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.
Troubleshooting
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.
Acknowledgments
WireBridge was inspired by Mingle by Patricio.
License
MIT — see LICENSE for details.