Installation
LarawellUi copies components into your app, then gets out of the way. Everything below takes about five minutes.
Requirements
- PHP 8.3 or later, and Laravel 12 or 13.
- Vite and Tailwind CSS 4.1 or later. New Laravel apps have both.
- Pages rendered with Blade, Livewire components included.
- The date picker, date range picker, time picker and price roll need PHP's
intlextension.
Browsers
- Everything works as designed in Chrome and Edge 117, Firefox 129 and Safari 17.5 or later, all released in 2024.
- In Chrome and Edge 114 to 116, Firefox 128 and Safari 17 to 17.4, everything works, but some opening and closing animations are skipped.
- In anything older, the select, the date pickers, the time picker's list and columns, the phone's country list, the dropdown and the tooltips can't open, because they're built on the browser's own popover. The rest still render, but aren't tested there.
- On iPhone and iPad before iOS 18.3, tapping outside an open popover doesn't close it, because of a WebKit bug. Choosing an option or pressing its trigger again still closes it.
Content Security Policy
- The components work under a strict policy such as
default-src 'self', with no'unsafe-inline'for scripts or styles. They render no inline scripts, event handlers orstyleattributes; sizes worked out on the server are classes. - Examples that call a component's JavaScript come with a script for your own JS file, not
onclick. - The captcha's Turnstile, reCAPTCHA or hCaptcha option loads that provider's script, so allow its domain in
script-srcandframe-src.
Livewire
- Livewire isn't needed, but the components work inside Livewire 3 and 4 components, checked in a browser. They keep what the person did through every render (an open modal or menu, a panel they opened, a running stopwatch, files they picked) while their content updates. Each one's page shows how under Usage.
- Form controls bind with
wire:modeland need no name. They show the property's value after every render, a value set in PHP included, and its validation errors. The file upload sends files to Livewire's temporary uploads (WithFileUploads). - A component shows a toast with
$this->dispatch('toast', type: 'success', message: 'Saved.')and opens or closes a modal with'modal-open'and'modal-close', each with anid. A flash before a redirect,wire:navigateincluded, still shows as a toast. - Components added to the page later, by a Livewire render,
wire:navigateor HTML you fetch, set themselves up. - Where the browser owns the state, the server's props only set how it starts: an accordion's
open, and a stopwatch or timer, which keeps running and reads its props once. A progress bar you drive withprogress.set()goes in awire:ignore. The captcha's Turnstile, reCAPTCHA and hCaptcha tokens aren't bound bywire:model; use those in a regular form.
When it isn't a fit
- Projects on Tailwind CSS v3, Bootstrap or another CSS framework: the components are styled with Tailwind v4 classes and theme tokens.
- Inertia apps whose pages are React or Vue: the components are Blade, so they can't render there.
Start a new app
Starting from scratch? The starter kit is a Laravel 13 app with sign-in, registration, password reset, email verification, account settings and a dashboard, built from these components. It's plain Blade with no React, Vue or Alpine, sends a strict Content Security Policy, and comes with Laravel Boost and the MCP server set up for AI agents. The components are already installed, so everything below applies to it too.
composer create-project larawellui/starter-kit my-app
cd my-app
npm install && npm run build
composer run dev
With the Laravel installer, laravel new my-app --using=larawellui/starter-kit does the same first step.
Its source, security notes and checklist for going live are on GitHub. Already have an app? Add components to it below.
Add components
Require the package, then add components by name. Each one brings the components, PHP helpers and validation rules it depends on. Before writing anything it checks package.json, and stops if Tailwind CSS is older than 4.1.
composer require --dev larawellui/larawellui
php artisan larawell:add datepicker table
npm run build
Run it again whenever you like, for example after composer update; --installed updates every component you already have. Files you haven't edited get the new version, files you have edited are skipped and listed, and --force overwrites those too. It keeps track in larawellui.lock in your app's root, so commit that file.
php artisan larawell:list # everything you can add
php artisan larawell:add # pick from a list
php artisan larawell:add --all
php artisan larawell:add select --dry-run # show what would change
php artisan larawell:add --installed # update everything you have
php artisan larawell:diff # what the skipped files would miss
php artisan larawell:diff select/index.blade.php
Values from your data
A prop that takes one of a set of values (variant, size, tone…) throws on anything else, so a typo shows up while you build instead of shipping the wrong look. A value from your data, such as an order's status or a size from settings, can be one you didn't expect, and then the page fails. Map it to the component's values first, with a default. With an enum, a method on it keeps the mapping in one place.
<x-widget.table.badge :tone="['paid' => 'success', 'failed' => 'error', 'pending' => 'warning'][$order->status] ?? 'neutral'">
{{ $order->status }}
</x-widget.table.badge>
What an update can change
What you build on is only ever added to, never renamed or removed: component names, props and the values they take, data-* hooks, JS exports, the commands and their flags, and the config keys. A release can still get stricter about input that never worked, such as a mistyped value that used to be ignored and now throws. The changelog lists every change by component, so check the ones you use before running --installed.
Common tasks
The catalogue is sorted by component; here it's sorted by what you came to do. Each one links to a live example you can copy.
- Sort a table by any column, without reloading the page: Table, Sortable
- Filter a table as people type or pick: Table, Filters
- Act on several selected rows at once: Table, Selectable
- Ask "are you sure?" before deleting: Modal, Confirm
- Show a toast, from a script or after a redirect: Toast, Try it
- Show a field only when another one has a value: Show if, Contact method
- Search your database as people type: Select, Search your app
- Upload each file as soon as it's picked, with progress and retry: File upload, Direct upload
- Ask for a date of birth with a minimum age: Date picker, Birthday
- Take a booking date range, from today with no upper limit: Date range picker, Booking
- Show progress through a multi-step form: Stepper, Labelled
- Ask for a one-time code: Verification code
Forms from your validation rules
Already have a backend? Your Form Request, or the array you pass to validate(), already says what each field is. Give each widget the rule's key as its name, and it fills in old input and shows that field's errors by itself. Add required where the rules say required, and leave it off for nullable. In a Livewire component, use wire:model instead of name; for a named error bag, add bag="…".
| In your rules | Widget | Set |
|---|---|---|
| string, min:3, max:120 | text-input | minlength="3" maxlength="120" |
| string, max:500 (longer text) | textarea | maxlength="500" counter |
| text-input | type="email" | |
| url | text-input | type="url" |
| integer, min:1, max:12 | number | :decimals="0" :min="1" :max="12" |
| numeric, decimal:0,2 | number | :decimals="2" |
| boolean | checkbox or switch | unchecked-value="0" |
| accepted | checkbox | required |
| in:… or Rule::in([…]) | select, or radio for a few | :options="[…]" |
| exists:rooms,id | select | :options="$rooms", searchable or search-url for long lists |
| array, plus in or exists for each | select | multiple |
| date, after:today | datepicker | :min="…" :max="false" (the latest date is today unless you change it) |
| date, before_or_equal:today | datepicker | nothing: today is the latest by default; birthday for an age |
| date_format:H:i | time-picker | min, max, step |
| a start and an end, after_or_equal on the end | date-range-picker | start-name and end-name: your two field names; :max="false" for future dates, as on the datepicker |
| file, mimes:pdf,jpg, max:2048 | file-upload | accept=".pdf,.jpg,.jpeg" max-size="2048" (mimes:jpg takes .jpeg files too) |
| image | file-upload | accept="image/*" |
| confirmed, Password::defaults() | password | new, with :min and mixed-case, numbers or symbols as your defaults ask, plus a second password named password_confirmation |
| digits:6, or a one-time code | verification-code | :length="6" |
| required_if:contact,phone | show-if around the field | field="contact" value="phone" |
| a phone number | phone | country="…" to start in one |
These mirror your rules so people find out early, as they type or pick; your rules on the server still decide. For example, these rules:
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email'],
'guests' => ['required', 'integer', 'min:1', 'max:12'],
'room' => ['required', Rule::in(['standard', 'deluxe', 'suite'])],
'arrival' => ['required', 'date', 'after:today'],
'notes' => ['nullable', 'string', 'max:500'],
'newsletter' => ['boolean'],
'passport' => ['nullable', 'file', 'mimes:pdf,jpg,png', 'max:2048'],
become this form. A file field needs enctype="multipart/form-data" on the form.
<form method="POST" action="{{ route('bookings.store') }}" enctype="multipart/form-data" class="grid gap-5">
@csrf
<x-widget.text-input name="name" label="Name" maxlength="120" required />
<x-widget.text-input name="email" type="email" label="Email" required />
<x-widget.number name="guests" label="Guests" :decimals="0" :min="1" :max="12" required />
<x-widget.select name="room" label="Room" :options="['standard' => 'Standard', 'deluxe' => 'Deluxe', 'suite' => 'Suite']" required />
<x-widget.datepicker name="arrival" label="Arrival" :min="now()->addDay()->toDateString()" :max="false" required />
<x-widget.textarea name="notes" label="Notes" maxlength="500" counter />
<x-widget.checkbox name="newsletter" label="Send me offers" unchecked-value="0" />
<x-widget.file-upload name="passport" label="Passport scan" accept=".pdf,.jpg,.jpeg,.png" max-size="2048" />
<x-widget.button type="submit">Book</x-widget.button>
</form>
Where files go
| What | Where |
|---|---|
| Blade components, used as <x-widget.*> | resources/views/components/widget/ |
| JavaScript, imported from resources/js/app.js | resources/js/widget/ |
| Theme and base CSS, imported from resources/css/app.css | resources/css/widget/ |
| PHP helpers (FormField, ElementIds, and Countries for the phone) | App\View\Widget |
| Validation rules (date rules, Captcha) | App\Rules |
To use other namespaces or paths, publish the config. Namespaces must sit under a PSR-4 root in your composer.json.
php artisan vendor:publish --tag=larawellui-config
Theme
Components only use the colour names in resources/css/widget/theme.css. Change the values there and every component follows.
@theme {
--color-primary: #15803d; /* text, borders, focus rings */
--color-on-primary: #ffffff;
--color-foreground: #101828; /* main text */
--color-field: #f5f6f8; /* input backgrounds */
--color-error: #b42318;
/* … */
}
Dark mode
The theme comes with a dark set of the same colours, all at WCAG AA. Put class="dark" or data-theme="dark" on <html> and every component follows, popovers and dialogs included. On any other element, only that part of the page does. To follow the device's setting instead, swap the selector in theme.css for @media (prefers-color-scheme: dark) { :root { … } }.
Each colour does one job, which is what lets dark mode stay readable: primary is for text, borders and focus rings, and primary-fill is for solid backgrounds with on-primary text on them. On a dark page a shade light enough to read can't also carry white text, so the two differ there. error-fill and success-fill do the same for red and green. In light mode the fills follow their base colour unless you set them, so changing primary restyles both. In dark mode they're a shade of their own, so change your brand colour there too.
:where(.dark, [data-theme=dark]) {
--color-primary: var(--color-green-400); /* reads on the dark surface */
--color-primary-fill: var(--color-green-700); /* carries white text */
/* … */
}
Validating dates
The date pickers stop people choosing future dates using their own local today. Validate with the rules that come with them, not before_or_equal:today, which uses the server's UTC date and can reject a real local today.
use App\Rules\MinimumAge;
use App\Rules\NotAfterToday;
return [
'start_date' => ['required', new NotAfterToday],
'date_of_birth' => ['required', new MinimumAge(18)],
];
Dates in any language
- Dates are written the way your app's locale writes them: Sep 26, 2026 in en, 26 Sept 2026 in en_GB, 2026年9月26日 in ja. Month and weekday names, and digits, follow too. What's submitted is always Y-m-d.
- The week starts on the locale's first day: Monday in most of the world, Sunday in the US, Saturday in parts of the Middle East. week-start overrides it, and locale sets a different locale for one picker.
- For Arabic, Hebrew, Persian and Urdu, set dir="rtl" on <html>: the calendar, its arrows and the arrow keys mirror.
- Formatting needs PHP's intl extension; without it, dates show as 2026-09-26.
The pickers' own text ("Choose a date", "Clear", "Previous month" and so on) is plain English in the component files, like every other component's. It's your copy, so change it there, or wrap it in __() if your app is multilingual.
For AI agents
Point your agent at /llms.txt. From there it can read any component's entry and either run larawell:add or write the files in itself. For an app built with Livewire, each entry's usage includes a Livewire example.
In your own app, php artisan larawell:mcp is an MCP server the agent can connect to. On top of the catalogue, it knows which components this app has, which installed files are out of date or edited, and it can install: a dry run first, which writes nothing until you agree, and never over files you edited.
claude mcp add larawellui -- php artisan larawell:mcp
{
"mcpServers": {
"larawellui": {
"type": "stdio",
"command": "php",
"args": [
"${workspaceFolder}/artisan",
"larawell:mcp"
]
}
}
}
{
"servers": {
"larawellui": {
"type": "stdio",
"command": "php",
"args": [
"artisan",
"larawell:mcp"
],
"cwd": "${workspaceFolder}"
}
}
}
codex mcp add larawellui -- php /path/to/your-app/artisan larawell:mcp
gemini mcp add larawellui php /path/to/your-app/artisan larawell:mcp
Claude Desktop, Windsurf, Zed, JetBrains and any other client: the command php, with the arguments /path/to/your-app/artisan larawell:mcp. The full path to artisan is what makes it work where a client may not start the server in your app's folder. If a desktop app can't find php, give it the full path too (which php).
- /llms.txt
- An index of every component, for agents that follow the llms.txt convention.
- /r/index.json
- Every component, with a link to its full entry.
- /r/datepicker.json
- One component's full entry: install command, props, examples and source.
- php artisan larawell:list --json
- The same catalogue offline, from the installed package.
- php artisan larawell:mcp
- An MCP server in your app: the catalogue, what this app has installed and edited, and installing with a dry run first.