Package Exports
- @joshcirre/vite-plugin-duo
- @joshcirre/vite-plugin-duo/client
Readme
Duo (VERY MUCH A WIP)
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.
Features
- π Zero Configuration: Add one trait and Duo automatically transforms your Livewire components to Alpine.js
- πΎ Automatic IndexedDB Caching: Transparently cache Eloquent models in the browser
- ποΈ Schema Extraction: Automatically extracts database column types, nullability, and defaults for IndexedDB
- β‘ Optimistic Updates: Instant UI updates with background server synchronization
- π Offline Support: Automatic offline detection with sync queue that resumes when back online
- π Visual Sync Status: Built-in component showing online/offline/syncing states
- π― Livewire Integration: Seamless integration with Livewire 3+ and Volt components
- π¦ Type-Safe: Full TypeScript support with auto-generated types from database schema
- π Vite Plugin: Automatic manifest generation with file watching
Local Development Setup
Want to contribute or test Duo locally? Follow these steps to set up local development with symlinked packages.
1. Clone and Install Duo
# 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 build2. Symlink Composer Package
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:@devThis creates a symlink in vendor/joshcirre/duo pointing to your local Duo directory. Changes to the PHP code are immediately reflected.
3. Symlink NPM Package
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-duoNow your Laravel app uses the local version of the Vite plugin.
4. Watch for Changes
In the Duo package directory, run the build watcher:
cd ~/Code/duo
npm run devThis watches for TypeScript changes and rebuilds automatically. Changes are immediately available in your linked Laravel app.
5. Test Your Changes
In your Laravel app:
# Run both Vite and Laravel (recommended)
composer run devThis 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 serve6. Unlinking (When Done)
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 unlinkDevelopment Tips
- PHP Changes: Automatically picked up via symlink
- TypeScript Changes: Require
npm run buildornpm run dev(watch mode) - View Changes: Blade components update automatically
- Config Changes: May require
php artisan optimize:clear - Manifest Changes: Run
php artisan duo:generatemanually if needed
Installation
Composer Package
composer require joshcirre/duoNPM Package (Vite Plugin)
npm install -D @joshcirre/vite-plugin-duoNote: Dexie is automatically installed as a dependency.
Quick Start
1. Add the Syncable Trait to Your Models
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 fillableDuo automatically extracts your model's fillable attributes and database schema (column types, nullable, defaults) to generate the IndexedDB manifestβno manual configuration needed!
2. Configure Vite
Add 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({
manifestPath: 'resources/js/duo/manifest.json',
watch: true, // Auto-regenerate manifest on file changes
autoGenerate: true,
patterns: [
'app/Models/**/*.php',
'resources/views/livewire/**/*.php', // Volt components
'app/Livewire/**/*.php', // Class-based components
],
}),
],
});Note: The Duo Vite plugin automatically injects the initialization code into your
app.jsfile. No manual initialization needed!You can customize the auto-injection behavior with these options:
entry- Path to your main JS file (default:'resources/js/app.js')autoInject- Set tofalseto manually initialize Duo
3. Add the WithDuo Trait to Your Livewire Components
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:
- β
Transform
wire:clickto Alpine.js@clickhandlers - β
Convert
@forelseloops to Alpinex-fortemplates - β
Transform
{{ $todo->property }}tox-textbindings - β
Convert conditional classes to
:classbindings - β
Add
x-cloakand loading state management - β Route all data operations through IndexedDB
- β Queue changes for background sync to the server
5. Add the Sync Status Component (Optional)
Display a visual indicator of the sync status:
<x-duo::sync-status position="top-right" />This component shows:
- π Offline: "You're offline - Changes saved locally"
- π΅ Syncing: "Syncing X changes..."
- π’ Synced: "All changes synced"
6. Run Your Application
composer run devThis runs both npm run dev and php artisan serve concurrently. The Vite plugin will automatically:
- Run
php artisan duo:generateto create the manifest - Watch for model and component changes
- Regenerate the manifest when files change
For production:
npm run buildHow It Works
Duo uses a sophisticated local-first architecture that transforms your Livewire components into offline-capable Alpine.js applications:
Initial Load
Component Transformation: When a component with the
WithDuotrait renders, Duo intercepts the HTML and transforms it:- Blade
@forelseloops β Alpinex-fortemplates wire:clickhandlers β Alpine@clickwith IndexedDB operations{{ $model->property }}β<span x-text="model.property"></span>- Conditional classes β
:classbindings - Adds loading states and
x-cloakfor smooth initialization
- Blade
Sync Server Data: On page load, the Alpine component syncs server data to IndexedDB
Ready State: Component shows with
duoReadyflag set to true
Data Operations
Reads:
- All data is read from IndexedDB (instant, no network delay)
- Alpine templates reactively update from the local cache
Writes:
- Changes write to IndexedDB immediately (optimistic update)
- UI updates instantly
- Operation queues for background server sync
- Sync happens automatically in the background
Offline Support
Going Offline:
- Browser's
navigator.onLineAPI detects offline state - Sync queue automatically pauses
- All operations continue to work locally
- Sync status component shows orange "Offline" badge
Coming Back Online:
- Browser detects connection restored
- Sync queue automatically resumes
- All queued operations sync to server
- Network errors don't count against retry limit
- Sync status shows progress, then green "Synced" when complete
Background Sync
- Runs on configurable interval (default: 5 seconds)
- Processes queued operations in order
- Retries failed operations (default: 3 attempts)
- Updates local cache with server responses
- Handles concurrent operations safely
Configuration
Publish the configuration file:
php artisan vendor:publish --tag=duo-configEdit 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),
];Advanced Usage
Manual Database Operations
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);Manual Sync Operations
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();Access Sync Status in Custom Components
// 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);
}
}"Clear Cache
const duo = getDuo();
await duo.clearCache();Custom Sync Component
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>Artisan Commands
Discover Models
php artisan duo:discoverLists all Eloquent models using the Syncable trait. Useful for verifying which models will be included in the manifest.
Generate Manifest
php artisan duo:generateGenerates 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 --forceNote: The Vite plugin with watch: true automatically regenerates the manifest when model files change, so you rarely need to run this manually.
Troubleshooting
Component Not Transforming
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-autoloadCheck Laravel logs:
tail -f storage/logs/laravel.log | grep Duo
"window.duo not available"
If you see this error in the console:
Check Duo is initialized:
- Duo initializes automatically via the Vite plugin
- Check that the Duo plugin is added to your
vite.config.js
Regenerate the manifest:
php artisan duo:generate npm run buildCheck Vite is running:
npm run dev
Data Not Syncing
If changes aren't syncing to the server:
- Check the browser console for sync errors
- Check sync queue status:
console.log(window.duo.getSyncQueue().getSyncStatus());
- Verify routes are registered - Duo registers routes at
/duo/sync - Check network tab in DevTools for failed requests
Changes Not Persisting
If changes disappear after refresh:
- Check IndexedDB in Browser DevTools β Application β IndexedDB
- Verify the model has
Syncabletrait - Check server logs for save errors
- Clear IndexedDB and resync:
await window.duo.clearCache(); location.reload();
FAQ
Do I need to change my Livewire components?
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.
Will this work with Volt components?
Yes! Duo works seamlessly with both class-based Livewire components and Volt single-file components.
What happens if JavaScript is disabled?
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.
Can I use this with existing Alpine.js code?
Yes! Duo generates Alpine.js-compatible code, so you can mix Duo-transformed components with regular Alpine components.
Does this replace Livewire?
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.
Can I use Flux components?
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.
How do I handle conflicts?
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.
Can I customize the transformation?
Currently, the transformation is automatic. Custom transformation logic is planned for a future release.
Requirements
- PHP ^8.2
- Laravel ^11.0 or ^12.0
- Livewire ^3.0
- Alpine.js 3.x (included with Livewire)
- Modern browser with IndexedDB support
Browser Support
Duo works in all modern browsers that support IndexedDB:
- Chrome/Edge 24+
- Firefox 16+
- Safari 10+
- iOS Safari 10+
License
MIT License. See LICENSE.md for details.
Credits
Created by Josh Cirre
Built with:
- Dexie.js - Minimalistic IndexedDB wrapper
- Laravel - PHP framework
- Livewire - Full-stack framework
- Alpine.js - Lightweight JavaScript framework
- Livewire Flux - UI components (optional)
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Roadmap
See ROADMAP.md for planned features including:
- Full page caching for complete offline mode
- Seamless conflict resolution with visual components
- Multiplayer mode ("Duet") with real-time sync
- Permission-based conflict resolution
- Architecture optimizations and Livewire v4 compatibility