Rendering Content & the Kanji Edge Case

Website Rebuild with Laravel

Chapter 7 · Rendering Content & the Kanji Edge Case

Two chapters have already quietly dismantled both of the reasons kanji pages were ever kept separate from the rest of this site's content. This chapter folds them into the unified tree — and finds a genuinely different kind of wrinkle than Django Rebuild 7 hit, living at a completely different layer of the stack.

Both Original Constraints, Already Resolved

Kanji pages were kept separate for two real reasons: a fixed, three-level content hierarchy with no natural slot for a flat grid of individual characters, and no self-contained-fragment convention, since a kanji page is a complete standalone document with its own inline stroke-animation markup, not a plain fragment. Chapter 2's own arbitrary-depth Page model removes the first constraint entirely — japanese/kanji/水 fits exactly as naturally as any deep course path. Chapter 4's own {!! $page->body !!} removes the second — a kanji page's stroke-animation markup can live in body and render through the identical mechanism every other page already uses.

The Resolution: One Row Per Kanji Character

$kanjiParent = Page::where('full_path', 'japanese/kanji')->firstOrFail(); Page::create([ 'title' => '水 (mizu) — water', 'slug' => '水', 'parent_id' => $kanjiParent->id, 'body' => '<!-- stroke-animation markup and inline CSS -->', ]); // full_path becomes: japanese/kanji/水

A Genuinely Different Kind of Wrinkle: Storage, Not Validation

Django's own kanji wrinkle was an application-level problem — SlugField's built-in validator rejecting the character outright, before the row could ever save. Laravel's slug column is just a plain string with no such built-in validator, so that exact failure mode doesn't exist here at all. The genuine risk lives one layer deeper: whether the database itself can correctly store a multi-byte character in the first place.

// config/database.php — Laravel's own default since 5.4+ 'mysql' => [ // ... 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', ],
Usually already fine — confirm it, don't just assume it
MySQL's older, plain utf8 charset is a limited, 3-byte-max subset of real UTF-8 — genuinely insufficient for some characters, though most common kanji (this one included) happen to fit within its limits anyway. utf8mb4 is the real, complete UTF-8 encoding, and it's already Laravel's own sensible default on a fresh install. The right habit isn't assuming this is a problem — it's confirming config/database.php actually still says utf8mb4, especially on any project connecting to an older or hand-configured MySQL instance where that default might have been overridden.

The Real, Active Risk: Multi-Byte-Unsafe String Functions

// DANGEROUS — substr() operates on raw BYTES, not real characters $truncated = substr($title, 0, 10); // can slice a multi-byte kanji character in half // SAFE — mb_substr() respects real UTF-8 character boundaries $truncated = mb_substr($title, 0, 10);
A kanji character is multiple bytes, and PHP's plain string functions don't know that
A character like is encoded as three real bytes in UTF-8, but plain PHP functions like substr(), strlen(), and strtoupper() operate on raw bytes, with no awareness that some "characters" actually span several of them. Slicing at exactly the wrong byte offset produces broken, invalid UTF-8 — a real, well-known PHP trap for any future code that ever needs to truncate or manipulate a kanji title programmatically. The mb_-prefixed functions (mb_substr, mb_strlen, and the rest) exist specifically to operate on actual characters instead.
The central fact this chapter is built on
Django's kanji wrinkle lived at the application's own input-validation layer — a field type actively refusing the character. Laravel's genuine wrinkle, where it exists at all, lives one layer deeper: the database's own character-set configuration, and any hand-written PHP string manipulation that isn't multi-byte-aware. Same underlying fact — this isn't plain ASCII — surfacing as a real risk at a completely different layer of each framework's own stack.

Stroke-Animation Assets Under Vite

The stroke-order library's own CSS/font/data files move into resources/, processed through Chapter 5's own Vite pipeline just like every other asset — still genuinely self-hosted, still never loaded from a third-party CDN. The one non-negotiable property of the original kanji pages carries forward completely unchanged.

Hands-On Exercises

Exercise 1

Name kanji's two original constraints and explain why neither one still applies in this Laravel rebuild, tying each one back to a specific earlier chapter.

📄 View solution
Exercise 2

Explain why Laravel's own kanji-related wrinkle is genuinely different in kind from Django's SlugField/allow_unicode issue — what layer of the stack does each one actually live at?

📄 View solution
Exercise 3

Explain why substr($title, 0, 10) is a real risk for a kanji title while mb_substr($title, 0, 10) is not, referencing how multi-byte UTF-8 characters are actually encoded.

📄 View solution

Chapter 7 Quick Reference

  • Both original constraints already resolved — Chapter 2's arbitrary-depth model, Chapter 4's {!! !!} rendering
  • No SlugField-style validator in Laravel — the slug column is plain, unvalidated string storage
  • The real wrinkle lives at the database layerutf8mb4 vs. the older, limited utf8 charset; already Laravel's own sensible default, worth confirming rather than assuming
  • The real, active risk: plain PHP string functions (substr()) operate on bytes, not characters — mb_substr() and the other mb_ functions are the safe alternative
  • Different layer, same underlying fact — Django's wrinkle is application-level validation; Laravel's is database storage plus string-handling discipline
  • Stroke-animation assets — moved into Vite's own pipeline, still genuinely self-hosted
  • Next chapter: Dynamic Content & Forms