Audit Log
A bounded semantic audit feed that separates actor, action, subject, time, and escaped metadata from already-authorized view data.
Preview
Today
- AS Avery Stone
-
PN
Priya Nair
Priya Nair created Customer Acme B.V.
- plan
- Pro
- seats
- 12
Yesterday
-
SY
System
System exported Customer report
- format
- CSV
- rows
- 1204
- via
- API
- from
- 203.0.113.4
GET /api/reports/customers?format=csv Range: 2026-09-01..2026-09-17 Duration: 4.2s
Tue, Sep 29
-
AS
Avery Stone
Avery Stone deleted API key sk_live_…9f2c
@php
$events = [
['actor' => ['name' => 'Avery Stone', 'initials' => 'AS'], 'action' => __('changed'), 'subject' => __('Invoice INV-1042'), 'href' => '#', 'at' => now()->subMinutes(12), 'changes' => [['status', 'unpaid', 'paid'], ['paid_at', '', now()->format('Y-m-d H:i')]], 'via' => 'Dashboard'],
['actor' => ['name' => 'Priya Nair', 'initials' => 'PN'], 'action' => __('created'), 'subject' => __('Customer Acme B.V.'), 'href' => '#', 'at' => now()->subHours(3), 'metadata' => ['plan' => 'Pro', 'seats' => 12]],
['actor' => ['name' => 'System', 'initials' => 'SY'], 'action' => __('exported'), 'subject' => __('Customer report'), 'at' => now()->subDay()->setTime(11, 50), 'metadata' => ['format' => 'CSV', 'rows' => 1204], 'via' => 'API', 'ip' => '203.0.113.4', 'details' => "GET /api/reports/customers?format=csv\nRange: 2026-09-01..2026-09-17\nDuration: 4.2s"],
['actor' => ['name' => 'Avery Stone', 'initials' => 'AS'], 'action' => __('deleted'), 'subject' => __('API key sk_live_…9f2c'), 'at' => now()->subDays(3)->setTime(9, 5), 'tone' => 'destructive'],
];
@endphp
<div class="w-full max-w-2xl">
<x-ui.audit-log :events="$events" />
</div>
Installation
php artisan ui:add audit-log
Registry contract
php artisan ui:add audit-log
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/audit-log.blade.php
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Audit Log (`audit-log`)
A bounded semantic audit feed that separates actor, action, subject, time, and escaped metadata from already-authorized view data.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add audit-log
```
## Usage
```blade
@php
$events = [
['actor' => ['name' => 'Avery Stone', 'initials' => 'AS'], 'action' => __('changed'), 'subject' => __('Invoice INV-1042'), 'href' => '#', 'at' => now()->subMinutes(12), 'changes' => [['status', 'unpaid', 'paid'], ['paid_at', '', now()->format('Y-m-d H:i')]], 'via' => 'Dashboard'],
['actor' => ['name' => 'Priya Nair', 'initials' => 'PN'], 'action' => __('created'), 'subject' => __('Customer Acme B.V.'), 'href' => '#', 'at' => now()->subHours(3), 'metadata' => ['plan' => 'Pro', 'seats' => 12]],
['actor' => ['name' => 'System', 'initials' => 'SY'], 'action' => __('exported'), 'subject' => __('Customer report'), 'at' => now()->subDay()->setTime(11, 50), 'metadata' => ['format' => 'CSV', 'rows' => 1204], 'via' => 'API', 'ip' => '203.0.113.4', 'details' => "GET /api/reports/customers?format=csv\nRange: 2026-09-01..2026-09-17\nDuration: 4.2s"],
['actor' => ['name' => 'Avery Stone', 'initials' => 'AS'], 'action' => __('deleted'), 'subject' => __('API key sk_live_…9f2c'), 'at' => now()->subDays(3)->setTime(9, 5), 'tone' => 'destructive'],
];
@endphp
<div class="w-full max-w-2xl">
<x-ui.audit-log :events="$events" />
</div>
```
## Props
- `events` (list<AuditEvent>, default `[]`) — Up to 100 authorized events with actor, action, subject, at, and scalar metadata.
- `emptyLabel` (string, default `No audit events`) — Localized empty label.
- `label` (string, default `Audit log`) — Accessible section label.
- `grouped` (boolean, default `true`) — Group events under day headings (Today, Yesterday, a date).
- `variant` ('card'|'plain', default `card`) — `card` frames each day's events in a bordered card; `plain` keeps only the hairlines, for a log that already sits inside a card or a tab panel.
## Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Present immutable audit events supplied by an application service.
## Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- Do not use this component for audit storage, authorization, or rich HTML metadata. Use admin-activity-timeline instead for a narrative event timeline.
## Anti-patterns
- Adding display ornament without informational value
## Rules
- Use the `<brok:audit-log>` tag (or `<x-ui.audit-log>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/audit-log
- Registry JSON (files, props, contract): https://brokui.dev/r/open/audit-log.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Long Content
<div class="max-w-xs">
<x-ui.audit-log :events="[['actor' => ['name' => __('A deliberately long actor name for overflow testing'), 'initials' => 'AL'], 'action' => __('changed a setting with a long translated action label'), 'subject' => __('A subject value that must wrap without clipping important information'), 'at' => now(), 'metadata' => ['source' => __('A long metadata value')]]]" />
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| events | list<AuditEvent> | [] | Up to 100 authorized events with actor, action, subject, at, and scalar metadata. |
| emptyLabel | string | No audit events | Localized empty label. |
| label | string | Audit log | Accessible section label. |
| grouped | boolean | true | Group events under day headings (Today, Yesterday, a date). |
| variant | 'card' | 'plain' | card | `card` frames each day's events in a bordered card; `plain` keeps only the hairlines, for a log that already sits inside a card or a tab panel. |
Slots
Default Blade slot only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Bounds output to 100 events and six metadata fields per event.
- Escapes actor, subject, action, and metadata values.
- Declares registry capability flags: a11y, authoredStateFixtures, responsive, rtl, darkMode, localized.
Guidance
Present data for rapid scanning.
Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Present immutable audit events supplied by an application service.
Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- Do not use this component for audit storage, authorization, or rich HTML metadata. Use admin-activity-timeline instead for a narrative event timeline.
Use instead
- Table for exact comparison
- Plain text for a single value
Anti-patterns
- Adding display ornament without informational value
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- Uses a labelled section and an ordered list.
- Event meaning is present in text.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="audit-log-{{ $record->id }}">
@php
$events = [
['actor' => ['name' => 'Avery Stone', 'initials' => 'AS'], 'action' => __('changed'), 'subject' => __('Invoice INV-1042'), 'href' => '#', 'at' => now()->subMinutes(12), 'changes' => [['status', 'unpaid', 'paid'], ['paid_at', '', now()->format('Y-m-d H:i')]], 'via' => 'Dashboard'],
['actor' => ['name' => 'Priya Nair', 'initials' => 'PN'], 'action' => __('created'), 'subject' => __('Customer Acme B.V.'), 'href' => '#', 'at' => now()->subHours(3), 'metadata' => ['plan' => 'Pro', 'seats' => 12]],
['actor' => ['name' => 'System', 'initials' => 'SY'], 'action' => __('exported'), 'subject' => __('Customer report'), 'at' => now()->subDay()->setTime(11, 50), 'metadata' => ['format' => 'CSV', 'rows' => 1204], 'via' => 'API', 'ip' => '203.0.113.4', 'details' => "GET /api/reports/customers?format=csv\nRange: 2026-09-01..2026-09-17\nDuration: 4.2s"],
['actor' => ['name' => 'Avery Stone', 'initials' => 'AS'], 'action' => __('deleted'), 'subject' => __('API key sk_live_…9f2c'), 'at' => now()->subDays(3)->setTime(9, 5), 'tone' => 'destructive'],
];
@endphp
<div class="w-full max-w-2xl">
<x-ui.audit-log :events="$events" />
</div>
</div>
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// [['actor' => ['name', 'initials', 'avatar'], 'action' => 'changed', 'subject' => 'Invoice INV-1042',
// 'href' => null, 'at' => Carbon|string, 'tone' => 'info|success|warning|destructive|neutral',
// 'changes' => [['field', 'from', 'to'], …], 'metadata' => ['key' => 'value'], 'via' => 'API', 'ip' => '…',
// 'details' => 'longer text, folded away']]
'events' => [],
'emptyLabel' => 'No audit events',
'label' => 'Audit log',
// Group events under day headings (Today, Yesterday, a date).
'grouped' => true,
// `card` frames each day's events in a bordered card; `plain` keeps only
// the hairlines, for a log that already sits inside a card or a tab panel.
'variant' => 'card',
])
@php
$events = array_slice(array_values(array_filter((array) $events, 'is_array')), 0, 100);
// Tone of the action glyph: the verb hints at it when the event does not say.
$toneOf = static function (array $event): string {
$tone = $event['tone'] ?? null;
if (in_array($tone, ['info', 'success', 'warning', 'destructive', 'neutral'], true)) {
return $tone;
}
$verb = mb_strtolower((string) ($event['action'] ?? ''));
return match (true) {
str_contains($verb, 'creat') || str_contains($verb, 'add') || str_contains($verb, 'approv') => 'success',
str_contains($verb, 'delet') || str_contains($verb, 'remov') || str_contains($verb, 'reject') || str_contains($verb, 'fail') => 'destructive',
str_contains($verb, 'warn') || str_contains($verb, 'suspend') || str_contains($verb, 'lock') => 'warning',
str_contains($verb, 'chang') || str_contains($verb, 'updat') || str_contains($verb, 'edit') => 'info',
default => 'neutral',
};
};
$glyphWrap = [
'success' => 'bg-success-soft text-success-text',
'destructive' => 'bg-destructive-soft text-destructive-text',
'warning' => 'bg-warning-soft text-warning-text',
'info' => 'bg-info-soft text-info-text',
'neutral' => 'bg-muted text-muted-foreground',
];
$glyphs = [
'success' => '<path d="M12 5v14M5 12h14" />',
'destructive' => '<path d="M5 12h14" />',
'warning' => '<path d="M12 9v4M12 17h.01" />',
'info' => '<path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />',
'neutral' => '<path d="M12 16v-4M12 8h.01" /><circle cx="12" cy="12" r="10" />',
];
$dayOf = static function ($at): string {
try {
$date = \Illuminate\Support\Carbon::parse($at);
} catch (\Throwable) {
return '';
}
if ($date->isToday()) {
return __('Today');
}
if ($date->isYesterday()) {
return __('Yesterday');
}
return $date->translatedFormat($date->isCurrentYear() ? 'D, M j' : 'M j, Y');
};
$groups = [];
foreach ($events as $event) {
$key = $grouped ? $dayOf($event['at'] ?? null) : '';
$groups[$key][] = $event;
}
$uid = 'audit-log-'.\Illuminate\Support\Str::random(6);
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$variant = in_array($variant, ['card', 'plain'], true) ? $variant : 'card';
$listClass = $variant === 'plain' ? 'divide-y divide-border' : 'divide-y divide-border rounded-md border border-border bg-card';
$eventPadding = $variant === 'plain' ? 'py-4 first:pt-0 last:pb-0' : 'p-4';
@endphp
{{-- Audit log: who did what to which record, when — one sentence per event
with an action glyph in a tone, field changes as "from → to" pairs, and
the longer detail folded away. Events group under day headings. --}}
<section data-slot="audit-log" data-variant="{{ $variant }}" aria-label="{{ __($label) }}" {{ $attributes->merge(['class' => 'min-w-0']) }}>
@if ($events === [])
<div class="flex items-center gap-3 rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
<svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6M8 13h8M8 17h5" /></svg>
{{ __($emptyLabel) }}
</div>
@else
<div class="flex flex-col gap-6">
@foreach ($groups as $day => $dayEvents)
<div class="flex flex-col gap-2">
@if ($day !== '')
<h3 data-slot="audit-log-day" class="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">{{ $day }}</h3>
@endif
<ol class="{{ $listClass }}" role="list">
@foreach ($dayEvents as $i => $event)
@php
$tone = $toneOf($event);
$actor = $event['actor'] ?? [];
$changes = array_values(array_filter((array) ($event['changes'] ?? []), 'is_array'));
$metadata = array_slice((array) ($event['metadata'] ?? []), 0, 6, true);
$details = (string) ($event['details'] ?? '');
$detailsId = $uid.'-'.$loop->parent->index.'-'.$i;
@endphp
<li data-slot="audit-log-event" data-tone="{{ $tone }}" class="flex min-w-0 gap-3 {{ $eventPadding }}" @if ($details !== '') x-data="{ open: false }" @endif>
<span class="relative inline-flex shrink-0 self-start">
<x-ui.avatar :src="$actor['avatar'] ?? null" :alt="$actor['name'] ?? ''" :fallback="$actor['initials'] ?? '?'" size="sm" />
<span class="absolute -bottom-1 -end-1 inline-flex size-4 items-center justify-center rounded-full ring-2 ring-card {{ $glyphWrap[$tone] }}" aria-hidden="true">
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">{!! $glyphs[$tone] !!}</svg>
</span>
</span>
<div class="min-w-0 flex-1">
{{-- The sentence centres on the 32px avatar while it fits one line. --}}
<div class="flex min-h-8 items-center justify-between gap-3">
<p class="min-w-0 break-words text-sm leading-5 text-foreground">
<span class="font-medium">{{ $actor['name'] ?? __('System') }}</span>
<span class="text-muted-foreground">{{ $event['action'] ?? __('updated') }}</span>
@if (filled($event['subject'] ?? null))
@if (filled($event['href'] ?? null))
<a href="{{ $event['href'] }}" class="font-medium underline-offset-4 hover:underline">{{ $event['subject'] }}</a>
@else
<span class="font-medium">{{ $event['subject'] }}</span>
@endif
@endif
</p>
@if (filled($event['at'] ?? null))
<span class="shrink-0 text-xs text-muted-foreground"><x-ui.relative-time :datetime="$event['at']" /></span>
@endif
</div>
@if ($changes !== [])
<dl data-slot="audit-log-changes" class="mt-1 flex flex-col gap-1 text-xs">
@foreach ($changes as $change)
<div class="flex min-w-0 flex-wrap items-center gap-2">
<dt class="shrink-0 text-muted-foreground">{{ $change[0] ?? '' }}</dt>
<dd class="flex min-w-0 items-center gap-2 font-mono">
@if (($change[1] ?? '') !== '')
<span class="truncate text-muted-foreground line-through">{{ $change[1] }}</span>
<svg class="size-3 shrink-0 text-muted-foreground rtl:-scale-x-100" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
@endif
<span class="truncate text-foreground">{{ $change[2] ?? '' }}</span>
</dd>
</div>
@endforeach
</dl>
@endif
{{-- Metadata reads like the changes: a muted key and a mono value,
one pair per chip of space, then the channel and address. --}}
@if ($metadata !== [] || filled($event['via'] ?? null) || filled($event['ip'] ?? null))
<dl data-slot="audit-log-meta" class="mt-2 flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1 text-xs">
@foreach ($metadata as $key => $value)
<div class="flex min-w-0 items-center gap-2">
<dt class="shrink-0 text-muted-foreground">{{ $key }}</dt>
<dd class="truncate font-mono text-foreground">{{ is_scalar($value) ? $value : __('Changed') }}</dd>
</div>
@endforeach
@if (filled($event['via'] ?? null))
<div class="flex min-w-0 items-center gap-2">
<dt class="shrink-0 text-muted-foreground">{{ __('via') }}</dt>
<dd class="truncate text-foreground">{{ $event['via'] }}</dd>
</div>
@endif
@if (filled($event['ip'] ?? null))
<div class="flex min-w-0 items-center gap-2">
<dt class="shrink-0 text-muted-foreground">{{ __('from') }}</dt>
<dd class="truncate font-mono text-foreground">{{ $event['ip'] }}</dd>
</div>
@endif
</dl>
@endif
@if ($details !== '')
<button type="button" @click="open = !open" :aria-expanded="open ? 'true' : 'false'" aria-controls="{{ $detailsId }}" class="mt-2 inline-flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:rounded-sm focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none">
<svg class="size-3 transition-transform motion-reduce:transition-none" :class="open && 'rotate-90'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
<span x-text="open ? '{{ __('Hide details') }}' : '{{ __('Show details') }}'">{{ __('Show details') }}</span>
</button>
<pre id="{{ $detailsId }}" x-show="open" x-cloak class="mt-2 overflow-x-auto rounded-sm bg-muted p-3 font-mono text-xs text-foreground">{{ $details }}</pre>
@endif
</div>
</li>
@endforeach
</ol>
</div>
@endforeach
</div>
@endif
</section>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.2.4 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0