Skip to content
Brok UI

Loading…

No results

Audit Log

Open source

A bounded semantic audit feed that separates actor, action, subject, time, and escaped metadata from already-authorized view data.

Version
v1.2.4
Stability
stable
License
MIT
Related
User Identity
Relative Time
Avatar

Preview

Today

  1. AS Avery Stone

    Avery Stone changed Invoice INV-1042

    status
    unpaid paid
    paid_at
    2026-10-02 22:56
    via
    Dashboard
  2. PN Priya Nair

    Priya Nair created Customer Acme B.V.

    plan
    Pro
    seats
    12

Yesterday

  1. 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

  1. AS Avery Stone

    Avery Stone deleted API key sk_live_…9f2c

Variant
previews.components.audit-log.default.blade.php 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>
Card Current
Plain Current

Installation

terminal
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.

  • blade resources/views/components/ui/audit-log.blade.php
Registry dependencies
avatar relative-time
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

audit-log.md
# 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.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

audit-log audit-log-changes audit-log-day audit-log-event audit-log-meta

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

Scannable data display

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
audit-log
Theming hooks
Uses semantic border and text tokens.

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
native
Focus
native
  • Uses a labelled section and an ordered list.
  • Event meaning is present in text.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/audit-log.blade.php Blade
@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