mwguerra/wire-bridge

Seamless two-way reactive bridge between Livewire 4 and Vue 3 / React components with automatic state persistence.

Maintainers

Package info

github.com/mwguerra/wire-bridge

pkg:composer/mwguerra/wire-bridge

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-03-21 01:32 UTC

This package is auto-updated.

Last update: 2026-03-21 01:37:51 UTC


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:ignore to 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.