Using the kit in Laravel
Supports Laravel 10, 11, 12 and 13 on PHP 8.1+. Works with plain Blade or Livewire; no Livewire, Alpine or Tailwind dependency.
Install
The repo is private, so point Composer at it first. no-api makes Composer use your normal git credentials. Composer prints a 404 for the zipball, then falls back to git clone; that is expected.
composer config repositories.admiral-ui '{"type":"vcs","url":"https://github.com/yeppymp/admiral-ui-kit","no-api":true}'
composer require admiral/ui-kit:^0.5
php artisan admin-ui:install --name="Northwind Admin" --brand=7c3aed --logo=/brand/logo.svg
That’s the whole setup. admin-ui:install publishes the config and assets, saves the brand to .env, and checks the result. All its options are optional: --name --brand --action --sidebar --logo --font.
To see every component in your brand, set ADMIN_UI_GALLERY=true and open /admin-ui. The page never loads in production.
Laravel 10 and 11
The kit supports both, and CI tests both on every change. The framework is the problem: Laravel 10 and 11 no longer get security fixes, and their last releases (10.50.4, 11.57.1) have five published vulnerabilities that will stay open. Upgrading to Laravel 12 or 13 is the fix. php artisan admin-ui:check reminds you while you’re on 10 or 11.
Adding the kit works as is. In an existing app, composer require admiral/ui-kit:^0.5 installs, and Composer only prints a warning about the framework. Tested with Composer 2.9 and 2.11.
What fails is the app’s own Composer. Composer 2.9 and later block every Laravel 10 and 11 release, so composer update, composer require ... -W and creating a new Laravel 10 project stop with “affected by security advisories”. That happens with or without the kit.
If the app must stay on 10 or 11 for now:
-
Update to the last release of your version first (10.50.x or 11.57.x). It fixes two older advisories, so you won’t need to ignore them: PKSA-8qx3-n5y5-vvnd and PKSA-w7xr-vk7n-rstm.
-
Read each of the five open advisories at packagist.org/security-advisories and decide whether it affects your app:
Advisory Severity What it is PKSA-b41t-wbzk-9q51 (CVE-2026-114509) critical Authentication bypass in TokenGuard, thetokenauth driverPKSA-3r5d-mb8f-1qw9 high CRLF injection through the default emailvalidation rulePKSA-mdq4-51ck-6kdq (CVE-2026-48019) not rated CRLF injection through the default emailvalidation rulePKSA-m5cs-t1y6-qpcs medium Path confusion in temporary signed URLs PKSA-d5tc-s1qs-h781 (CVE-2026-102279) low XSS on the debug page, so only with APP_DEBUGon -
Ignore only those five, each with the reason you decided, in the app’s
composer.json. The reasons stay in the repo for the next person:"config": { "audit": { "ignore": { "PKSA-b41t-wbzk-9q51": "We don't use the token guard. Upgrade to 12 planned for Q1.", "PKSA-3r5d-mb8f-1qw9": "...", "PKSA-mdq4-51ck-6kdq": "...", "PKSA-m5cs-t1y6-qpcs": "...", "PKSA-d5tc-s1qs-h781": "APP_DEBUG is off in production." } } }
Don’t set audit.block-insecure to false. That also hides every advisory published later, in any package. With the list above, Composer blocks again when a new one appears, which is when you need to decide again.
Advisories as of 6 Nov 2026. composer audit shows the current list.
Updating
Read CHANGELOG.md first. ^0.5 takes fixes within 0.5; moving to 0.6 is a deliberate change of the constraint.
composer update admiral/ui-kit
php artisan vendor:publish --tag=admin-ui-assets --force
New Laravel apps already publish laravel-assets on composer update, which includes the kit, so the second line is only for older apps.
Change the brand later
Edit .env, then run php artisan admin-ui:check.
ADMIN_UI_NAME="Northwind Admin"
ADMIN_UI_LOGO=/brand/logo.svg
ADMIN_UI_BRAND="#7c3aed"
ADMIN_UI_ACTION="#6d28d9"
ADMIN_UI_SIDEBAR="#1e1b4b"
ADMIN_UI_FONT="IBM Plex Sans"
ADMIN_UI_FONT_URL="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&display=swap"
ADMIN_UI_RADIUS=6
ADMIN_UI_DENSITY=1
Quote colours. An unquoted # starts a comment in .env, so the default colour would be used with no error. admin-ui:check catches this, along with invalid colours, unreadable contrast and unpublished assets. It exits with an error, so it can run in CI.
With no logo set, the sidebar shows the first letter of the name. For navigation, fill in nav in config/admin-ui.php; Cmd/Ctrl+K search over those pages is added automatically.
Generate a page
One command creates a working list, form and detail page with a controller:
php artisan admin-ui:page Segment --model \
--fields="name,email:email?,status:select[draft|live],starts:date?,notes:textarea?,members:number?,live:switch"
php artisan migrate
Field types: text (default), email, url, number, date, textarea, select[a|b], checkbox, switch. A ? makes a field optional. Without --model, it expects App\Models\Segment to exist already. --prefix=admin sets the URL, route and view prefix; --force replaces existing files. Without --force, existing files are left alone.
It prints the route and sidebar lines to add. Put the route behind your auth middleware and add a policy before going live: the generated controller doesn’t check who may see or change a record. It does use validated input only, an allow-listed sort and escaped search.
To change what it generates, run php artisan vendor:publish --tag=admin-ui-stubs and edit the copies in stubs/vendor/admin-ui.
A page
<x-admin::layout title="Segments">
<x-admin::page-header title="Segments" description="Rules that group members.">
<x-slot:actions>
<x-admin::button variant="primary" icon="plus" :href="route('segments.create')">New segment</x-admin::button>
</x-slot:actions>
</x-admin::page-header>
<x-admin::table :is-empty="$segments->isEmpty()">
<x-slot:head><th>Name</th><th class="adm-num">Members</th></x-slot:head>
@foreach ($segments as $segment)
<tr><td>{{ $segment->name }}</td><td class="adm-num">{{ $segment->members }}</td></tr>
@endforeach
<x-slot:empty><x-admin::empty title="No segments yet" /></x-slot:empty>
<x-slot:footer><x-admin::pagination :paginator="$segments" /></x-slot:footer>
</x-admin::table>
</x-admin::layout>
A form
name is all a field needs. The label’s for, the id, the old() value, the validation error and aria-invalid are handled for you. x-admin::form adds CSRF, and method spoofing for PUT, PATCH and DELETE.
<x-admin::form :action="route('segments.update', $segment)" method="PUT" files>
<x-admin::input name="name" label="Name" hint="At least 5 characters." required />
<x-admin::select name="platform" label="Platform" placeholder="Choose…" :options="$platforms" />
<x-admin::radio-group name="division" label="Division" inline :options="$divisions" />
<x-admin::file-input name="logo" label="Logo" accept="image/*" :current="$segment->logo_url" removable />
<x-admin::sensitive-input name="api_key" label="API key" :stored="$segment->has_key" />
<x-admin::switch name="live" label="Sync is live" />
<x-admin::save-bar status="Saving keeps a draft.">
<x-admin::button variant="primary" type="submit">Save</x-admin::button>
</x-admin::save-bar>
</x-admin::form>
Add files to any form with a file input. Without it the browser sends no file, and the kit logs a console warning on submit.
sensitive-input never prints a value, even when you pass one. Treat blank as “keep the saved value” in your controller when stored is set. removable adds a {name}_remove checkbox; your controller does the removing.
Flash messages become toasts: return back()->with('success', 'Segment saved'); (also status, error, warning, info). Add a button, such as Undo, with ->with('toast_action', ['label' => 'Undo', 'url' => route('perks.restore', $perk), 'method' => 'POST']); GET gives a link instead of a form.
Pass dynamic text with :label="$text", not label="{{ $text }}", which escapes it twice.
Livewire
Attributes pass through to the real element, so plain fields and buttons work as normal:
<x-admin::input name="query" wire:model.live="query" />
<x-admin::button wire:click="save">Save</x-admin::button>
Components
There are 109. The full reference is examples/components.html: every component live, with its Blade and the HTML for other stacks. A test fails if a component is missing from it.
| Group | Components |
|---|---|
| Actions | button, button-group, dropdown, dropdown-item, dropdown-separator, toggle, segmented, link, dropdown-header, context-menu, export-menu |
| Forms | form, input, textarea, select, combobox, autocomplete, tag-input, date-picker, date-range-picker, checkbox, switch, radio, radio-group, file-input, sensitive-input, slider, otp, label, field, form-grid, form-actions, save-bar, choice-cards, checkbox-list, repeater, repeater-row, rich-text, otp-resend, calendar, condition-group, condition-row |
| Data | table, th, select-all, row-checkbox, bulk-actions, pagination, dl, item, stat, bar-chart, line-chart, pie-chart, sparkline, progress, meter, avatar, copy-text, code, kbd, row-toggle, row-details, badge-list, bar-list, timeline, timeline-item, diff, rule-summary, checklist, prose |
| Feedback | badge, banner, empty, skeleton, spinner, toasts, notification-menu |
| Overlays | dialog, confirm, confirm-delete, sheet, popover, tooltip, command-palette, command-trigger |
| Layout | layout, auth-layout, head, nav, page-header, breadcrumbs, card, tabs, tab-group, tab-panel, accordion, accordion-item, collapsible, table-of-contents, toolbar, stack, grid, separator, text, icon, columns, section-nav, steps |
Sortable tables
th sort="name" builds the link; your controller does the sorting. Check the column against an allow-list before it reaches a query, because the value comes from the URL:
$sort = in_array($request->query('sort'), ['name', 'members'], true) ? $request->query('sort') : 'name';
$segments = Segment::orderBy($sort, $request->query('direction') === 'desc' ? 'desc' : 'asc')->paginate(20);
Livewire and the enhanced fields
Tested with Livewire 4: combobox (single and multiple), date-picker and radio-group send changes through wire:model and survive re-renders.
- They set
wire:ignorewhen you pass awire:*attribute, so values flow from the browser to Livewire. If the server changes the value, re-render the component with a newwire:key. tag-inputdoesn’t bind towire:model, because its values are hidden inputs. Inside Livewire, submit it with a form or usecombobox multiple.
Layout: columns and grids
columns sets widths; grid fits as many equal columns as there’s room for.
<x-admin::columns spans="8/4" sticky="last">
<x-admin::card title="Rule">…</x-admin::card>
<x-admin::card title="Save">…</x-admin::card>
</x-admin::columns>
<x-admin::grid min="20rem" equal>…cards of the same height…</x-admin::grid>
Columns stack when the space they get runs out, not the screen, so a sidebar or a card around them counts. Give them a block-level parent: inside a flex row or an auto column they have no width to measure.
Exports
One definition sends every row as CSV, Excel, text to copy, a print view or a PDF:
use Admiral\UiKit\Export;
public function export(Request $request)
{
$this->authorize('viewAny', Perk::class);
return Export::make(Perk::query()->filter($request)->orderBy('name'))
->columns([
'name' => 'Name',
'status' => ['label' => 'Status', 'format' => fn ($value) => ucfirst($value)],
])
->filename('perks')
->response($request->query('format', 'csv'));
}
<x-admin::export-menu :url="route('perks.export', request()->query())" />
Excel needs composer require openspout/openspout and PDF needs composer require dompdf/dompdf. Turn off what you don’t install in config('admin-ui.exports.formats'). A query is read with a cursor, so eager loads don’t apply: join what you need, or pass a collection. cap limits rows (50,000 by default, 2,000 for print and PDF); over it the response has an X-Export-Truncated header with the full count, and print and PDF say so.
Rich text
<x-admin::rich-text name="body" label="Description" :value="$perk->body" :buttons="['bold', 'italic', 'ul', 'link']" />
<x-admin::prose :html="$perk->body" :buttons="['bold', 'italic', 'ul', 'link']" />
$perk->body = RichText::clean($request->input('body'), ['bold', 'italic', 'ul', 'link']);
Needs composer require symfony/html-sanitizer. Always clean on the server: the toolbar limits what the editor makes, not what can be posted.
Using your own layout or bundler
Put <x-admin::head /> in your <head> and add class="adm-root" to <body>. <x-admin::head nonce="…" /> passes a CSP nonce; Vite::useCspNonce() is picked up automatically.
ADMIN_UI_ASSETS controls how the assets load:
published(default): links topublic/vendor/admiral-ui.inline: prints them in the page. No publish step, no browser caching. Use it for trying the kit out.none: you import them yourself, fromvendor/admiral/ui-kit/laravel/resources/dist/.
Adding the kit to a project with an existing theme
You can move one screen at a time. Next to an unlayered theme the kit loses every CSS conflict, because unlayered rules beat layered ones: on a real client admin, 1,849 style values changed. The fix is to load the theme into a low-priority layer, in the layout:
<style>
@import url('{{ asset('assets/css/style.css') }}') layer(legacy);
@import url('{{ asset('assets/css/style-preset.css') }}') layer(legacy);
</style>
<x-admin::head />
Then wrap new kit markup in <div class="adm-root" style="background:none">. Kit resets only apply inside .adm-root, so old screens keep their look.
Tested on a copy of a real client admin’s auth layout: the existing login page had 0 style changes across 361 elements, and only 37 trivial differences remained on kit components. Its main admin layout wasn’t tested, because that needs a database. Compare your own pages before and after, particularly any with inline <style> blocks, which now beat the theme.
Translations
All kit text follows app()->setLocale(). English, German and French are included. The German and French are unreviewed drafts, so have a native speaker check them before a client sees them.
To add a language or change wording, run php artisan vendor:publish --tag=admin-ui-lang and edit lang/vendor/admin-ui/{locale}/ui.php. Text you pass in, such as title or label, stays yours to translate. Generated pages use __(), so add those strings to your JSON language files.
Dates in the calendar use the page’s <html lang>, including the first day of the week. Numbers follow the locale when PHP’s intl extension is installed.
Browser text is printed by <x-admin::head /> in a <meta name="adm-i18n"> tag. On other stacks, print the same tag with your own JSON; missing keys fall back to English.
Change one component for one project
php artisan vendor:publish --tag=admin-ui-views
Delete the copies you don’t change, or they will miss future kit updates. Whatever remains in resources/views/vendor/admin-ui/components overrides the kit’s version.
Not on Laravel
Use laravel/resources/dist/admiral-ui.css and .js directly with the same class names, from any language. See the main README.