Admin CRUD Interface

Website Rebuild with Laravel

Chapter 10 · Admin CRUD Interface

Django Rebuild 10 got a working CRUD interface almost for free — a single admin.site.register(Page) call, thanks to Django's own admin app introspecting the model and generating list, edit, and delete views automatically. Laravel ships nothing like that. This is a real, honest philosophical difference, not just a missing convenience method — and it's where this chapter finally pays off Chapter 2's own deferred cost: recomputing full_path for an entire subtree when a page is reparented.

No Free Admin — A Real Philosophical Difference

Laravel's own official ecosystem does offer admin-panel packages — Nova (paid, first-party) and Filament (free, community-maintained) both generate CRUD interfaces from model definitions, genuinely comparable in spirit to Django's own built-in admin. But neither ships with the framework itself the way Django's admin app does out of INSTALLED_APPS — installing one is an explicit, separate decision, not a default. This chapter builds the interface by hand instead, matching Next.js Rebuild 10's own approach and, more importantly, because hand-building it is the only way to actually teach the reparenting logic this chapter needs to cover.

Listing Pages: The Same Searchable Flat List

Next.js Rebuild 10 solved its own parent-picker problem — choosing a new parent for a page at arbitrary depth — with a searchable flat list rather than a nested tree widget, for an explicit, scale-justified reason: at this site's realistic page count, a flat list with client-side filtering is simpler to build and just as fast to use as a tree view, and a tree view's own advantage (seeing hierarchy at a glance) matters less than being able to type a few letters and jump straight to a page. That reasoning carries over unchanged here.

// app/Http/Controllers/Admin/PageController.php public function edit(Page $page) { $allPages = Page::orderBy('full_path') ->where('id', '!=', $page->id) ->get(['id', 'full_path']); return view('admin.pages.edit', [ 'page' => $page, 'allPages' => $allPages, ]); }

The page itself is excluded from its own candidate-parent list — reparenting a page under itself, or under one of its own descendants, would corrupt the tree. A simple client-side <input> filters the <select> options by full_path substring, giving fast lookup without a full tree-rendering component.

Paying Off Chapter 2's Deferred Cost: movePage()

// app/Models/Page.php public function movePage(?Page $newParent): void { $this->parent_id = $newParent?->id; $this->full_path = $newParent ? $newParent->full_path . '/' . $this->slug : $this->slug; $this->save(); $this->updateDescendantPaths(); } protected function updateDescendantPaths(): void { foreach ($this->children as $child) { $child->full_path = $this->full_path . '/' . $child->slug; $child->save(); $child->updateDescendantPaths(); // recurse down the subtree } }
Explicit recursion, not an implicit cascade
Django Rebuild 10 paid this same cost through an implicit mechanism — overriding save() once, then re-triggering every descendant's own save() in breadth-first order, so each node recomputes its own full_path from its own parent without any code explicitly walking the tree. Laravel's Eloquent models don't have an equivalent built-in cascade for a computed field like this, so updateDescendantPaths() walks the subtree explicitly and recursively instead. Same real cost, same result, genuinely different mechanism — Django's cascade lives inside the model's own save() override; Laravel's lives in a dedicated method that's called on purpose.

Refusing, Not Cascading, on Delete

public function destroy(Page $page) { try { $page->delete(); } catch (\Illuminate\Database\QueryException $e) { return back()->withErrors([ 'delete' => "Can't delete a page that still has children — move or delete them first.", ]); } return redirect()->route('admin.pages.index'); }

Chapter 2's own restrictOnDelete() constraint means the database itself refuses to delete a page with existing children — the raw failure is a QueryException from a foreign-key violation. Catching it here and returning a plain, readable error message turns that low-level database refusal into an honest, expected UI outcome, rather than a raw stack trace reaching the admin. Deliberately no automatic cascading delete: reparenting or removing an entire subtree is left as a conscious, separate action.

Three Admin Interfaces, Compared

Next.jsDjangoLaravel
Free admin panelNone — built by handadmin.site.register(), generated automaticallyNone by default — Nova/Filament exist as separate packages
Reparent cascadeExplicit movePage() walkImplicit — re-triggered save() cascadeExplicit movePage() + recursive updateDescendantPaths()
Delete-with-childrenRefused, error surfaced in the UIRefused via on_delete=PROTECT, surfaced in the adminRefused via restrictOnDelete(), caught and surfaced
A tree view would scale differently
The flat searchable list works well at this site's realistic page count. A collection large enough to make scrolling a flat list impractical would eventually justify a real nested tree widget — a genuine future trade-off, not something this chapter treats as settled forever.

Hands-On Exercises

Exercise 1

Explain the real, honest philosophical difference between Django's free automatic admin and Laravel's total absence of one, and why this chapter builds the interface by hand rather than reaching for a package like Filament.

📄 View solution
Exercise 2

Explain how Page::movePage() recomputes full_path for the moved page and all of its descendants, and how this differs mechanically from Django Rebuild 10's own breadth-first save() re-trigger approach.

📄 View solution
Exercise 3

Explain why deleting a page with children fails with a friendly error instead of silently cascading, and which earlier chapter's decision this behavior traces back to.

📄 View solution

Chapter 10 Quick Reference

  • No free admin by default — Nova (paid) and Filament (free) exist as separate packages, not a framework default like Django's admin app
  • Searchable flat list — same parent-picker approach as Next.js Rebuild 10, for the same scale-justified reason
  • movePage() — updates the moved page's own full_path, then calls updateDescendantPaths()
  • updateDescendantPaths() — explicit recursive walk, Laravel's own answer to Django's implicit save()-cascade mechanism
  • Delete refusal — Chapter 2's restrictOnDelete() throws a QueryException, caught here and turned into a readable UI error
  • Next chapter: Deployment