Pure Blade + Alpine.js + DaisyUI components for Laravel ERP/CRM interfaces.
No Livewire. No magic. No hidden server roundtrips.
composer require edumicro/daisybladeDaisyBlade is the evolution of edumicro/daisylw4, rebuilt without Livewire. The stack is intentionally boring:
- Blade renders structure
- Alpine.js manages local UI state
- Axios handles explicit server calls
- DaisyUI 5 provides the design system
Every server interaction is a plain Laravel controller returning JSON. No protocol overhead, no wire attributes, no object inspector surprises. If something breaks, you know exactly where to look.
| Dependency | Version |
|---|---|
| PHP | ^8.2 |
| Laravel | ^11.0 | ^12.0 |
| Alpine.js | ^3.0 |
| DaisyUI | ^5.0 |
| blade-heroicons | ^2.4 |
composer require edumicro/daisyblade
php artisan daisyblade:installThe install command will ask whether to publish assets for Vite (recommended) or as a public script tag:
# Vite — assets published to resources/js/daisyblade.js
php artisan daisyblade:install --vite
# Script tag — assets published to public/vendor/daisyblade/
php artisan daisyblade:install --public// vite.config.js
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
export default defineConfig({
plugins: [laravel({ input: ['resources/js/app.js'] })],
})// resources/js/app.js
import './daisyblade.js'
import Alpine from 'alpinejs'
window.Alpine = Alpine
Alpine.start()<script src="/vendor/daisyblade/daisyblade.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>All components use the dbl prefix:
<x-dbl::display.badge label="Active" color="success" />
<x-dbl::form.input name="email" type="email" label="Email" />
<x-dbl::display.data-table :load-url="route('products.data')" :columns="$columns" />No JavaScript. Receive props, render HTML.
| Component | Usage |
|---|---|
display.accordion |
DaisyUI accordion (CSS-driven) |
display.avatar |
User avatar with initials fallback |
display.badge |
Status badges with color variants |
display.card |
Content card with optional shadow |
display.chat-bubble |
Chat message bubble |
display.collapse |
DaisyUI collapse panel (CSS-driven) |
display.compare |
Side-by-side property comparison table |
display.diff |
Code diff viewer (line-by-line +/−/~) |
display.hover-3d-card |
CSS 3D tilt card on hover |
display.hover-gallery |
CSS-animated image gallery |
display.kbd |
Keyboard shortcut display |
display.list |
Styled list component |
display.mask |
DaisyUI mask shapes |
display.radial-progress |
Circular progress indicator |
display.resource-details |
Detail view loaded from URL |
display.stat |
KPI card with value, title, trend |
display.status |
Status dot with label |
display.table |
Static HTML table from array data |
display.timeline |
Vertical event timeline |
display.tree |
Recursive tree from nested array |
feedback.alert |
Alert message with type variants |
feedback.loading |
Loading spinner |
feedback.progress |
Linear progress bar |
feedback.skeleton |
Content skeleton placeholder |
feedback.tooltip |
Tooltip wrapper |
form.checkbox |
Checkbox field with label |
form.input |
Text/email/number/date input |
form.radio |
Radio button group |
form.textarea |
Textarea with label |
form.toggle |
Toggle switch |
form.validator |
Inline validation message display |
layout.divider |
Section divider |
layout.footer |
Page footer |
layout.hero |
Hero section |
layout.indicator |
Badge indicator overlay |
layout.join |
DaisyUI join group |
layout.section-wrapper |
Padded section container |
layout.stack |
DaisyUI stack layout |
navigation.breadcrumb |
Breadcrumb trail |
navigation.dock |
Bottom dock navigation |
navigation.steps |
Step indicator |
actions.button |
Button with variants, loading, icon |
actions.fab |
Floating action button |
actions.swap |
Toggle swap element |
components.icon |
Heroicon wrapper |
UI state managed by Alpine. No Axios.
| Component | Usage |
|---|---|
actions.modal |
Modal with Alpine open/close |
display.carousel |
Image/content carousel |
display.filters |
Filter bar with Alpine state |
display.node-graph |
Tree graph with event timeline + detail modal |
display.text-rotate |
Animated rotating text |
feedback.toast |
Toast notification |
form.kv-editor |
Key-value editor with auto-inferring type selector |
form.list-editor |
Chip/tag array-of-strings editor |
form.repeater |
Dynamic repeatable field group |
navigation.menu |
Dropdown / nested menu |
navigation.navbar |
Top navigation bar |
navigation.pagination |
Page navigation controls |
navigation.sidebar |
Collapsible sidebar |
navigation.sidebar-tree |
Nested sidebar menu |
layout.app |
Full page layout with slots |
layout.auth |
Auth page layout |
navigation.tabs |
Tab switcher (inline content) |
Receive a load-url or action prop. Call plain Laravel controllers returning JSON.
| Component | Usage |
|---|---|
display.data-table |
Paginated, sortable, filterable table |
form.filter |
Filter bar for data-table |
form.select |
Select with remote search |
import.spreadsheet |
Excel/CSV chunked import |
sections.auto-form |
Declarative form from schema array |
sections.tabs |
Tabs with lazy-loaded content |
sections.wizard |
Multi-step form with localStorage resume |
<x-dbl::display.badge label="Active" color="success" />
<x-dbl::display.badge label="Pending" color="warning" size="lg" />
<x-dbl::display.badge label="Error" color="error" :outline="true" /><x-dbl::display.stat
title="Monthly revenue"
value="€ 12.400"
description="vs last month"
trend="up"
icon="heroicon-o-banknotes"
/>Renders a tree of simulation/workflow nodes with per-event color coding and a detail modal. Each node is an array with id, parent (null for roots), label, status, events[], and optional meta{}.
@php
$nodes = [
[
'id' => 1,
'parent' => null,
'label' => 'Main trajectory',
'status' => 'completed',
'events' => [
['name' => 'timer_done', 'action_type' => 'terminate', 'termination_type' => 'end', 't_s' => 12.5],
['name' => 'heat_acc', 'action_type' => 'accumulator_abort', 'termination_type' => 'abort', 'acc_value' => 42.7],
],
'meta' => ['h_max_m' => 350.2, 't_final_s' => 12.5],
],
[
'id' => 2,
'parent' => 1,
'label' => 'Branch — ricochet',
'status' => 'failed',
'events' => [
['name' => 'impact', 'action_type' => 'accumulator_fail', 'termination_type' => 'fail', 'acc_value' => 0.1],
],
'meta' => [],
],
];
@endphp
<x-dbl::display.node-graph :nodes="$nodes" label="Execution tree" />Event badge colours: end → green, abort → amber, fail → red, no termination → ghost. Accumulator events show acc_value inline. Click any node dot or event badge to open the detail modal.
{{-- In your Blade view --}}
<x-dbl::display.data-table
:load-url="route('products.data')"
:columns="$columns"
:per-page="15"
:filters-url="route('products.filters')"
/>// In your controller
public function index()
{
return view('products.index', [
'columns' => [
['key' => 'name', 'label' => 'Name', 'sortable' => true],
['key' => 'category', 'label' => 'Category', 'sortable' => false],
['key' => 'price', 'label' => 'Price', 'sortable' => true],
],
]);
}
// Data endpoint — returns JSON
public function data(Request $request)
{
$products = Product::query()
->when($request->search, fn($q) => $q->where('name', 'like', "%{$request->search}%"))
->orderBy($request->sort_by ?? 'name', $request->sort_dir ?? 'asc')
->paginate($request->per_page ?? 15);
return response()->json($products);
}<x-dbl::sections.auto-form
:schema="[
['name' => 'name', 'label' => 'Product name', 'order' => 10],
['name' => 'category_id', 'label' => 'Category', 'type' => 'relation',
'options-url' => route('categories.options'), 'order' => 20],
['name' => 'price', 'label' => 'Price', 'type' => 'money', 'order' => 30],
['name' => 'active', 'label' => 'Active', 'type' => 'toggle', 'order' => 40],
]"
action="{{ route('products.store') }}"
method="POST"
/>// Controller — plain Laravel, no Livewire
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'price' => 'required|numeric|min:0',
'active' => 'boolean',
]);
$product = Product::create($validated);
// DaisyBlade expects: {success, redirect} or {success: false, errors}
return response()->json([
'success' => true,
'redirect' => route('products.index'),
]);
}Each field definition is keyed by key (not name):
<x-dbl::form.repeater
name="installments"
label="Installments"
:fields="[
['key' => 'label', 'type' => 'text', 'label' => 'Label'],
['key' => 'amount', 'type' => 'decimal', 'label' => 'Amount'],
]"
:value="old('installments', [])"
add-label="Add installment"
:min="1"
:max="10"
/>The repeater dispatches dbl-repeater-change ({ name, rows }) whenever the rows change, so a
parent can react without reaching into its Alpine scope:
<div x-data="{ save() { /* … */ } }" @dbl-repeater-change.debounce.500ms="save()">
<x-dbl::form.repeater name="installments" :fields="$fields" />
</div><input type="number"> is the wrong element wherever the decimal separator is not a dot. When the
browser cannot parse what was typed it sanitises value to the empty string, so 0,5 reaches the
server as nothing at all — and which browsers do this in which locale is inconsistent, which is
worse than failing outright.
'type' => 'decimal' renders type="text" inputmode="decimal": the numeric keypad still comes up
on mobile, the text arrives intact, and the backend parses it in its own locale.
Field definitions take default, and it applies to the first row and to every row add()
creates. A default that only reaches the first row is a default the user stops seeing the moment
they add a second one:
['key' => 'unit', 'type' => 'select', 'options' => $units, 'default' => 'mg/h']Schema-driven components take arrays, so there is no attribute bag to merge into. attrs is the
array equivalent, available on form.repeater fields and form.fields:
['key' => 'ref', 'attrs' => ['autocomplete' => 'off', 'maxlength' => 12, 'aria-label' => 'Reference']]Values are escaped. Attribute names are validated, and on* handlers are refused — event handlers
go through events, which is explicit about executing code.
events maps a target element to Alpine bindings, merged over the component's own defaults. The
path is the binding spec and the leaf is the expression:
<x-dbl::form.repeater
name="drugs"
:fields="$fields"
:events="[
'root' => ['keydown' => ['enter' => ['prevent' => 'add()']]],
]"
/>Targets are root, row, add and remove. In scope: rows, add(), remove(index),
notify() — plus index inside a row.
Dotted keys mean the same thing, so a binding can be pasted straight out of the Alpine docs:
['root' => ['keydown.enter.prevent' => 'add()']]The nesting is what makes overriding work. A leaf replaces the branch below it, so passing
['keydown' => ['enter' => 'mine()']] over a default of keydown.enter.prevent leaves one
binding — yours. With flat keys the two would be different keys, both would survive, and both would
fire. Declaring the same binding twice, or a handler and modifiers on the same node, throws.
To extend a default rather than replace it, call it: ['add' => ['click' => 'add(); mine()']].
eventsleaves are executable by design. They are for expressions written by the developer, never for values arriving from outside the application.
Renders a table of key/value rows with an auto-inferring type selector (num/str/bool/{…}). Serialises to a hidden <input> as JSON.
<x-dbl::form.kv-editor
name="parameters"
:value="$model->parameters ?? []"
label="Parámetros"
hint="Campos habituales: mass_kg, v0_ms, diameter_m"
/>value accepts a PHP associative array. Types are inferred automatically when the user types; the selector lets them override if needed.
Chip/tag input for an array of strings. Enter or comma adds a chip; Backspace removes the last one.
<x-dbl::form.list-editor
name="output_fields"
:value="$model->output_fields ?? []"
label="Campos de output"
hint="Disponibles: t_s, x_m, h_m, v_ms, mach"
placeholder="Escribe un campo y pulsa Enter…"
/><x-dbl::sections.wizard
form-id="product-onboarding"
schema-version="2"
:user-id="auth()->id()"
action="{{ route('products.store') }}"
:steps="[
['title' => 'Basic info', 'fields' => ['name', 'category_id']],
['title' => 'Pricing', 'fields' => ['price', 'currency']],
['title' => 'Visibility', 'fields' => ['active', 'publish_at']],
]"
/>If the user refreshes mid-wizard, their progress is automatically restored from localStorage. The storage key is versioned (product-onboarding_{userId}_v2), so changing schema-version invalidates stale state.
{{-- resources/views/products/index.blade.php --}}
<x-dbl::layout.app title="Products">
<x-slot:navbar>
<x-dbl::navigation.navbar>
<x-dbl::navigation.breadcrumb :items="[
['label' => 'Dashboard', 'url' => route('dashboard')],
['label' => 'Products'],
]"/>
</x-dbl::navigation.navbar>
</x-slot:navbar>
<x-slot:sidebar>
<x-dbl::navigation.sidebar />
</x-slot:sidebar>
<x-dbl::display.data-table
:load-url="route('products.data')"
:columns="$columns"
/>
</x-dbl::layout.app>All Type 3 components expect controllers to return JSON following this contract:
// Success with redirect
{ "success": true, "redirect": "/products" }
// Success with data (for remote selects, resource-details, etc.)
{ "success": true, "data": [...], "meta": { "current_page": 1, "last_page": 5 } }
// Validation failure
{ "success": false, "errors": { "name": ["The name field is required."] } }Laravel's response()->json() + standard validation exceptions handle this automatically if you let them.
To customise any component, publish the views:
php artisan vendor:publish --tag=daisyblade-viewsPublished views in resources/views/vendor/daisyblade/ take precedence over package views. Edit freely — your customisations survive package updates.
composer test
# or
vendor/bin/pest188 tests, 294 assertions. All green.
MIT — Eduardo de Vicente / Microvalencia Soluciones Informáticas S.L.