Approval Workflow
A server-driven approval surface with explicit states, allowlisted actions, per-action comment rules, idempotency and record-version hooks, and an immutable history slot.
Preview
Approval
-
Submitted
@php
$actions = [
['key' => 'approve', 'label' => __('Approve')],
['key' => 'reject', 'label' => __('Reject'), 'destructive' => true, 'commentRequired' => true, 'commentPlaceholder' => __('Explain why this request is rejected.')],
];
@endphp
<x-ui.approval-workflow state="pending" :actions="$actions" action-url="#" idempotency-key="preview-intent" record-version="7">
{{ __('Review request REQ-1042 before you make a decision.') }}
<x-slot:history>
<x-ui.status-history :events="[['state' => 'completed', 'label' => __('Submitted'), 'at' => now()->subDay()]]" />
</x-slot:history>
</x-ui.approval-workflow>
Installation
php artisan ui:add approval-workflow
Registry contract
php artisan ui:add approval-workflow
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/approval-workflow.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: Approval Workflow (`approval-workflow`)
A server-driven approval surface with explicit states, allowlisted actions, per-action comment rules, idempotency and record-version hooks, and an immutable history slot.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add approval-workflow
```
## Usage
```blade
@php
$actions = [
['key' => 'approve', 'label' => __('Approve')],
['key' => 'reject', 'label' => __('Reject'), 'destructive' => true, 'commentRequired' => true, 'commentPlaceholder' => __('Explain why this request is rejected.')],
];
@endphp
<x-ui.approval-workflow state="pending" :actions="$actions" action-url="#" idempotency-key="preview-intent" record-version="7">
{{ __('Review request REQ-1042 before you make a decision.') }}
<x-slot:history>
<x-ui.status-history :events="[['state' => 'completed', 'label' => __('Submitted'), 'at' => now()->subDay()]]" />
</x-slot:history>
</x-ui.approval-workflow>
```
## Props
- `state` (pending|approved|rejected|cancelled, default `pending`) — Server-owned workflow state.
- `actions` (list<ApprovalAction>, default `[]`) — Up to eight authorized actions: key, label, destructive (reject/cancel by default), primary (approve/accept by default) and commentRequired. The pressed button posts its key as `decision`.
- `actionUrl` (url|null, default `null`) — Application workflow endpoint.
- `idempotencyKey` (string|null, default `null`) — Key that makes the decision submission idempotent; the server pairs it with the posted decision.
- `recordVersion` (string|null, default `null`) — Optimistic concurrency value checked by the server.
- `commentLabel` (string, default `Comment`) — Localized comment label.
- `historyLabel` (string, default `Approval history`) — Accessible history label.
- `error` (string|null, default `null`) — Server error message.
## Use when
- Use when self-contained content objects benefit from a recognizable card-like presentation with local actions or metadata.
- Present actions and state for an approval workflow that the application owns.
## Avoid when
- Do not use card widgets where a simpler list, table, or plain text block would make comparison faster.
- Do not use this component as a workflow database or authorization engine. Use audit-log to display the resulting history once decided.
## Anti-patterns
- Using cards for dense cross-item comparison
## Rules
- Use the `<brok:approval-workflow>` tag (or `<x-ui.approval-workflow>`) 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/approval-workflow
- Registry JSON (files, props, contract): https://brokui.dev/r/open/approval-workflow.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
<x-ui.approval-workflow state="pending" error="{{ __('This request changed. Reload it before you submit another decision.') }}" />
Long Content
<div class="max-w-sm">
<x-ui.approval-workflow state="pending" :actions="[['key' => 'request_changes', 'label' => __('Request changes with a deliberately long translated action label'), 'commentRequired' => true]]">
{{ __('A deliberately long example that verifies wrapping, overflow, and content expansion without clipping important information.') }}
</x-ui.approval-workflow>
</div>
<div class="grid gap-4 md:grid-cols-3">
<x-ui.approval-workflow state="approved"><p class="text-sm">{{ __('This request was approved.') }}</p></x-ui.approval-workflow>
<x-ui.approval-workflow state="rejected"><p class="text-sm">{{ __('This request was rejected.') }}</p></x-ui.approval-workflow>
<x-ui.approval-workflow state="cancelled"><p class="text-sm">{{ __('This request was cancelled.') }}</p></x-ui.approval-workflow>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| state | pending | approved | rejected | cancelled | pending | Server-owned workflow state. |
| actions | list<ApprovalAction> | [] | Up to eight authorized actions: key, label, destructive (reject/cancel by default), primary (approve/accept by default) and commentRequired. The pressed button posts its key as `decision`. |
| actionUrl | url | null | null | Application workflow endpoint. |
| idempotencyKey | string | null | null | Key that makes the decision submission idempotent; the server pairs it with the posted decision. |
| recordVersion | string | null | null | Optimistic concurrency value checked by the server. |
| commentLabel | string | Comment | Localized comment label. |
| historyLabel | string | Approval history | Accessible history label. |
| error | string | null | null | Server error message. |
Slots
default— Immutable approval context.history— Immutable history supplied by the application.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Renders actions only while pending.
- One ordinary server form: a single comment field and one submit button per decision (name=decision). Choosing an action with commentRequired makes the comment required before the browser submits.
- Declares registry capability flags: a11y, authoredStateFixtures, responsive, rtl, darkMode, localized.
Guidance
Present a self-contained content object with local context.
Use when
- Use when self-contained content objects benefit from a recognizable card-like presentation with local actions or metadata.
- Present actions and state for an approval workflow that the application owns.
Avoid when
- Do not use card widgets where a simpler list, table, or plain text block would make comparison faster.
- Do not use this component as a workflow database or authorization engine. Use audit-log to display the resulting history once decided.
Use instead
- List for compact scanning
- Table for exact comparison
Anti-patterns
- Using cards for dense cross-item comparison
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- State and actions use visible text.
- Required comments use native required semantics and Laravel validation errors.
- 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="approval-workflow-{{ $record->id }}">
@php
$actions = [
['key' => 'approve', 'label' => __('Approve')],
['key' => 'reject', 'label' => __('Reject'), 'destructive' => true, 'commentRequired' => true, 'commentPlaceholder' => __('Explain why this request is rejected.')],
];
@endphp
<x-ui.approval-workflow state="pending" :actions="$actions" action-url="#" idempotency-key="preview-intent" record-version="7">
{{ __('Review request REQ-1042 before you make a decision.') }}
<x-slot:history>
<x-ui.status-history :events="[['state' => 'completed', 'label' => __('Submitted'), 'at' => now()->subDay()]]" />
</x-slot:history>
</x-ui.approval-workflow>
</div>
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'state' => 'pending',
'actions' => [],
'actionUrl' => null,
'idempotencyKey' => null,
'recordVersion' => null,
'commentLabel' => 'Comment',
'historyLabel' => 'Approval history',
'error' => null,
])
@php
$states = [
'pending' => ['variant' => 'warning', 'label' => 'Pending'],
'approved' => ['variant' => 'success', 'label' => 'Approved'],
'rejected' => ['variant' => 'destructive', 'label' => 'Rejected'],
'cancelled' => ['variant' => 'secondary', 'label' => 'Cancelled'],
];
$resolvedState = array_key_exists($state, $states) ? $state : 'pending';
$actions = array_slice(array_values(array_filter(
(array) $actions,
fn ($action): bool => is_array($action) && filled($action['key'] ?? null),
)), 0, 8);
@endphp
<x-ui.card data-slot="approval-workflow" data-state="{{ $resolvedState }}" {{ $attributes }}>
<x-ui.card.header class="flex-row flex-wrap items-center justify-between gap-3">
<x-ui.card.title>{{ __('Approval') }}</x-ui.card.title>
<x-ui.badge :variant="$states[$resolvedState]['variant']">{{ __($states[$resolvedState]['label']) }}</x-ui.badge>
</x-ui.card.header>
<x-ui.card.content class="space-y-5">
@if (filled($error))
<x-ui.alert variant="destructive" role="alert">{{ $error }}</x-ui.alert>
@endif
@if (! $slot->isEmpty())
{{-- Wrapped so plain text in the slot still takes the stack spacing. --}}
<div data-slot="approval-workflow-context" class="text-sm text-muted-foreground">{{ $slot }}</div>
@endif
@if ($resolvedState === 'pending' && $actions !== [])
@php
// One comment, one form: the pressed button names the decision.
// Actions that need a comment make the field required the moment
// they are chosen, so the browser blocks an empty rejection.
$requiring = array_values(array_map(
static fn (array $a): string => preg_replace('/[^a-z0-9_-]/i', '', (string) $a['key']),
array_filter($actions, static fn (array $a): bool => (bool) ($a['commentRequired'] ?? false)),
));
$placeholder = $requiring !== []
? __('Add a note for the requester (required to :actions).', ['actions' => mb_strtolower(implode(' / ', array_map(static fn (array $a) => $a['label'] ?? ucfirst((string) $a['key']), array_filter($actions, static fn (array $a): bool => (bool) ($a['commentRequired'] ?? false)))))])
: __('Add a note for the requester.');
@endphp
<x-ui.form
:action="$actionUrl"
method="POST"
:idempotency-key="$idempotencyKey"
:record-version="$recordVersion"
data-slot="approval-workflow-actions"
class="space-y-4"
x-data="{
decision: null,
requiring: {{ \Illuminate\Support\Js::from($requiring) }},
// The pressed decision also names the idempotency key
// (base:decision), so each decision is its own intent.
choose(key, intent) {
this.decision = key;
const input = this.$root.querySelector('input[name=idempotency_key]');
if (input && intent) input.value = intent;
},
}"
>
<x-ui.field name="comment" :label="$commentLabel">
<x-ui.textarea
name="comment"
rows="3"
:placeholder="$placeholder"
x-bind:required="requiring.includes(decision)"
x-bind:aria-required="requiring.includes(decision) ? 'true' : 'false'"
/>
</x-ui.field>
<div class="flex flex-wrap items-center gap-2">
@foreach ($actions as $action)
@php
$key = preg_replace('/[^a-z0-9_-]/i', '', (string) $action['key']);
$destructive = (bool) ($action['destructive'] ?? in_array($key, ['reject', 'cancel'], true));
$primary = (bool) ($action['primary'] ?? in_array($key, ['approve', 'accept'], true));
@endphp
@if ($key !== '')
<x-ui.button
type="submit"
name="decision"
value="{{ $key }}"
:variant="$destructive ? 'destructive' : ($primary ? 'default' : 'outline')"
class="h-auto min-h-10 py-2 [&_[data-slot=button-label]]:whitespace-normal"
:data-idempotency-key="$idempotencyKey ? $idempotencyKey.':'.$key : null"
@click="choose({{ \Illuminate\Support\Js::from($key) }}, $el.dataset.idempotencyKey)"
>
{{ $action['label'] ?? ucfirst($key) }}
</x-ui.button>
@endif
@endforeach
</div>
</x-ui.form>
@endif
@isset($history)
<section aria-label="{{ __($historyLabel) }}" class="border-t border-border pt-4">
{{ $history }}
</section>
@endisset
</x-ui.card.content>
</x-ui.card>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.2 - 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