How-To: Add an Admin CRUD Page (Inertia) ​
CPR's admin panel runs on Inertia + Vue 3 inside the Laravel app. This guide is for screens that live under /admin/* — services, branches, users, payment methods, legacy-import tools.
Reference: the users admin module.
File layout ​
app/Http/Controllers/Admin/MyResourceController.php
app/Http/Requests/MyResource/Store…Request.php
app/Http/Requests/MyResource/Update…Request.php
app/Services/MyResource/AdminMyResourceService.php
resources/js/pages/admin/my-resources/
├── Index.vue
├── Create.vue
└── Edit.vue1. Controller ​
Admin controllers return Inertia responses (not JSON). They redirect on writes — flash messages travel through Inertia's success/error session flash.
namespace App\Http\Controllers\Admin;
class MyResourceController extends Controller
{
public function __construct(protected AdminMyResourceService $service) {}
public function index(Request $request): Response
{
return Inertia::render('admin/my-resources/Index',
$this->service->getIndexData([
'search' => $request->input('search', ''),
'sort' => $request->input('sort', 'name'),
'direction' => $request->input('direction', 'asc'),
])
);
}
public function create(): Response
{
return Inertia::render('admin/my-resources/Create',
$this->service->getCreateData()
);
}
public function store(StoreMyResourceRequest $request): RedirectResponse
{
$this->service->create($request->validated());
return redirect()
->route('admin.my-resources.index')
->with('success', 'Created successfully!');
}
public function edit(MyResource $myResource): Response
{
return Inertia::render('admin/my-resources/Edit',
$this->service->getEditData($myResource)
);
}
public function update(UpdateMyResourceRequest $request, MyResource $myResource): RedirectResponse
{
$this->service->update($myResource, $request->validated());
// Update stays on the edit page — don't redirect to index
return redirect()
->route('admin.my-resources.edit', $myResource)
->with('success', 'Updated successfully!');
}
public function destroy(MyResource $myResource): RedirectResponse
{
$this->service->delete($myResource);
return redirect()
->route('admin.my-resources.index')
->with('success', 'Deleted successfully!');
}
}CPR conventions
- Create → redirect to index (no confirmation modal)
- Update → redirect to edit (stays on the form)
- Delete → redirect to index + must have a
ConfirmDialogmodal +vue-sonnertoast
See memory: admin-crud-ux-conventions.
2. Routes ​
// routes/web.php — admin group
Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(function () {
Route::middleware('permission:my-resources.access')->group(function () {
Route::resource('my-resources', MyResourceController::class)
->parameters(['my-resources' => 'myResource']);
});
});3. Vue pages ​
Use the AdminLayout for all admin screens. Tables use UiTable/UiTableHeader from resources/js/components/ui/.
<!-- resources/js/pages/admin/my-resources/Index.vue -->
<script setup lang="ts">
import AdminLayout from '@/layouts/AdminLayout.vue'
import { Head, Link, router } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
import { route } from 'ziggy-js'
const props = defineProps<{
items: PaginatedResponse<MyResource>
filters: { search: string; sort: string; direction: 'asc' | 'desc' }
}>()
const search = ref(props.filters.search)
watch(search, debounce((value) => {
router.get(route('admin.my-resources.index'),
{ search: value, sort: props.filters.sort, direction: props.filters.direction },
{ preserveState: true, replace: true },
)
}, 300))
</script>
<template>
<AdminLayout title="My Resources">
<Head title="My Resources" />
<div class="flex justify-between mb-4">
<UiInput v-model="search" placeholder="Search…" />
<Link
v-if="$can('my-resources.create')"
:href="route('admin.my-resources.create')"
class="btn btn-primary"
>
Add
</Link>
</div>
<UiTable :rows="items.data" :pagination="items.meta">
<!-- columns… -->
</UiTable>
</AdminLayout>
</template>4. Delete with confirmation + toast ​
The expected UX for any destructive action:
<script setup lang="ts">
import { useToast } from 'vue-sonner'
const toast = useToast()
const showDelete = ref(false)
const target = ref<MyResource | null>(null)
function askDelete(item: MyResource) {
target.value = item
showDelete.value = true
}
function confirmDelete() {
if (!target.value) return
router.delete(route('admin.my-resources.destroy', target.value.id), {
preserveScroll: true,
onSuccess: () => toast.success('Deleted successfully'),
onError: () => toast.error('Failed to delete'),
onFinish: () => { showDelete.value = false; target.value = null },
})
}
</script>
<template>
<ConfirmDialog
v-model="showDelete"
title="Delete this resource?"
message="This action cannot be undone."
confirm-label="Delete"
variant="danger"
@confirm="confirmDelete"
/>
</template>5. Service ​
Services do the data fetching + writes. Keep the controller free of Eloquent calls.
class AdminMyResourceService
{
public function getIndexData(array $filters): array
{
return [
'items' => MyResource::query()
->when($filters['search'], fn ($q, $s) => $q->where('name', 'like', "%{$s}%"))
->orderBy($filters['sort'], $filters['direction'])
->paginate(20)
->withQueryString(),
'filters' => $filters,
];
}
public function create(array $data): MyResource
{
return MyResource::create($data);
}
public function update(MyResource $item, array $data): MyResource
{
$item->update($data);
return $item->fresh();
}
public function delete(MyResource $item): void
{
$item->delete();
}
}6. Permission ​
Declare my-resources.access, my-resources.create, my-resources.update, my-resources.delete in config/permissions.php. See Add a Permission.
7. Excel export (optional) ​
Drop an Export to Excel button into the index page — see Add an Excel Export.
Checklist ​
- [ ] Controller returns
Inertia::render(...)for reads,redirect()->with('success', ...)for writes - [ ] Create redirects to index; update redirects to edit
- [ ] Delete uses
ConfirmDialog+vue-sonnertoast - [ ] Pages live under
resources/js/pages/admin/<kebab-case>/ - [ ] Buttons guarded with
$can('my-resources.<verb>') - [ ] Search/sort filters preserved via
preserveState: true, replace: true - [ ] Resource macro uses kebab-case path; parameters explicit
