Skip to content

Last updated:

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

1. Controller ​

Admin controllers return Inertia responses (not JSON). They redirect on writes — flash messages travel through Inertia's success/error session flash.

php
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 ConfirmDialog modal + vue-sonner toast

See memory: admin-crud-ux-conventions.

2. Routes ​

php
// 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/.

vue
<!-- 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:

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

php
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-sonner toast
  • [ ] 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

CPR - Clinical Patient Records