What is LiveDomJS?
LiveDomJS is a lightweight JavaScript engine that lets Blade templates call existing Laravel controllers directly from HTML attributes. There is no separate API layer to design, no JavaScript state management to write, and no new routes to register — every interaction is described declaratively on the element that triggers it.
Under the hood, every attribute you add is handled by one of four engines that ship inside
livedom.js: the AJAX/event engine (click, change, submit, hover,
keyup, polling), the Live Compute engine (client-side spreadsheet-style formulas), the
real-time engine (WebSocket broadcasting through Laravel Reverb), and the
SPA engine (region-based navigation with history support).
live-click="saveUser" sends a request to
/ajax/{live-scope}/saveUser, which Laravel's
AjaxController dispatches straight to
App\Http\Controllers\{live-scope}@saveUser. The controller
never knows it was called this way.
LiveDomJS still bundles jQuery internally today (see roadmap), but you never write jQuery yourself — every selector, event binding, and DOM patch is generated from the attributes you declare.
Installation
Requirements: Laravel 9, 10, 11, or 12 · PHP 8.0+
# that's the whole install
composer require gadingrengga/livedomjs
Nothing else is required. The moment the package is required by Composer:
- The route
/ajax/{controller}/{action}is registered automatically byLiveDomServiceProvider. - The CSRF meta tag is injected automatically whenever real-time assets are active — you don't need to add it by hand.
- Assets are served directly from the package via a fallback route — no
vendor:publishneeded to get started.
Optional: publishing & customizing
If you want to change the route prefix, disable auto-injection, or edit livedom.js directly in your project:
php artisan livedomjs:install --publish
This publishes config/livedomjs.php and the JS assets to public/vendor/livedomjs. Once published, your local copy automatically takes priority over the package's built-in fallback.
⚠ Laravel 11 & 12 — one extra step for real-time only
New Laravel 11/12 projects ship a "slim skeleton" without config/broadcasting.php or
routes/channels.php. AJAX, live-compute, and SPA work with zero setup on any version —
only live-realtime needs broadcasting configured first:
php artisan install:broadcasting
On Laravel 9/10, config/broadcasting.php already exists, so you only need:
composer require laravel/reverb php artisan reverb:install
Once REVERB_APP_KEY is set in .env, LiveDomJS
detects it automatically and starts injecting the realtime script stack. Until then, live-realtime is
silently inactive — no errors, no "Echo is not defined". Keep a broadcast server and queue worker running:
php artisan reverb:start and php artisan queue:work.
Your first reactive element
<div live-scope="UserController"> <input name="username" placeholder="Enter username..."> <button live-click="checkAvailability" live-target="#status">Check</button> <div id="status"></div> </div>
// app/Http/Controllers/UserController.php — no new routes or files needed public function checkAvailability(Request $request) { $exists = User::where('username', $request->username)->exists(); return $exists ? '<span class="text-red-500">Username taken</span>' : '<span class="text-green-500">Username available</span>'; }
AjaxController wraps whatever you return into the
{ success, message, data } envelope automatically — your method just
returns the raw payload (a string, an array, or a Blade View, which gets
->render()'d to a string for you). Don't wrap it yourself
in response()->json([...]) — AjaxController
only unwraps View instances specially, so a hand-built
JsonResponse ends up nested as-is under data
instead of the string/array you meant to send. On failure, throw or let the exception propagate —
LiveDomJS shows an error modal (dev) or toast (production) for you automatically.
How a request actually flows
User clicks [live-click="saveUser"]
│
▼
POST /ajax/UserController/saveUser ← route registered by LiveDomServiceProvider
│
▼
AjaxController::handle() ← resolves "UserController" → App\Http\Controllers\UserController
│
▼
UserController@saveUser($request) ← your existing controller, untouched
│
▼
{ success: true, data: "<html or json>" }
│
▼
Rendered into [live-target="#result"] ← or passed to a callback / autoBindDomFromResponse()
Controller names may include / or . to reach
nested namespaces — live-scope="Invoice/ItemController" resolves to
App\Http\Controllers\Invoice\ItemController. Both controller and action names are
validated with a strict whitelist regex before resolution, so arbitrary class names can never be dispatched.
live-scope — the request boundary
Every element with a live-* trigger needs a live-scope ancestor. It does two
jobs at once: it tells LiveDomJS which controller to call, and it defines which inputs on the page are allowed to travel
with the request. Only <input>/<select>/<textarea> elements
inside the nearest live-scope ancestor are serialized — nothing outside that boundary leaks in.
This is what makes repeating rows (tables, line items) safe by default.
<!-- Row 1 — only its own inputs are sent --> <tr live-scope="Order/ItemController"> <td><input name="qty"></td> <td><button live-click="update">Update</button></td> </tr> <!-- Row 2 — completely isolated, even though it's the same controller --> <tr live-scope="Order/ItemController"> <td><input name="qty"></td> <td><button live-click="update">Update</button></td> </tr>
live-dom — how the response lands
By default a successful response replaces the target's innerHTML (or its
value, if the target is a form field). live-dom lets you
pick a different DOM action explicitly:
| Value | Effect |
|---|---|
| auto | Default. value for form fields, innerHTML otherwise. |
| html | Replace innerHTML. |
| text | Replace textContent (no HTML parsing). |
| value / val | Set an input's value and dispatch change/input. |
| append / prepend | Insert HTML at the start/end of the target. |
| before / after | Insert HTML as a sibling of the target. |
| show / hide / toggle | Visibility only — ignores the response body (toggle uses response as truthy flag). |
| remove | Removes the target element(s) entirely. |
You can combine several actions with several targets in one call: live-target="#a, #b" paired with
live-dom="append, hide" applies each action to its matching target by index.
Trigger attributes
Each of these binds a DOM event to one or more controller methods. All of them are delegated at the document level, so they work on content that's injected later (AJAX responses, SPA navigation) without any re-initialization step on your part.
| Attribute | Fires on | Notes |
|---|---|---|
live-click | click | Most common trigger. |
live-change | change | Selects, checkboxes, blur-commit fields. |
live-input | input (debounced) | 400ms debounce per controller+method, shared with live-keyup. |
live-keyup | keyup (debounced) | Good for search-as-you-type. |
live-submit | form submit | preventDefault() is automatic. |
live-hover | mouseenter | Delegated hover (mouseover/mouseout + relatedTarget check), so it works on dynamic content too. |
live-poll | interval, in ms | Repeats a GET call to whatever method is set on live-click on the same element. |
<button live-click="store">Create</button> <input live-change="search"> <form live-submit="processForm"> <div live-hover="preview"></div> </form> <div id="ticker" live-poll="5000" live-click="refresh"></div>
live-method overrides the HTTP verb used for the request (defaults to POST,
or the enclosing <form method> for submits): live-method="GET".
Sending data & calling methods with arguments
Default: everything in scope
With no extra configuration, LiveDomJS collects every named input inside the nearest
live-scope and sends it as FormData (files, checkboxes,
and radios are handled the same way a native form submit would).
Multiple methods, one element
Comma-separate method calls inside a single live-click — each can carry its own arguments and,
paired with a comma-separated live-target, its own target:
<button live-click="logAction('viewed'), refreshPanel" live-target="#log, #panel" >View</button>
Passing explicit arguments
Call the method like a JS function directly in the attribute. Arguments are evaluated safely (not
eval'd against global scope) and sent as data
in the request body — single argument, array of arguments, selector string, or a plain object literal are all supported:
<!-- single scalar argument --> <button live-click="deleteItem(42)">Delete</button> <!-- object literal --> <button live-click="updateStatus({id: 42, status: 'done'})">Complete</button> <!-- 'this' refers to the triggering element --> <button live-click="remove(this.dataset.id)" data-id="42">Remove</button> <!-- a #id/.class argument scopes data collection to THAT element instead --> <button live-click="updateDimension('#tr-1')">Update row</button>
Your controller receives whichever shape you sent under $request->input('data') when
args are used, or the raw named fields when the scope is serialized normally.
live-data — static payload override
When you need to send a fixed value regardless of scope contents, live-data takes priority over
both scope serialization and inline arguments:
<button live-click="archive" live-data="archived">Archive</button>
Targeting the DOM
live-target accepts a plain CSS selector (#result,
.row), or one of these relative traversal helpers — the DOM equivalent of
jQuery's .closest()/.find()/.parent() family, resolved from the triggering element:
| Syntax | Resolves to |
|---|---|
| self | the triggering element itself |
| closest(selector) | nearest matching ancestor |
| find(selector) | matching descendants |
| parent | direct parent element |
| children(selector?) | direct children, optionally filtered |
| next(selector?) | next sibling |
| prev(selector?) | previous sibling |
| siblings(selector?) | all siblings, optionally filtered |
<tr live-scope="Order/ItemController"> <td><button live-click="remove" live-target="closest(tr)" live-dom="remove" >×</button></td> </tr>
Multiple selectors can be comma-separated and are matched by index against a comma-separated live-dom.
Loading states & callbacks
live-loading / live-loading-indicator
Both accept an element selector to show while the request is in flight, and hide once it settles. Reference counting means two overlapping requests pointed at the same indicator never hide it prematurely.
<!-- show/hide a specific element by selector --> <button live-click="save" live-loading="#save-spinner">Save</button> <!-- live-loading="true" falls back to the global ".loading" overlay --> <button live-click="save" live-loading="true">Save</button> <!-- live-loading-indicator (no value) toggles itself --> <button live-click="save" live-loading-indicator> <span class="spinner"></span> Saving… </button>
A page-wide progress bar (fixed to the top of the viewport) also animates automatically on every SPA navigation, independent of these attributes.
live-callback-before
Runs before the request fires. Return (or resolve, for async/Promise-returning callbacks) false
to cancel — perfect for confirmation dialogs:
<button live-click="deleteUser" live-callback-before="confirmDelete">Delete</button> <script> function confirmDelete(el) { return confirm('Are you sure?'); // false cancels the request entirely } </script>
live-callback-after
Runs once the response has landed, receiving the triggering element and the raw response:
<button live-click="save" live-callback-after="onSaved">Save</button> <script> function onSaved(el, response) { toast('Saved!'); } </script>
On live-submit forms specifically, both callbacks can also be written as inline expressions
(anything containing a () instead of a bare function name.
Live Compute — a spreadsheet inside your HTML
live-compute evaluates a formula entirely in the browser, reading every named input
currently on the page — no server round-trip, no network latency. It re-evaluates on every keystroke across
a convergence loop (up to 10 iterations, batched within a 16ms time budget) so chains of dependent formulas
settle correctly even with 1000+ inputs on a page.
<input name="qty" value="10"> <input name="price" value="25000"> <input name="discount" value="10"> <input live-compute="qty * price * (1 - discount / 100)" live-compute-format="idr" readonly >
Number formats
live-compute-format controls both how existing values are parsed (so
1.234,56 is read correctly as an IDR-formatted number) and how the
computed result is displayed. Built-in formats:
| Key | Example output | Default decimals |
|---|---|---|
| idr | 1.000.000 | 0 |
| usd | 1,000,000.00 | 2 |
| jpy | 1,000,000 | 0 |
| eur | 1.000.000,00 | 2 |
| percent | 12.5% | 1 |
| plain | 1,000,000 | 0 |
| auto | Follows the current global currency — see below. | |
Omit live-compute-format entirely and the element is never formatted — this is what
keeps plain numeric outputs (row indices, counts) safe from being mangled into currency notation. Use
live-decimal-max to override the decimal count for a single element regardless of format default.
Global currency switching
Elements pinned to a literal format (idr, usd…)
never change on their own. Elements that opt in with live-compute-format="auto" instantly
re-render (never re-converting the underlying value) whenever you call:
<script> LiveDom.setCurrency('usd'); // only "auto" elements react LiveDom.registerFormat('gbp', { // register a custom currency/format kind: 'currency', locale: 'en-GB', thousandSep: ',', decimalSep: '.', defaultDecimals: 2 }); LiveDom.unpin(document.querySelector('#price')); // drop a pinned format, fall back to "auto" </script>
Every switch dispatches a livedom:currencychange CustomEvent
on document with { from, to } in
detail, so you can sync a currency picker UI elsewhere on the page.
Aggregate functions
Six spreadsheet-style functions work inside any live-compute expression. Use a
? wildcard in the field name to aggregate across repeated rows — LiveDomJS scans the
page for every input whose name matches the wildcard pattern.
| Function | Example |
|---|---|
| sum | sum(subtotal_?) |
| avg | avg(price_?) |
| min / max | max(price_?) |
| count | count(qty_?) |
| sumif | sumif(status_?, 'paid', total_?) |
<!-- Aggregate across all rows sharing the "subtotal_?" pattern --> <input live-compute="sum(subtotal_?)" live-compute-format="idr" readonly> <!-- Only sum rows where status_? equals 'paid' --> <input live-compute="sumif(status_?, 'paid', total_?)" readonly>
Fine-tuning evaluation
live-compute-init="false"
Locks an output element out of the initial calculation pass — it stays untouched until the user actually edits a related input, then unlocks permanently for that page view. Useful for outputs that should keep a server-rendered value until the user interacts.
live-compute-skip="true"
Excludes an element from being read as a formula input, even though it still has a name — handy for a field you want visible/serialized but not part of anyone's formula dependency graph.
live-compute-trigger="qty, price"
Restricts which named fields force this specific element to recompute, instead of any input on the page. Useful when a formula is expensive or has side effects you want minimized.
live-decimal-max="2"
Overrides the number of decimals shown, independent of the format's own default —
e.g. show 2 decimals on an otherwise zero-decimal idr field.
Masked inputs (any <input> carrying live-compute-format)
re-format live while typing, without ever losing cursor position — the caret is repositioned by digit count, not character
index, so inserting a thousands separator mid-keystroke never jumps the cursor.
Reactive directives
These re-evaluate whenever an input inside the enclosing live-scope changes (debounced 200ms).
Expressions run against every named input in that scope as variables — checkbox names resolve to their value only when
checked, and bracketed names like discount[1] become discount_1
inside the expression.
| Attribute | Effect |
|---|---|
live-show | Shows/hides the element based on a truthy expression. |
live-class | Appends the expression's string result to class-base (a separate attribute holding the element's permanent classes). |
live-style | Sets the element's style attribute from the expression's string result. |
live-attr | Sets/removes one or more arbitrary attributes: attr:expr, attr2:expr2. |
live-bind | Two-way mirror: any input named X pushes its value live into every element with live-bind="X". |
<div live-show="qty > 0">In stock</div> <div class-base="font-bold" live-class="total > 1000000 ? 'text-green-500' : 'text-red-500'"> Total </div> <div live-style="'opacity:' + (qty > 0 ? 1 : 0.4)">Preview</div> <button live-attr="disabled: qty < 1">Checkout</button> <!-- live-bind: type in one, see it mirrored anywhere --> <input name="username"> <span live-bind="username"></span>
Expressions are evaluated with Function('ctx', 'with(ctx){ return (...) }') against
a scoped object built only from that particular live-scope's inputs — never against
window — so a broken formula fails safely (logs a warning in debug mode, returns
null) instead of throwing.
live-realtime — WebSocket broadcasting in one attribute
Add live-realtime="true" to any trigger attribute and the request no longer just updates
the caller's own DOM — it asks the server to broadcast the update, via Laravel Reverb, to every open page that has a
matching live-scope. Each recipient independently re-fetches and re-renders; nobody polls.
<!-- without live-realtime: a normal AJAX request, only this page updates --> <button live-click="refreshMetrics" live-target="#metrics-panel">Refresh</button> <!-- with live-realtime: broadcasts to every open dashboard --> <button live-click="refreshMetrics" live-realtime="true" live-target="#metrics-panel" >Sync All</button>
What happens under the hood
Client sends X-Live-Reverb: true + realtime: true
│
▼
AjaxController detects the realtime flag → does NOT run the render-and-return path
│
▼
reverbDynamic($controller, $action, $target, ...) dispatches DynamicBroadcastEvent (ShouldBroadcast)
│
▼
Laravel Reverb pushes to the channel over WebSocket
│
▼
dynamic-broadcast.js on every subscribed page matches [live-scope] containing $controller
│
▼
Each page independently re-calls the same controller/action via GET and re-renders its own DOM
Because every recipient re-fetches independently rather than receiving pre-rendered HTML in the broadcast payload, each client always gets output rendered with its own auth/session/locale context.
Broadcasting to specific users (server-side)
Call the reverbDynamic() global helper directly from anywhere in your backend
(a job, an observer, another controller) to push an update proactively, not just in response to a client request:
reverbDynamic( controller: 'Dashboard/MetricsController', function: 'refreshMetrics', target: '#metrics-panel', data: null, typeChannel: 'private', // 'public' | 'private' | 'presence' recipients: [$user->id], eventName: 'html-render', );
| typeChannel | Resulting channel |
|---|---|
| public | public-{recipient} (defaults to realtime-updates if recipients omitted) |
| private | private-user.{recipient} |
| presence | presence-{recipient} |
On the client, every page subscribes automatically to public-realtime-updates and,
if a user is authenticated, to private-user.{id} — this is wired up for you inside
dynamic-broadcast.js, injected automatically once Reverb is detected.
SPA navigation
Wrap any container in live-spa-region="name" to turn it into an SPA outlet. Links and forms
inside it are intercepted automatically — no router configuration, no client-side route table.
<main live-spa-region="main"> <!-- every <a href> and <form> in here becomes SPA navigation --> </main>
What gets intercepted
<a href>— fetched via GET, response parsed, matchinglive-spa-regionelements swapped by name, history pushed.<form>GET — query string built from form fields, fetched, region swapped, history replaced.<form>POST — submitted via AJAX; a JSONredirectkey triggers a follow-up GET automatically; a 422 response renders field errors inline (.is-invalid/.invalid-feedback) instead of navigating.- Browser back/forward (
popstate) — replays the stored URL through the same SPA loader.
Any <script> tag inside a freshly swapped region is re-executed
(external scripts once per src, inline scripts every time, each isolated in an IIFE) —
so page-specific initialization code keeps working after navigation exactly like a full page load.
Excluding URLs from SPA handling
Set window.liveDomConfig.spaExcludePrefixes before LiveDomJS initializes to let
specific paths fall through to a normal full-page navigation (e.g. a download link or an external auth redirect):
<script> window.liveDomConfig = { spaExcludePrefixes: ['/downloads', '/logout'] }; </script>
Lifecycle events
| Event | Fires |
|---|---|
| live-dom:init | Once, after LiveDomJS finishes its own initial bootstrap. |
| live-dom:afterUpdate | After any AJAX-driven DOM update (rebinds all delegated handlers, safe to listen to for custom widgets). |
| live-dom:afterSpa | After an SPA region swap, with detail.url. |
<script> document.addEventListener('live-dom:afterSpa', (e) => { console.log('Navigated to', e.detail.url); }); </script>
Errors & debugging
LiveDomJS reads Laravel's own app.debug flag (surfaced to the browser as
a <meta name="app-debug"> tag) to decide how failures are shown, and
keeps an in-memory history of every error it has displayed during the session.
A detailed modal appears with the exception message, file, line, and trace — parsed from either a Laravel Ignition HTML page or a JSON exception payload. It includes quick "copy" and "copy prompt for AI" actions for pasting into an assistant.
A clean, dismissible toast shows a generic message — no stack trace, file paths, or internal detail ever reaches production users.
Laravel validation errors (HTTP 422 with a JSON errors object) are handled
specially on SPA form submits: each field gets an .is-invalid class and an inline
.invalid-feedback message next to it, instead of triggering the generic error
modal.
Every AJAX request also auto-cancels its predecessor sharing the same target — clicking "search" three times fast only ever renders the last response, never a stale one that happened to resolve out of order.
Configuration reference
Publish config/livedomjs.php with php artisan livedomjs:install --publish to edit any of these:
| Key | Default | Purpose |
|---|---|---|
| route_prefix | ajax | The dynamic endpoint becomes /{prefix}/{controller}/{action}. |
| route_middleware | ['web'] | Middleware stack applied to the dynamic AJAX route. |
| auto_inject | true | Auto-injects livedom.js (100% vanilla JS — no jQuery required) before </body> on every HTML response. |
| auto_inject_realtime | true | When true and a Reverb key is configured, also injects the CSRF meta tag, window.userGlobal, Echo, and dynamic-broadcast.js. |
| jquery_cdn | null | Not needed by LiveDomJS itself. Set a CDN URL only if some other custom script in your project still depends on a global jQuery. |
| pusher_cdn | pusher-js 8.4.0 CDN | Reverb speaks the Pusher protocol, so the client library is pusher-js. |
| echo_cdn | laravel-echo 1.16.1 CDN | Set either CDN key to null if you bundle your own via Vite. |
| serve_assets | true | Serves livedom.js/dynamic-broadcast.js from the package directly, no vendor:publish required. Published files in public/ always take priority. |
JavaScript API
These are exposed globally for cases the declarative attributes don't cover.
| API | Purpose |
|---|---|
| LiveDom.setCurrency(code) | Switches the global "auto" number format instantly, no re-conversion of values. |
| LiveDom.registerFormat(key, cfg) | Registers a new named format/currency (or overrides a built-in one). |
| LiveDom.unpin(el) | Removes a literal live-compute-format from an element so it follows the global "auto" format. |
| LiveDom.config.currency | Read-only view of the currently active global currency key. |
| runAjaxRequest(...) | Same request pipeline the attribute engine uses internally — call it directly from your own JS for advanced flows. |
| debouncedAjaxDynamic(...) | Debounced wrapper around the core AJAX call, keyed per controller+method. |
| autoBindDomFromResponse(data) | Given a plain object response, writes each key into any matching #id/.class on the page (camelCase, kebab-case, and snake_case are all matched). |
PHP API
Controller name resolution
live-scope values map to App\Http\Controllers\* by splitting on
/ or . and ucfirst-ing each segment:
live-scope="Invoice/ItemController" → App\Http\Controllers\Invoice\ItemController live-scope="UserController" → App\Http\Controllers\UserController
Both segments are validated with a whitelist regex (/^[a-zA-Z0-9\/\.]+$/ for the
controller, /^[a-zA-Z0-9]+$/ for the action) before the class is even referenced.
A missing class or method returns a clean 404 JSON response rather than a fatal error.
reverbDynamic()
function reverbDynamic( string $controller, string $function, string $target, $data = null, string $typeChannel = 'public', array $recipients = [], string $eventName = 'html-render', ): void
Globally available (autoloaded via composer.json's files section) —
call it from anywhere in your app, not only from within an live-realtime request cycle.
Blade component
If you ever set auto_inject to false
for manual control, drop this into your layout instead:
<x-livedomjs::livedom-scripts />
Full attribute reference
Every live-* attribute LiveDomJS understands, in one table.
Structure & scope
live-scope | Controller path + request/data boundary. |
live-target | Where the response is applied — selector or traversal DSL. |
live-dom | How the response is applied (html, append, value, remove, …). |
live-data | Fixed payload, overrides scope serialization. |
live-method | Override HTTP verb (GET/POST/PUT/DELETE…). |
Interaction triggers
live-click | Call method(s) on click. |
live-change | Call method(s) on change. |
live-input | Call method(s) on input, debounced. |
live-keyup | Call method(s) on keyup, debounced. |
live-submit | Call method(s) on form submit (auto-prevents default). |
live-hover | Call method(s) on delegated mouseenter. |
live-poll | Repeat the element's live-click method every N ms. |
Feedback & lifecycle
live-loading | Selector (or "true") to show/hide while the request is in flight. |
live-loading-indicator | Same as above, defaults to the triggering element itself. |
live-callback-before | Runs before the request; returning/resolving false cancels it. |
live-callback-after | Runs after the response lands, receives (element, response). |
live-realtime | Broadcast this action to every matching open page via Reverb. |
Live Compute
live-compute | Client-side formula, re-evaluated live. |
live-compute-format | Number format for parsing & display (idr, usd, jpy, eur, percent, plain, auto, or custom). |
live-decimal-max | Overrides decimal places for this element only. |
live-compute-init | "false" skips the initial calculation pass until the user edits something. |
live-compute-skip | "true" excludes this field from being read as a formula input. |
live-compute-trigger | Comma-separated field names that force this element to recompute. |
Reactive directives
live-show | Toggle visibility from an expression. |
live-class | Append a dynamic class from an expression. |
live-style | Set inline style from an expression. |
live-attr | Set/remove one or more attributes from expressions. |
live-bind | Mirror an input's value into any element bound to its name. |
SPA
live-spa-region | Named SPA outlet — links/forms inside it navigate without a full reload. |
Recipes
Invoice with auto-calculation
<div live-scope="Invoice/ItemController"> <table> <tr> <td><input name="qty" value="1"></td> <td><input name="price" value="150000"></td> <td><input name="discount" value="0"></td> <td><input live-compute="qty * price * (1 - discount / 100)" live-compute-format="idr" readonly></td> <td><button live-click="removeItem" live-target="closest(tr)" live-dom="remove">×</button></td> </tr> </table> <div>Total: <input live-compute="sum(subtotal_?)" live-compute-format="idr" readonly> </div> <button live-click="submit" live-target="#response">Submit Invoice</button> <div id="response"></div> </div>
Debounced search-as-you-type
<div live-scope="Catalog/ProductController"> <input name="q" live-input="search" live-target="#results" placeholder="Search products…"> <div id="results"></div> </div>
Real-time collaborative dashboard
<div live-scope="Dashboard/MetricsController"> <div id="metrics-panel">{{-- server-rendered on load --}}</div> <button live-click="refreshMetrics" live-realtime="true" live-target="#metrics-panel"> Sync All </button> </div>
SPA app shell
<body> <nav> <a href="/dashboard">Dashboard</a> <a href="/reports">Reports</a> </nav> <main live-spa-region="main"> @yield('content') </main> </body>
As long as every page you navigate to also renders a live-spa-region="main" wrapper with the same name, back/forward and direct links all keep working — no client-side route table to maintain.
Troubleshooting
"Element with live-click needs a live-scope attribute on an ancestor"
Every trigger attribute that calls a controller method needs a live-scope somewhere above it in the DOM. If the element only performs a local DOM action (no method name), you can omit live-scope entirely — pass an empty live-click to trigger live-dom/live-target without a server call.
My aggregate formula always returns 0
Aggregate functions take exactly one field pattern — sum(a, b) is read as a single (nonexistent) field named literally "a, b", not two fields added together. Use the wildcard pattern instead: sum(price_?) against fields named price_1, price_2, etc. In debug mode, a console warning flags this exact mistake.
live-realtime does nothing
Real-time is silently inactive until Laravel Reverb (or another broadcasting driver populating config('broadcasting.connections.reverb.key')) is configured — this is intentional, so projects that don't need it never see an "Echo is not defined" error. Run php artisan install:broadcasting (Laravel 11/12) and keep reverb:start + queue:work running.
A field outside my table row leaked into the request
Check that each repeating unit (row, card) has its own live-scope. Only inputs inside the nearest live-scope ancestor of the trigger are serialized — a shared outer scope will pull in every row's inputs at once.
A currency-formatted input shows the wrong number after typing
Make sure live-compute-format matches how the value was already written into the page (server-rendered or otherwise) — the parser trusts the format you declare to know which character is the decimal separator versus the thousands separator.
How it compares
| LiveDomJS | Livewire | HTMX | |
|---|---|---|---|
| New files per feature | 0 | 2+ (PHP class + Blade) | 0 |
| Route registration required | No | No | Yes |
| Client-side calculations | Built-in | Server round-trip | Manual JS |
| Real-time (WebSocket) | One attribute | Complex setup | Extension required |
| Laravel-native | Yes | Yes | No |
| Build step required | No | No | No |
| jQuery dependency | No | No | No |
Best fit: data-heavy Laravel apps (ERP, CRM, admin panels, internal tools) that want reactive UI without adopting a full component framework.
Changelog & roadmap
Recent since the last docs pass
- Global currency switching —
LiveDom.setCurrency(),registerFormat(),unpin(), and the"auto"format. - Cursor-safe live input masking for currency-formatted fields while typing.
live-decimal-max,live-compute-skip, andlive-compute-triggerfor fine-grained control over the compute engine.- Unified, abortable SPA request pipeline — GET navigation, form GET, and form POST now share one controller, so a stale in-flight navigation can never render over a newer one.
- Redesigned dev-mode error modal with copy/AI-prompt actions and an in-session error history.
- Reference-counted
live-loading/live-loading-indicator, safe for overlapping requests. - Laravel 11/12 "slim skeleton" support — real-time detection no longer assumes
config/broadcasting.phpexists.
Shipped foundation
- ✅ Attribute-driven AJAX interactions (click, change, input, keyup, submit, hover, poll)
- ✅ Scope-based data isolation (
live-scope) - ✅ Live compute with aggregate functions (sum, avg, min, max, count, sumif)
- ✅ Real-time broadcasting via Laravel Reverb
- ✅ SPA navigation with pushState/popstate support
- ✅ Reactive directives (show, class, style, attr, bind)
- ✅ Remove the jQuery dependency
On the roadmap
- ⬜ DevTools browser extension
- ⬜ VS Code extension for attribute autocomplete
- ⬜ Official testing utilities
Contributing
Bug reports and feature suggestions are welcome. Before submitting changes, be ready to explain what you're changing, what could break, and how it affects the framework's long-term direction.
git clone https://github.com/GadingRengga/LiveDomJs cd LiveDomJs git checkout -b feature/your-feature-name
Author
Gading Rengga
Licensed under the MIT License. Free for personal and commercial use.