Local-first IndexedDB syncing for Laravel and Livewire applications.
Duo enables automatic client-side caching and synchronization of your Eloquent models using IndexedDB, providing a seamless offline-first experience for your Laravel/Livewire applications. Just add a trait to your Livewire component and Duo handles the rest—automatically transforming your server-side components to work with IndexedDB.
Want to contribute or test Duo locally? Follow these steps to set up local development with symlinked packages.
# Clone the Duo package repository
git clone https://github.com/joshcirre/duo.git
cd duo
# Install PHP dependencies
composer install
# Install Node dependencies
npm install
# Build the package
npm run build
Link the Duo package to your local Laravel application:
# In your Laravel app directory (e.g., ~/Code/my-laravel-app)
cd ~/Code/my-laravel-app
# Add the local repository to composer.json
composer config repositories.duo path ../duo
# Require the package from the local path
composer require joshcirre/duo:@dev
This creates a symlink in vendor/joshcirre/duo pointing to your local Duo directory. Changes to the PHP code are immediately reflected.
Link the Vite plugin to your Laravel application:
# In the Duo package directory
cd ~/Code/duo
npm link
# In your Laravel app directory
cd ~/Code/my-laravel-app
npm link @joshcirre/vite-plugin-duo
Now your Laravel app uses the local version of the Vite plugin.
In the Duo package directory, run the build watcher:
cd ~/Code/duo
npm run dev
This watches for TypeScript changes and rebuilds automatically. Changes are immediately available in your linked Laravel app.
In your Laravel app:
# Run both Vite and Laravel (recommended)
composer run dev
This runs both npm run dev and php artisan serve concurrently. Any changes you make to Duo's PHP or TypeScript code will be reflected immediately!
Alternative (manual):
# Terminal 1: Vite
npm run dev
# Terminal 2: Laravel
php artisan serve
To remove the symlinks:
# Unlink npm package (in your Laravel app)
cd ~/Code/my-laravel-app
npm unlink @joshcirre/vite-plugin-duo
# Unlink composer package
composer config repositories.duo --unset
composer require joshcirre/duo # Reinstall from Packagist
# Unlink from Duo directory
cd ~/Code/duo
npm unlink
npm run build or npm run dev (watch mode)php artisan optimize:clearphp artisan duo:generate manually if neededcomposer require joshcirre/duo
npm install -D @joshcirre/vite-plugin-duo
Note: Dexie is automatically installed as a dependency.
Duo works out-of-the-box without publishing any files. However, you can publish various assets for customization:
# Publish configuration file
php artisan vendor:publish --tag=duo-config
# Publish Blade components (sync-status, debug panel)
php artisan vendor:publish --tag=duo-views
# Publish JavaScript assets (advanced users only)
php artisan vendor:publish --tag=duo-assets
# Publish everything
php artisan vendor:publish --provider="JoshCirre\Duo\DuoServiceProvider"
What gets published:
duo-config → config/duo.php - Global configurationduo-views → resources/views/vendor/duo/components/ - Blade components for customizationduo-assets → resources/js/vendor/duo/ - JavaScript source files (rarely needed)See Publishing Components in the Configuration section for customization examples.
Add the Syncable trait to any Eloquent model you want to cache in IndexedDB:
use JoshCirre\Duo\Syncable;
class Todo extends Model
{
use Syncable;
protected $fillable = ['title', 'description', 'completed'];
}
Both $fillable and $guarded are supported:
// Option 1: Using $fillable (explicit allow list)
protected $fillable = ['title', 'description', 'completed'];
// Option 2: Using $guarded (explicit deny list)
protected $guarded = ['id']; // Everything except 'id' is fillable
Duo automatically extracts your model's fillable attributes and database schema (column types, nullable, defaults) to generate the IndexedDB manifest—no manual configuration needed!
User-Scoped Models:
For models that belong to users, add a user() relationship but do NOT add user_id to $fillable:
class Todo extends Model
{
use Syncable;
// ✅ CORRECT: user_id is NOT in $fillable (security)
protected $fillable = ['title', 'description', 'completed'];
// ✅ Add user relationship - Duo auto-assigns user_id during sync
public function user()
{
return $this->belongsTo(User::class);
}
}
Why? Including user_id in $fillable is a security risk—users could assign items to other users. Duo automatically detects the user() relationship and assigns the authenticated user's ID securely during sync.
CRITICAL: Add the @duoMeta directive to the <head> section of your main layout. This provides the CSRF token and enables offline page caching:
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@duoMeta
<title>{{ $title ?? config('app.name') }}</title>
<!-- rest of your head content -->
</head>
The @duoMeta directive outputs:
<meta name="csrf-token"> - Required for API sync requests<meta name="duo-cache"> - Tells the service worker to cache this page for offline accessAdd the Duo plugin to your vite.config.js:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import { duo } from '@joshcirre/vite-plugin-duo';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
duo(), // That's it! Uses sensible defaults
],
});
That's all you need! The plugin will automatically:
resources/js/duo/manifest.jsonapp/Models/**/*.php for changesresources/js/app.jspublic/duo-sw.js on buildWant to customize? All options have sensible defaults and are optional:
duo({
// Manifest path (default: 'resources/js/duo/manifest.json')
manifestPath: 'resources/js/duo/manifest.json',
// Watch for file changes (default: true)
watch: true,
// Auto-generate manifest (default: true)
autoGenerate: true,
// Files to watch for changes (default: ['app/Models/**/*.php'])
patterns: [
'app/Models/**/*.php',
'resources/views/livewire/**/*.php', // Include Volt components
'app/Livewire/**/*.php', // Include class-based components
],
// Entry file for auto-injection (default: 'resources/js/app.js')
entry: 'resources/js/app.js',
// Auto-inject initialization code (default: true)
autoInject: true,
// Custom artisan command (default: 'php artisan duo:generate')
command: 'php artisan duo:generate',
})
This is where the magic happens! Add the WithDuo trait to any Livewire component and Duo will automatically transform it to use IndexedDB:
Volt Component Example:
<?php
use Livewire\Volt\Component;
use App\Models\Todo;
use JoshCirre\Duo\WithDuo;
new class extends Component {
use WithDuo; // ✨ This is all you need!
public string $newTodoTitle = '';
public function addTodo()
{
Todo::create(['title' => $this->newTodoTitle]);
$this->reset('newTodoTitle');
}
public function toggleTodo($id)
{
$todo = Todo::findOrFail($id);
$todo->update(['completed' => !$todo->completed]);
}
public function deleteTodo($id)
{
Todo::findOrFail($id)->delete();
}
public function with()
{
return ['todos' => Todo::latest()->get()];
}
}; ?>
<div>
<form wire:submit="addTodo">
<input type="text" wire:model="newTodoTitle" placeholder="New todo...">
<button type="submit">Add</button>
</form>
<div class="space-y-2">
@forelse($todos as $todo)
<div>
<input
type="checkbox"
wire:click="toggleTodo({{ $todo->id }})"
{{ $todo->completed ? 'checked' : '' }}
>
<span>{{ $todo->title }}</span>
<button wire:click="deleteTodo({{ $todo->id }})">Delete</button>
</div>
@empty
<p>No todos yet</p>
@endforelse
</div>
</div>
Class-Based Component Example:
<?php
namespace App\Livewire;
use Livewire\Component;
use App\Models\Todo;
use JoshCirre\Duo\WithDuo;
class TodoList extends Component
{
use WithDuo; // ✨ Add this trait
public string $newTodoTitle = '';
public function addTodo()
{
Todo::create(['title' => $this->newTodoTitle]);
$this->reset('newTodoTitle');
}
public function render()
{
return view('livewire.todo-list', [
'todos' => Todo::latest()->get(),
]);
}
}
What Happens Automatically:
When you add the WithDuo trait, Duo will:
wire:click to Alpine.js @click handlers@forelse loops to Alpine x-for templates{{ $todo->property }} to x-text bindings:class bindingsx-cloak and loading state managementSync Status Indicator:
Display a visual indicator of the sync status:
<x-duo::sync-status position="top-right" />
This component shows:
Configuration Options:
{{-- Basic usage with position --}}
<x-duo::sync-status position="top-right" />
{{-- Customize when sync indicator appears --}}
<x-duo::sync-status :show-delay="2000" /> {{-- Wait 2 seconds before showing syncing indicator --}}
{{-- Show success indicator --}}
<x-duo::sync-status :show-success="true" /> {{-- Show "All changes synced" badge --}}
{{-- Display inline instead of fixed positioning --}}
<x-duo::sync-status :inline="true" />
{{-- All options combined --}}
<x-duo::sync-status
position="bottom-right"
:show-delay="1500"
:show-success="true"
:inline="false"
/>
Available Props:
| Prop | Type | Default | Description |
|---|---|---|---|
position |
string | 'top-right' |
Position of the indicator: 'top-right', 'top-left', 'bottom-right', 'bottom-left' |
inline |
bool | false |
Display inline instead of fixed positioning |
show-delay |
int | 1000 |
Milliseconds to wait before showing syncing indicator (prevents flash for fast syncs) |
show-success |
bool | false |
Whether to show "All changes synced" success message |
Global Configuration:
You can set default values in your config/duo.php:
'sync_status' => [
'show_delay' => env('DUO_SYNC_STATUS_DELAY', 1000),
'show_success' => env('DUO_SYNC_STATUS_SUCCESS', false),
'success_duration' => env('DUO_SYNC_STATUS_SUCCESS_DURATION', 2000),
],
Component-level props override global config values.
Debug Panel (Local Development Only):
Add a debug panel to view IndexedDB info and manage the database during development:
<x-duo::debug position="bottom-right" />
This component (only visible in local environment) provides:
The debug panel automatically shows the current schema version (timestamp-based) and makes it easy to test schema migrations by clearing the database.
Available Props:
| Prop | Type | Default | Description |
|---|---|---|---|
position |
string | 'bottom-right' |
Position of the panel: 'top-right', 'top-left', 'bottom-right', 'bottom-left' |
Note: Both components (sync-status and debug) can be customized by publishing the views. See Publishing Components for details.
Development:
composer run dev
This runs both npm run dev and php artisan serve concurrently. The Vite plugin will automatically:
php artisan duo:generate to create the manifestProduction Build:
npm run build
The production build will:
public/duo-sw.js automaticallyImportant for Offline Support:
@duoMeta and cache themThe service worker route (/duo-sw.js) is automatically registered by the Duo service provider—no additional configuration needed!
Duo uses a sophisticated local-first architecture that transforms your Livewire components into offline-capable Alpine.js applications:
Component Transformation: When a component with the WithDuo trait renders, Duo intercepts the HTML and transforms it:
@forelse loops → Alpine x-for templateswire:click handlers → Alpine @click with IndexedDB operations{{ $model->property }} → <span x-text="model.property"></span>:class bindingsx-cloak for smooth initializationSync Server Data: On page load, the Alpine component syncs server data to IndexedDB
Ready State: Component shows with duoReady flag set to true
Reads:
Writes:
Going Offline:
navigator.onLine API detects offline stateComing Back Online:
Publish the configuration file:
php artisan vendor:publish --tag=duo-config
Edit config/duo.php:
return [
'database_name' => env('DUO_DATABASE_NAME', 'duo_cache'),
'sync_strategy' => env('DUO_SYNC_STRATEGY', 'write-behind'),
'sync_interval' => env('DUO_SYNC_INTERVAL', 5000), // milliseconds
'max_retry_attempts' => env('DUO_MAX_RETRY_ATTEMPTS', 3),
'cache_ttl' => env('DUO_CACHE_TTL', null), // seconds, null = no expiration
'debug' => env('DUO_DEBUG', false),
'auto_discover' => env('DUO_AUTO_DISCOVER', true),
];
If you want to customize the Duo components (sync-status, debug panel), publish the views:
php artisan vendor:publish --tag=duo-views
This will copy the component views to resources/views/vendor/duo/components/. Published views take precedence over the package views, so you can customize:
Sync Status Component:
resources/views/vendor/duo/components/sync-status.blade.php
Debug Panel Component:
resources/views/vendor/duo/components/debug.blade.php
After publishing, you can modify:
Example Customization:
{{-- resources/views/vendor/duo/components/sync-status.blade.php --}}
{{-- Change offline badge color from amber to red --}}
<flux:badge color="red" icon="exclamation-triangle" size="lg">
<div style="display: flex; flex-direction: column; align-items: flex-start;">
<span style="font-weight: 600;">Connection Lost</span>
<span style="font-size: 0.75rem; opacity: 0.9;">Working offline</span>
</div>
</flux:badge>
Note: The components use inline styles by default, so they work out-of-the-box without requiring Tailwind or custom CSS. If you publish and customize them, you can keep the inline styles or switch to your own CSS approach.
You can customize Duo's behavior on a per-component basis using the type-safe DuoConfig class. This provides full IDE autocomplete and type checking!
Volt Component:
<?php
use Livewire\Volt\Component;
use JoshCirre\Duo\{WithDuo, DuoConfig};
new class extends Component {
use WithDuo;
// Type-safe configuration with IDE autocomplete!
protected function duoConfig(): DuoConfig
{
return DuoConfig::make(
syncInterval: 3000,
timestampRefreshInterval: 5000,
debug: true
);
}
// ... rest of your component
}
Class-Based Component:
<?php
namespace App\Livewire;
use Livewire\Component;
use JoshCirre\Duo\{WithDuo, DuoConfig};
class TodoList extends Component
{
use WithDuo;
protected function duoConfig(): DuoConfig
{
return DuoConfig::make(
timestampRefreshInterval: 30000,
maxRetryAttempts: 5
);
}
// ... rest of your component
}
Available Configuration Options:
All options can be set globally in config/duo.php and overridden per-component in duoConfig():
| Option | Type | Default | Global Config | Component Override | Description |
|---|---|---|---|---|---|
syncInterval |
int | 5000 | duo.sync_interval |
syncInterval |
Milliseconds between sync attempts to server. Controls how often pending changes are sent. |
timestampRefreshInterval |
int | 10000 | duo.timestamp_refresh_interval |
timestampRefreshInterval |
Milliseconds between timestamp updates. Controls how often relative times like "5 minutes ago" refresh. |
maxRetryAttempts |
int | 3 | duo.max_retry_attempts |
maxRetryAttempts |
Maximum number of retry attempts for failed sync operations before giving up. |
debug |
bool | false | duo.debug |
debug |
Enable verbose console logging. Useful for debugging transformation and sync issues. |
Configuration Priority:
Component duoConfig() > Global config/duo.php > Hardcoded defaults
Example with all options:
protected function duoConfig(): DuoConfig
{
return DuoConfig::make(
syncInterval: 3000, // Sync every 3 seconds
timestampRefreshInterval: 5000, // Refresh timestamps every 5 seconds
maxRetryAttempts: 5, // Retry failed syncs 5 times
debug: true // Enable debug logging
);
}
Benefits of Type-Safe Config:
Why Component-Level Config?
Component-level configuration allows you to:
Global vs Component Config:
config/duo.php): Sets application-wide defaults, configured via environment variablesduoConfig()): Overrides for specific components, set in codeThis architecture provides flexibility while maintaining sensible defaults across your entire application.
The Duo Vite plugin has sensible defaults and requires no configuration. Simply add duo() to your Vite plugins.
All options are optional:
| Option | Type | Default | Description |
|---|---|---|---|
manifestPath |
string | 'resources/js/duo/manifest.json' |
Path where the manifest file is generated |
watch |
boolean | true |
Watch files for changes during development |
autoGenerate |
boolean | true |
Automatically run duo:generate on build and file changes |
patterns |
string[] | ['app/Models/**/*.php'] |
Glob patterns to watch for changes. Add Volt/Livewire paths if you want manifest regeneration on component changes |
entry |
string | 'resources/js/app.js' |
Entry file where Duo initialization code is injected |
autoInject |
boolean | true |
Automatically inject Duo initialization code into entry file |
command |
string | 'php artisan duo:generate' |
Custom artisan command to run for manifest generation |
basePath |
string | process.cwd() |
Base path for resolving file paths |
Example with custom options:
duo({
patterns: [
'app/Models/**/*.php', // Watch models
'resources/views/livewire/**/*.php', // Watch Volt components
'app/Livewire/**/*.php', // Watch class-based components
],
watch: true, // Regenerate on file changes (dev only)
autoGenerate: true, // Auto-run duo:generate
})
Disabling auto-injection (manual initialization):
If you want full control over Duo initialization:
// vite.config.js
duo({
autoInject: false,
})
Then manually initialize in your JavaScript:
// resources/js/app.js
import { initializeDuo } from '@joshcirre/vite-plugin-duo/client';
import manifest from 'virtual:duo-manifest';
await initializeDuo({
manifest,
debug: import.meta.env.DEV,
syncInterval: 3000,
maxRetries: 5,
});
import { getDuo } from '@joshcirre/duo/client';
const duo = getDuo();
const db = duo.getDatabase();
// Get a store
const postsStore = db.getStore('App_Models_Post');
// Query data
const allPosts = await postsStore.toArray();
const post = await postsStore.get(1);
// Add/update
await postsStore.put({
id: 1,
title: 'Hello World',
content: 'This is a post',
});
// Delete
await postsStore.delete(1);
const duo = getDuo();
const syncQueue = duo.getSyncQueue();
// Check sync status
const status = syncQueue.getSyncStatus();
console.log('Online:', status.isOnline);
console.log('Pending:', status.pendingCount);
console.log('Processing:', status.isProcessing);
// Check if online
const isOnline = syncQueue.isNetworkOnline();
// Get pending operations
const pending = syncQueue.getPendingOperations();
// Force sync now
await syncQueue.processQueue();
Duo dispatches a duo-synced event whenever a sync operation completes successfully. You can listen for this event to trigger custom behavior:
Livewire Components:
use Livewire\Attributes\On;
#[On('duo-synced')]
public function handleSyncComplete()
{
// Refresh data, show notification, etc.
$this->dispatch('notify', message: 'Changes synced!');
}
Alpine Components:
// Using Alpine's @event directive
<div @duo-synced.window="handleSync($event.detail)">
<!-- Your component -->
</div>
// Or in Alpine x-data
x-data="{
init() {
window.addEventListener('duo-synced', (event) => {
console.log('Sync completed:', event.detail.operation);
// event.detail.operation contains: id, storeName, operation, data, timestamp
});
}
}"
Vanilla JavaScript:
window.addEventListener('duo-synced', (event) => {
const { operation } = event.detail;
console.log('Synced:', operation.storeName, operation.operation);
});
This is particularly useful for:
// In Alpine component
x-data="{
duoStatus: { isOnline: true, pendingCount: 0, isProcessing: false },
init() {
setInterval(() => {
if (window.duo && window.duo.getSyncQueue()) {
this.duoStatus = window.duo.getSyncQueue().getSyncStatus();
}
}, 1000);
}
}"
const duo = getDuo();
await duo.clearCache();
You can build your own sync indicator using the sync status API:
<div x-data="{
status: { isOnline: true, pendingCount: 0 },
init() {
setInterval(() => {
if (window.duo?.getSyncQueue()) {
this.status = window.duo.getSyncQueue().getSyncStatus();
}
}, 1000);
}
}">
<span x-show="!status.isOnline" class="text-orange-600">
Offline
</span>
<span x-show="status.isOnline && status.pendingCount > 0" class="text-blue-600">
Syncing <span x-text="status.pendingCount"></span> changes
</span>
<span x-show="status.isOnline && status.pendingCount === 0" class="text-green-600">
Synced
</span>
</div>
php artisan duo:discover
Lists all Eloquent models using the Syncable trait. Useful for verifying which models will be included in the manifest.
php artisan duo:generate
Generates the manifest.json file with IndexedDB schema from your models. The Vite plugin runs this automatically, but you can run it manually:
# Generate with custom path
php artisan duo:generate --path=resources/js/duo
# Force regeneration
php artisan duo:generate --force
Note: The Vite plugin with watch: true automatically regenerates the manifest when model files change, so you rarely need to run this manually.
If your Livewire component isn't being transformed to Alpine:
Check the trait is present:
use JoshCirre\Duo\WithDuo;
class MyComponent extends Component {
use WithDuo; // Make sure this is here
}
Clear caches:
php artisan optimize:clear
composer dump-autoload
Check Laravel logs:
tail -f storage/logs/laravel.log | grep Duo
If you see this error in the console:
Check Duo is initialized:
vite.config.jsRegenerate the manifest:
php artisan duo:generate
npm run build
Check Vite is running:
npm run dev
If changes aren't syncing to the server:
console.log(window.duo.getSyncQueue().getSyncStatus());
/duo/syncIf changes disappear after refresh:
Syncable traitawait window.duo.clearCache();
location.reload();
Need to clear IndexedDB during development? You have several options:
Option 1: Debug Panel (Easiest)
<x-duo::debug position="bottom-right" />
Click "Delete Database & Reload" to clear IndexedDB and reload the page.
Option 2: Browser Console
// Delete Duo database
await window.duo?.getDatabase()?.delete();
location.reload();
// Or delete ALL databases (nuclear option)
const dbs = await indexedDB.databases();
for (const db of dbs) {
indexedDB.deleteDatabase(db.name);
}
location.reload();
Option 3: Browser DevTools
When to clear IndexedDB:
No! Just add the WithDuo trait. Your existing Blade templates and Livewire methods work as-is. Duo automatically transforms them to use IndexedDB and Alpine.js.
Yes! Duo works seamlessly with both class-based Livewire components and Volt single-file components.
Components without the WithDuo trait will continue to work as normal server-side Livewire components. Components with the trait require JavaScript for the IndexedDB functionality.
Yes! Duo generates Alpine.js-compatible code, so you can mix Duo-transformed components with regular Alpine components.
No. Duo enhances Livewire by adding local-first capabilities. The server is still the source of truth. Duo just caches data locally and provides offline support.
Partially. Flux components work great for forms, buttons, and static UI elements. However, Flux components inside @forelse loops won't transform correctly since they're server-side components. Use plain HTML with Alpine bindings for loop items.
Duo uses a "server wins" strategy. When sync operations complete, the server response updates the local cache. This ensures the server remains the source of truth.
Currently, the transformation is automatic. Custom transformation logic is planned for a future release.
Duo works in all modern browsers that support IndexedDB:
MIT License. See LICENSE.md for details.
Created by Josh Cirre
Built with:
Contributions are welcome! Please feel free to submit a Pull Request.
See ROADMAP.md for planned features including: