Build a Backend Table View
Kompass drives every page from registered Eloquent models, but it doesn't ship a generic CRUD UI for your models. When editors need to manage custom records — like the TeamMember model from Use Relationship Blocks — you build a small Livewire table component and wire it into the admin.
This is exactly how Kompass builds its own Categories, Pages, and Posts screens. This guide mirrors that pattern so your screen feels native: a searchable, sortable table with a slide-in edit panel and a delete confirmation, all from Kompass Blade components.
Prerequisite
This guide assumes the TeamMember model and migration from the Team Members worked example. Any Eloquent model works — swap the names as you go.
Overview
A backend table view is a single full-page Livewire component:
- The component class holds the search + sort state, the form fields, and the
save/update/deleteactions. Open/close state lives in three booleans —$FormAdd,$FormEdit,$FormDelete. - The Blade view renders the table plus an offcanvas edit panel and a delete modal, and renders inside the Kompass admin layout.
- A route mounts the component under
/admin, reusing the same role middleware as the rest of the backend, and a nav item links to it.
Step 1: Create the Component
php artisan make:livewire Admin/TeamMembersThis generates app/Livewire/Admin/TeamMembers.php and its view at resources/views/livewire/admin/team-members.blade.php.
Step 2: Build the Component Class
The class follows the Kompass table convention: search and sort state is persisted to the URL with #[Url], the visible columns come from headerTable() / dataTable(), and selectItem() opens the right panel for a row.
namespace App\Livewire\Admin;
use App\Models\TeamMember;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\WithPagination;
class TeamMembers extends Component
{
use WithPagination, WithFileUploads;
#[Url(except: '')]
public $search;
#[Url(except: 'order')]
public $orderBy = 'order';
#[Url(except: true)]
public $orderAsc = true;
public $perPage = 20;
public $headers;
public $data;
// Form fields
public $name;
public $role;
public $email;
public $phone;
public $description;
public $photo; // path string (existing) or temporary upload (new)
public $is_active = true;
public $order = 0;
#[Locked]
public $selectedItem;
public $FormAdd = false;
public $FormEdit = false;
public $FormDelete = false;
protected function rules(): array
{
return [
'name' => 'required|string|min:2',
'role' => 'nullable|string|max:255',
'email' => 'nullable|email',
'phone' => 'nullable|string|max:50',
'description' => 'nullable|string',
'photo' => 'nullable',
'is_active' => 'boolean',
'order' => 'nullable|integer',
];
}
// Columns: headers render in <thead>, data maps to the row cells.
protected function headerTable(): array
{
return ['name', 'role', 'is_active', ''];
}
protected function dataTable(): array
{
return ['name', 'role', 'is_active'];
}
public function mount(): void
{
$this->headers = $this->headerTable();
$this->data = $this->dataTable();
}
public function sortBy($field): void
{
if ($this->orderBy === $field) {
$this->orderAsc = ! $this->orderAsc;
} else {
$this->orderBy = $field;
$this->orderAsc = true;
}
}
public function selectItem($itemId, $action): void
{
$this->selectedItem = $itemId;
if ($action === 'add') {
$this->FormAdd = true;
$this->resetFields();
}
if ($action === 'edit') {
$this->FormEdit = true;
$this->loadMember($itemId);
}
if ($action === 'delete') {
$this->FormDelete = true;
}
}
private function resetFields(): void
{
$this->reset('name', 'role', 'email', 'phone', 'description', 'photo', 'order');
$this->is_active = true;
}
private function loadMember($id): void
{
$member = TeamMember::findOrFail($id);
$this->selectedItem = $member->id;
$this->name = $member->name;
$this->role = $member->role;
$this->email = $member->email;
$this->phone = $member->phone;
$this->description = $member->description;
$this->photo = $member->photo;
$this->is_active = $member->is_active;
$this->order = $member->order;
}
public function deleteImage(): void
{
$this->photo = null;
}
public function save(): void
{
$this->validate();
TeamMember::create($this->payload());
$this->FormAdd = false;
$this->resetFields();
}
public function update(): void
{
$this->validate();
TeamMember::findOrFail($this->selectedItem)->update($this->payload());
$this->FormEdit = false;
$this->resetFields();
}
public function delete(): void
{
TeamMember::find($this->selectedItem)?->delete();
$this->FormDelete = false;
$this->resetFields();
}
// Persist a freshly uploaded photo, otherwise keep the existing path.
private function payload(): array
{
$photo = is_object($this->photo)
? $this->photo->store('team', 'public')
: $this->photo;
return [
'name' => $this->name,
'role' => $this->role,
'email' => $this->email,
'phone' => $this->phone,
'description' => $this->description,
'photo' => $photo,
'is_active' => $this->is_active,
'order' => $this->order,
];
}
public function render()
{
$members = TeamMember::query()
->when($this->search, fn ($query) => $query
->where('name', 'like', '%'.$this->search.'%')
->orWhere('role', 'like', '%'.$this->search.'%'))
->orderBy($this->orderBy, $this->orderAsc ? 'ASC' : 'DESC')
->paginate($this->perPage);
return view('livewire.admin.team-members', compact('members'))
->layout('kompass::admin.layouts.app');
}
}Photo storage
<x-kompass::upload-image> works with a file path string (it renders temporaryUrl() while uploading and url($path) once stored) — that's what store('team', 'public') returns above. The Relationship worked example instead stored a Media Library file id and rendered it with <x-image :id>. Both are valid — match whichever your photo column holds.
Step 3: Build the Table View
The view drives the offcanvas panels with @entangle (the booleans from the class), uses the table-search and table-footer helpers, and confirms deletes with <x-kompass::modal> — which is hard-wired to the $FormDelete property and calls your delete() method.
<div>
{{-- Add panel --}}
<div x-cloak x-data="{ open: @entangle('FormAdd') }">
<x-kompass::offcanvas :w="'w-2/6'">
<x-slot name="body">
@include('livewire.admin.partials.team-form', ['submit' => 'save', 'label' => __('Add Member')])
</x-slot>
</x-kompass::offcanvas>
</div>
{{-- Edit panel --}}
<div x-cloak x-data="{ open: @entangle('FormEdit') }">
<x-kompass::offcanvas :w="'w-2/6'">
<x-slot name="body">
@include('livewire.admin.partials.team-form', ['submit' => 'update', 'label' => __('Update Member')])
</x-slot>
</x-kompass::offcanvas>
</div>
{{-- Delete confirmation (bound to $FormDelete → delete()) --}}
<x-kompass::modal data="FormDelete" />
<div class="flex flex-col">
<div class="flex items-end justify-between gap-4 flex-wrap p-5 bg-base-100 border border-base-300 rounded-t-xl">
<div>
<h6 class="font-semibold text-lg">{{ __('Team Members') }}</h6>
<p class="text-xs opacity-60">{{ __('Manage who appears in the team section') }}</p>
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<div class="w-full sm:w-64">
<x-kompass::table-search wire:model.live="search" placeholder="{{ __('Search by name or role...') }}" />
</div>
<button class="btn btn-primary" wire:click="selectItem(null, 'add')">
<x-tabler-square-plus stroke-width="1.5" />{{ __('New member') }}
</button>
</div>
</div>
<div class="overflow-hidden rounded-b-xl border border-t-0 border-base-300 bg-base-100">
@if ($members->count())
<table class="min-w-full divide-y divide-base-200 [&_tbody_tr:hover_td]:bg-base-200/50">
<thead class="bg-base-200">
<tr>
@foreach ($headers as $value)
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-base-content/70 uppercase">
@if (in_array($value, ['name', 'role']))
<button wire:click="sortBy('{{ $value }}')" class="flex items-center gap-1 uppercase font-medium">
{{ __($value) }}
@if ($orderBy === $value)
<x-tabler-chevron-{{ $orderAsc ? 'up' : 'down' }} class="w-4 h-4" />
@endif
</button>
@else
{{ __($value) }}
@endif
</th>
@endforeach
</tr>
</thead>
<tbody class="bg-base-100 divide-y divide-base-200">
@foreach ($members as $member)
<tr wire:key="member-{{ $member->id }}">
@foreach ($data as $value)
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-base-content bg-base-100">
@if ($value === 'is_active')
<span class="badge badge-{{ $member->is_active ? 'success' : 'neutral' }}">
{{ $member->is_active ? __('Active') : __('Hidden') }}
</span>
@else
{{ $member->$value }}
@endif
</td>
@endforeach
<td class="px-4 py-3 whitespace-nowrap bg-base-100">
<div class="flex justify-end items-center gap-1">
<span wire:click="selectItem({{ $member->id }}, 'edit')" class="flex justify-center cursor-pointer">
<x-tabler-edit class="stroke-blue-500" />
</span>
<span wire:click="selectItem({{ $member->id }}, 'delete')" class="flex justify-center cursor-pointer">
<x-tabler-trash class="stroke-red-500" />
</span>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
<x-kompass::table-footer :paginator="$members" />
@else
<div class="min-h-[60vh] flex flex-col items-center justify-center">
<x-tabler-users-group stroke-width="1.5" class="w-16 h-16 mb-2 text-brand-500" />
<div class="text-lg font-semibold">{{ __('No team members yet') }}</div>
</div>
@endif
</div>
</div>
</div>Step 4: The Shared Form Partial
Both panels reuse the same fields. Create resources/views/livewire/admin/partials/team-form.blade.php. The $submit variable points each panel at either save or update:
<div class="space-y-4">
<x-kompass::section-title>
<x-slot name="title">{{ $label }}</x-slot>
<x-slot name="description">{{ __("Manage the team member's details.") }}</x-slot>
</x-kompass::section-title>
<x-kompass::upload-image
wire:model="photo"
:image="$photo"
deleteAction="deleteImage"
label="{{ __('Photo') }}"
/>
<x-kompass::form.input label="{{ __('Name') }}" type="text" name="name" wire:model="name" />
<x-kompass::input-error for="name" />
<x-kompass::form.input label="{{ __('Role') }}" type="text" name="role" wire:model="role" />
<x-kompass::form.input label="{{ __('Email') }}" type="email" name="email" wire:model="email" />
<x-kompass::form.input label="{{ __('Phone') }}" type="text" name="phone" wire:model="phone" />
<x-kompass::form.textarea wire:model="description" id="description" name="description"
label="{{ __('Description') }}" class="block w-full h-24" />
<x-kompass::form.switch wire:model="is_active" label="{{ __('Visible on the website') }}" />
<button wire:click="{{ $submit }}" class="btn btn-primary mt-6">
<x-tabler-device-floppy class="icon-lg" wire:loading.remove wire:target="{{ $submit }}" />
<span wire:loading.remove wire:target="{{ $submit }}">{{ $label }}</span>
<span wire:loading wire:target="{{ $submit }}">{{ __('Saving...') }}</span>
</button>
</div>Step 5: Add the Route
Mount the component inside the existing Kompass admin group so it inherits the web, auth, and role middleware and lives under /admin. In your app's routes/web.php:
use App\Livewire\Admin\TeamMembers;
Route::group([
'middleware' => ['web', 'auth', 'role:admin|manager|editor'],
'prefix' => 'admin',
'as' => 'admin.',
], function () {
Route::get('team', TeamMembers::class)->name('team');
});Match the backend's guard
Kompass guards /admin/* with role:admin|manager|editor (and tighter roles for sensitive screens). A table that creates and deletes records must sit behind the same role middleware — never expose it publicly.
Step 6: Link It in the Sidebar
You don't edit the layout to add a sidebar link — the admin sidebar has a built-in, editable menu called Admin Sidebar (admin_aside). It renders right inside the backend navigation, so you add your link through the interface with the Menu Builder:
- Go to Menu Builder and open the built-in Admin Sidebar menu.
- Click Builder and Add an item:
- Name:
Team Members - Route / URL:
/admin/team(or the route nameadmin.team) - Icon:
tabler-users-group
- Name:
- Save. The link appears in the admin sidebar immediately — no code changes.
Where the built-in items live
The fixed sidebar entries (Posts, Pages, Media library, Settings…) are defined in the package admin layout, each guarded by @role(...). The Admin Sidebar (admin_aside) menu is the supported, no-code slot for your own links — that's why you add the Team Members link there rather than touching a Blade file.
That's it. Editors now have a native backend table to manage team members — add, search, sort, edit, and delete — reachable from the sidebar, and every change flows straight into the Relationship block on the frontend.