Designing a Flexible URL & Content Model

Website Rebuild with Laravel

Chapter 2 · Designing a Flexible URL & Content Model

This is the third time this exact design question gets asked on this site — Website Rebuild with Next.js answered it with Prisma, Website Rebuild with Django answered it with the ORM, and both landed on the identical hybrid: a real adjacency list for structure, plus a stored materialized path for fast reads. That two independent frameworks reached the same answer wasn't a coincidence of either framework's own bias — it's a property of this specific site's own read-heavy, write-rare traffic. Watch Laravel arrive at the same place a third time, independently, for the same reason.

Where Does the Schema Actually Live? A Real Difference

Django's Page model declares its own fields directly in the class — title = models.CharField(...) — and the migration is auto-generated from that class. Prisma works the same way: schema.prisma declares the fields, and a migration is generated from the schema file. Laravel inverts this entirely.

The Migration: Hand-Written, and the Real Source of Truth

php artisan make:model Page -m // database/migrations/xxxx_create_pages_table.php Schema::create('pages', function (Blueprint $table) { $table->id(); $table->string('title'); $table->string('slug'); $table->foreignId('parent_id')->nullable()->constrained('pages')->restrictOnDelete(); $table->string('full_path')->unique(); $table->text('body')->nullable(); $table->timestamps(); });

The -m flag generates a migration alongside the model in one step. ->restrictOnDelete() is Laravel's own direct parallel to Django Rebuild 2's own on_delete=PROTECT decision — explicitly refusing a deletion while referenced rows still exist, made explicit here on purpose rather than left to the database's own implicit default behavior.

The central fact this chapter is built on
In Django and Prisma, the model/schema file is the real definition — read it, and you know every column. In Laravel, the migration is the real schema, hand-written, and the Eloquent model class barely mentions its own columns at all. Asking "what fields does this table actually have?" means reading a completely different file depending on the framework — a genuine, practical habit to adjust, not just a syntax difference.

The Eloquent Model: Two Relationships, Not One Field

// app/Models/Page.php class Page extends Model { protected $fillable = ['title', 'slug', 'parent_id', 'full_path', 'body']; public function parent() { return $this->belongsTo(Page::class, 'parent_id'); } public function children() { return $this->hasMany(Page::class, 'parent_id'); } }

Django's single ForeignKey('self', related_name='children') field declaration gave both directions — page.parent and page.children.all() — from one line. Eloquent needs two separate, explicitly written relationship methods to get the identical two-directional access: parent() reads upward, children() reads downward, and neither one implies the other automatically.

Recomputing full_path: A Model Event, Not an Overridden save()

protected static function booted() { static::saving(function (Page $page) { $page->full_path = $page->parent ? $page->parent->full_path . '/' . $page->slug : $page->slug; }); }

Django recomputes full_path by directly overriding save() — an imperative method replacement. Laravel reaches the identical practical result through a genuinely different mechanism: model events. static::saving(...) registers a closure to run automatically every time a Page is about to be saved, without touching save() itself at all. Eloquent fires a whole family of these (creating, created, saving, saved, deleting, and more) — an event-listener pattern, not a method override.

$fillable: mass-assignment protection
Only fields listed in $fillable can be set through Page::create([...]) or $page->fill([...]) — a genuinely Laravel-specific convention with no direct one-line equivalent in Django or Prisma, guarding against a real class of bug where unexpected form input silently overwrites a column nobody meant to expose.

Hands-On Exercises

Exercise 1

Explain the real difference in "where the schema lives" between Django's Page model, Prisma's schema.prisma, and Laravel's own migration-plus-Eloquent-model pair.

📄 View solution
Exercise 2

Explain why Page.php needs two separate relationship methods, parent() and children(), to get the same two-directional access Django's single ForeignKey('self', related_name='children') field provided from one declaration.

📄 View solution
Exercise 3

Explain why full_path is recomputed inside a static::saving() closure rather than by overriding a save() method directly, and what "model event" means in this context.

📄 View solution

Chapter 2 Quick Reference

  • The same hybrid, a third time — adjacency list plus materialized path, independently reached by Prisma, Django, and now Eloquent
  • The schema lives in the migration, not the Eloquent model class — a real, practical difference from Django and Prisma
  • php artisan make:model Page -m — generates a model and its migration together
  • ->restrictOnDelete() — Laravel's own explicit parallel to Django's on_delete=PROTECT
  • parent() / children() — two explicit relationship methods, replacing Django's single self-referencing field
  • Model events (static::saving()) — Eloquent's own mechanism for "run this before every save," genuinely different from overriding save()
  • $fillable — Laravel's own mass-assignment protection, with no direct Django/Prisma equivalent
  • Next chapter: Routing: Laravel's Own Mechanism for Arbitrary-Depth Routing