Duo
A offline-first plug and play package to convert your Livewire components on runtime.
Install / Use
/learn @joshcirre/DuoREADME
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 build
2. 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:@dev
This 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-duo
Now 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 dev
This 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 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
6. 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 unlink
Development 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/duo
NPM Package (Vite Plugin)
npm install -D @joshcirre/vite-plugin-duo
Note: Dexie is automatically installed as a dependency.
Publishing Assets (Optional)
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.
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 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.
2. Add @duoMeta Directive to Your Layout
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 access
3. 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(), // That's it! Uses sensible defaults
],
});
That's all you need! The plugin will automatically:
- ✅ Generate the manifest at
resources/js/duo/manifest.json - ✅ Watch
app/Models/**/*.phpfor changes - ✅ Auto-regenerate manifest when models change
- ✅ Inject Duo initialization code into
resources/js/app.js - ✅ Copy the service worker to
public/duo-sw.json build
Want 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',
})
4. 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"
