joshcirre/duo
Duo brings local-first IndexedDB syncing to Laravel and Livewire apps. Add one trait and your components become offline-capable Alpine.js, with automatic model caching, optimistic updates, schema extraction, and a sync queue that resumes when you're back online.
Duo is a local-first layer for Livewire: Eloquent models get mirrored into IndexedDB in the browser, and Livewire components marked with WithDuo are rewritten to Alpine so reads and writes hit IndexedDB first and sync to the server in the background.
The minimum viable setup is five steps:
composer require joshcirre/duo
npm install -D @joshcirre/vite-plugin-duo
JoshCirre\Duo\Syncable to the model you want cached. Duo reads $fillable/$guarded plus the DB schema to build the IndexedDB manifest.@duoMeta in <head>. Without it you get no CSRF meta tag and no offline page caching; sync requests will fail.duo() to vite.config.js. It generates resources/js/duo/manifest.json, watches app/Models/**/*.php, injects init code into resources/js/app.js, and copies public/duo-sw.js.JoshCirre\Duo\WithDuo to the Livewire/Volt component.<x-duo::sync-status position="top-right" /> and, in local, <x-duo::debug position="bottom-right" />.Where to look first, in this order:
resources/js/duo/manifest.json — this is the actual contract between your DB and the browser. Read it before debugging anything else. If a column is missing here, it does not exist client-side.config/duo.php (publish with --tag=duo-config) — sync status delays, global defaults.First use case: pick one small, single-user-owned list screen — a todo list, a shopping list, a personal notes index. One model, one component, flat markup, create/toggle/delete only. Do not start with your billing table or a component that embeds three other Blade components. You want to learn how the transformation behaves on markup you can hold in your head before you point it at anything real.
Keep the server path canonical. The most important pattern: write component methods exactly as you would in normal Livewire — Todo::create(...), Todo::findOrFail($id)->update(...), Todo::latest()->get() in with()/render(). Duo's debug toggle flips between local-first and stock Livewire, and that only works if your PHP is untouched, idiomatic Eloquent. Treat the trait as an additive layer, not a rewrite. This also gives you a one-line kill switch in production if things go sideways.
Scope writes through a user() relationship, never through $fillable. For user-owned models, add the belongsTo(User::class) relationship and deliberately keep user_id out of $fillable. Duo detects user() and stamps the authenticated user during sync. If you skip the relationship, you get unscoped rows; if you add user_id to $fillable, you've reintroduced mass-assignment risk in a system where the client now controls the payload.
One component, one resource. Because the trait mechanically converts wire:click → @click, @forelse → x-for, and {{ $todo->title }} → x-text, the conversion quality degrades fast with complexity. A component that lists todos and shows a user profile and has a modal editor is three transformations stacked on one root element. Split them. Small components convert cleanly; large ones produce markup you can't reason about.
Design for optimistic UI, not for confirmation dialogs. Duo writes locally and syncs in the background, so the UI updates before the server agrees. Build the component assuming the write already succeeded: no wire:confirm round trips, no "saving..." spinners in the happy path, no reading back the server-generated ID for UI keys. Prefer stable client-side keys for x-for.
Use the sync-status component deliberately. The show-delay prop (default 1000ms) exists to stop the indicator flashing on fast syncs — keep it. show-success is off by default and usually should stay off for CRUD-heavy screens; a badge that says "All changes synced" every three seconds is noise. Turn it on only for long-form editing where the user genuinely needs reassurance. Use :inline="true" when you're placing it in a toolbar rather than the viewport corner.
Tune the Vite plugin's patterns, not its defaults. The default watch glob is app/Models/**/*.php, which misses Volt components and class-based Livewire components. Add both:
duo({
patterns: [
'app/Models/**/*.php',
'resources/views/livewire/**/*.php',
'app/Livewire/**/*.php',
],
})
Treat the manifest as a build artifact with a CI step. Since duo:generate is what actually produces the schema contract, run it explicitly in CI before npm run build rather than relying on the watcher or on someone's local npm run dev having been running. A stale manifest produces the worst class of bug: fields silently missing in the browser while the server is perfectly fine.
Stage the rollout. Ship one screen with WithDuo behind a feature flag, keep the debug toggle available in staging, and only widen coverage after you've watched a full offline→online cycle on a real device with real network flapping. This package has 36 stars and zero dependents; you are the QA function.
Publish views when you need brand consistency. --tag=duo-views gives you the sync-status and debug components in resources/views/vendor/duo/components/; --tag=duo-assets exposes the JS if you need to fork sync behavior. Publishing the config alone (--tag=duo-config) is the common case.
The component transformation is heuristic, and that is the whole risk surface. "Automatically transforms your Livewire components to Alpine.js" is a code-rewriting pass over Blade. Expect it to handle the documented shapes — flat @forelse, simple wire:click="method($id)", property interpolation, conditional classes — and to mangle anything else: nested loops, @if inside @foreach with varying roots, wire:model.live.debounce, computed/dynamic property access, Blade components inside the loop, @php blocks, multiple root elements. Read the generated DOM in devtools before you trust a screen. When it breaks, simplify the markup rather than fighting the transform.
Your model schema is now public. The manifest ships column names, types, nullability, and defaults to every browser. Never put Syncable on a model with salary, SSN, API token, or internal-flag columns — even if the UI never renders them, the field definitions are client-side and the whole row is cacheable in IndexedDB. Create a slimmed-down DTO-ish model or a dedicated read model if you need to sync only part of a table.
IndexedDB schema changes are real migrations. Adding a column bumps the local schema version and existing browsers hold old data in old object stores. The documented escape hatch is the debug panel's "Clear Database," which is fine for you and useless for users. Plan for this: version your sync payloads, expect a period where old clients send old shapes, and assume you cannot retroactively clean up client data. Get the model right before you ship, because changing it later is expensive.
Service worker staleness will burn you in development. duo-sw.js is copied to public/ at build time and caches pages marked with duo-cache. In dev you'll routinely see yesterday's markup after a Blade edit. Hard-reload, unregister the SW, or clear site data — and make sure optimize:clear runs after config changes, as the README notes.
Session expiry breaks the sync queue silently. The queue resumes when connectivity returns, but if the user's session lapsed while offline, those queued requests come back 419/401. There is no documented re-auth choreography here, so build it: intercept failed syncs, surface a re-authenticate prompt, and preserve the local queue rather than dropping it. Test this explicitly by going offline, waiting out the session lifetime, then reconnecting.
ID reconciliation and idempotency. Optimistic creates get a local identity before the server assigns one. If your sync layer ever retries after a partial success, you can get duplicates on models without a natural unique key. Be conservative about unique constraints on synced models — enforcing uniqueness offline is not something this package promises, and the last-write-wins outcome across two tabs is undefined.
Don't build analytics or pagination on top of this. Todo::latest()->get() in with() becomes an IndexedDB read of whatever was cached. Aggregates, count(), where() clauses beyond what's cached, and paginated lists of large tables will not behave like Eloquent. Use Duo for interactive working sets, and route reporting through a normal server-rendered Livewire component without the trait.
Validation and flash messages won't survive the transform. Standard Livewire $this->validate() error bags and session()->flash() are server round trips; in local-first mode there's no immediate server to produce them. Do client-side validation in Alpine for anything the user hits while offline, and treat server-side validation as a background reconciliation concern rather than an inline UX mechanism.
Debugging order that actually works: (1) is the column in manifest.json? (2) does it exist in the IndexedDB object store with the right type? (3) does the component's rendered DOM show x-text/@click where you expected? If the answer to (3) is no, the transform bailed and you should simplify the Blade until it converts. Regenerate the manifest with php artisan duo:generate after any model change and re-check step (1).
Pin the version and read the changelog. The README opens with "VERY MUCH A WIP," the package has no dependents, and the documented API (props, directives, config keys) is still moving. Constrain it in composer.json and package.json, and treat every minor bump as a potentially breaking change to your generated markup.
How can I help you explore Laravel packages today?