Every SaaS admin panel needs the same screen: a list of users, and the ability to create, edit, and delete them. It sounds trivial until you count the real requirements: only admins may touch it, the list must be paginated, deletes must not orphan active API tokens, and an admin must never be able to delete their own account. I recently built this loop for a Laravel + React starter kit, and the interesting part was how little code it took once the conventions were right.
The backend: one resource route, five small methods
The route is a single line, wrapped in two layers of protection:
Route::middleware(['auth:sanctum', 'role:admin'])->group(function () {
Route::apiResource('users', UserController::class);
});
auth:sanctum keeps unauthenticated traffic out; the role:admin middleware (EnsureUserHasRole, which returns a plain 403 Forbidden JSON when the role doesn't match) keeps regular users out. Note that the role middleware is registered as the role alias and accepts the role name as a parameter — so role:admin reads like plain English in the routes file.
The controller itself is deliberately boring:
public function index(Request $request): AnonymousResourceCollection
{
$users = User::query()
->latest()
->paginate(15)
->withQueryString();
return UserResource::collection($users);
}
public function store(StoreUserRequest $request): JsonResponse
{
$user = User::create($request->validated());
return response()->json(['user' => new UserResource($user)], 201);
}
public function update(UpdateUserRequest $request, User $user): JsonResponse
{
$user->update($request->validated());
return response()->json(['user' => new UserResource($user)], 200);
}
Three deliberate decisions here:
-
Pagination is fixed at 15 per page,
latest()first. A paginated response throughUserResource::collectionkeeps the envelope consistent — the frontend parses one shape, once. -
The responses use consistent envelopes:
{ "user": { ... } }for single-user operations, a resource collection for the list. When the frontend needs the user's name, it's always in the same place regardless of which endpoint answered. - Validation lives in FormRequests, and the FormRequests carry the authorisation too:
// StoreUserRequest
public function authorize(): bool
{
return $this->user()?->isAdmin() ?? false;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'min:8'],
'role' => ['required', 'string', 'in:admin,user'],
];
}
UpdateUserRequest mirrors this, except every rule becomes sometimes — and the email uniqueness ignores the current user: Rule::unique('users', 'email')->ignore($this->route('user')). If you've ever had a user edit form fail validation because the user's own unchanged email "already exists", you've met this bug. It costs one chained call to prevent.
Yes, there is double gating: the route middleware checks role:admin, and the FormRequest's authorize() checks isAdmin() again. That is intentional, not redundant. The middleware protects the route; the FormRequest protects the operation. If someone re-registers the route without the middleware six months from now, the operation still refuses non-admins.
The delete method deserves its own paragraph
public function destroy(Request $request, User $user): JsonResponse
{
if ($request->user()->is($user)) {
return response()->json(['message' => 'You cannot delete your own account.'], 422);
}
$user->tokens()->delete();
$user->delete();
return response()->json(null, 204);
}
Two edge cases, handled explicitly:
- An admin cannot delete their own account. A 422 with a clear message beats silently logging yourself out of your own system.
-
The user's API tokens are revoked before the user row is deleted. With Sanctum personal-access tokens, deleting the user row without clearing
personal_access_tokensleaves valid tokens pointing at a ghost.$user->tokens()->delete()first, then$user->delete(). The response is 204 with no body, which is the honest status code for "it's gone now".
The frontend: a dumb table and a page that wires it
The UsersTable component takes exactly three props — users, onEdit, onDelete — and knows nothing about HTTP, auth, or routing. It renders name, email, a role pill (indigo for admin, grey for user), a formatted creation date, and two buttons. Presentation only.
The Users page owns the rest: fetching the paginated list through the single axios client (token attached by the request interceptor, 401 handled by the response interceptor), opening an edit dialog that reuses the same create form, and calling onDelete with a confirmation. The table never asks where its data came from, which is why I can drop the same component into any future admin screen without touching it.
That separation — dumb components, smart pages, one HTTP client — is the whole architecture philosophy. It has survived three client projects without a rewrite, which is more than I can say for most of my earlier ideas.
The lesson
CRUD is not about the five methods. It's about the envelope consistency ({ "user": ... } everywhere), the double gating (middleware + FormRequest authorisation), and the two delete edge cases that bite you at 2am if you skip them: self-deletion and orphaned tokens. Get those right and the screen writes itself.
This is the exact user-management loop from my Laravel + React SaaS Starter Kit — Sanctum auth, role middleware, the same FormRequests, the same table component. If you'd rather start from working code than a blank file: https://kamranofficial.gumroad.com/l/mhekoig ($19, one-time). Either way, steal the self-delete guard. Your future self will thank you.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.