1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

laravel パンくずリスト(DBから動的生成)の新規導入

1
Last updated at Posted at 2025-11-19

こんにちは。
株式会社Kaienでエンジニアをしている窪田です。
先日、弊社プロジェクト内でパンくずリストを導入する機会がありました。

導入の際には、ルート定義ファイルへのベタ書きは避ける方法にすることを検討しました。
というのも、パンくず内には「親分類A」とその「子分類A」、「親分類B」とその「子分類B」が存在し、その掛け算でページが生成されるため、パンくずリストも同様の数(数千個)用意しなければならないのです。
パンくずの実際の表示例:親分類A>子分類A>親分類B>子分類B

そこで今回、Laravelのパッケージ diglactic/laravel-breadcrumbs を活用して、パンくずリストを動的に生成する仕組みを導入しました。

この記事では、親子関係を持つパンくずを、DBと再帰処理を使いどのように実現したか実際のコードと共にご紹介します。


この記事の対象読者

  • Laravelで中〜大規模なWebサービスを開発・運用している方
  • これからパンくずリストを導入する方
  • DB設計とアプリケーションロジックの連携に興味がある方
  • diglactic/laravel-breadcrumbs の具体的な活用例を知りたい方

結論:DBから動的にパンくずリストを作成することで、修正コストの低いパンくずリストが作成できました。

  • パンくずのデータソースをDBに集約し、slugをキーに動的に「親子マップ」を生成しました。
  • diglactic/laravel-breadcrumbs と再帰関数(クロージャ)を使い、将来発生するであろうカテゴリ追加・変更時の修正工数を減らすことでできました。

詳細解説

1. 予想された「パンくず」べた書きによる問題(アンチパターン)

今回、パンくずリストを新規導入するにあたり、何も考えずに routes/breadcrumbs.php にベタ書きで実装していると、下記のようにすべての「親分類A、B」「子分類A、B」がすべて記載されたパンくずのコードになります。

// routes/breadcrumbs.php (もしベタ書きしていた場合の想像図)
Breadcrumbs::for('search.categoryA', function ($trail, $slug) {
	$trail->parent('home');
	if ($slug === 'parent-a-area') {
		$trail->push('親分類Aの項目1', route('search.categoryA', 'parent-a-area'));
	} elseif ($slug === 'child-a-pref') {
		// 親カテゴリの項目をベタ書きで指定...
		$trail->parent('search.categoryA', 'parent-a-area');	
		$trail->push('子分類Aの項目1', route('search.categoryA', 'child-a-pref'));
	}
	// ...これが全ての親分類・子分類分、続く...
});

このコードの問題点としては下記です。

  • メンテナンス性の欠如: 新しい分類が追加されるたび、エンジニアがこのファイルを手動で修正・デプロイする必要が発生します。
  • ミスの誘発: 親カテゴリの指定ミスやURLの間違いが生じる可能性がある。

上記を回避するため、DB駆動での実装を選択しました。


2. 技術選定

diglactic/laravel-breadcrumbs を導入しました。

  • パンくずの定義を routes/breadcrumbs.php に完全に分離できる。
  • Breadcrumbs::for('ルート名', ...) のように、Laravelのルート名と連携して定義できる。
  • 導入実績が多くドキュメントも豊富であること。

3. 肝となるDB設計(テーブルやカラム名は仮名です。)

パンくずを動的に生成するには、「親子関係」と「表示名」「URL(スラッグ)」をDBで管理する必要があります。
私たちの要件は「親分類(1階層目)」 > 「子分類(2階層目)」という最大2階層の構造です。
これを実現するため、以下のようなテーブル構成(簡略版)を採用しました。

slug_mappings テーブル
URLスラッグと、それがどのデータに紐づくかを定義します。

id slug type linkable_type linkable_id
1 parent-a-slug 1 (親分類A) App\Models\Category 10
2 child-a-slug 1 (親分類A) App\Models\CategoryItem 101
3 parent-b-slug 2 (親分類B) App\Models\Category 20
4 child-b-slug 2 (親分類B) App\Models\CategoryItem 201

categories テーブル
大カテゴリ(親)の情報を持ちます。これが1階層目です。

id name
10 親分類Aの項目
20 親分類Bの項目

category_items テーブル
小カテゴリ(子)の情報を持ちます。これが2階層目です。
parent_category_code (仮) で親(categoriesテーブル)を参照します。

id name parent_category_code (仮)
101 子分類Aの項目 10
201 子分類Bの項目 20

この設計により、例えば child-a-slug というスラッグからパンくずの親子関係を辿る流れは以下のようになります。

  1. slug_mappings を参照し、slugが child-a-slug のレコード(ID: 2)を特定します。
  2. このレコードの linkable_id(101)と linkable_typeApp\Models\CategoryItem)から、category_items テーブルのID: 101(表示名は「子分類Aの項目」)を特定します。
  3. category_items のID: 101 が持つ parent_category_code(10)を参照し、親カテゴリが categories テーブルのID: 10 であることを特定します。
  4. 次に、この親カテゴリ(ID: 10)を参照している slug_mappings のレコード(ID: 1、linkable_id: 10、linkable_type: App\Models\Category)を逆引きすることで、親のパンくずのURLスラッグである parent-a-slug を動的に取得できるようにしました。

この「スラッグから始まり、子テーブルを経由し、親テーブルを参照し、最終的に親のスラッグを逆引きする」流れをDBから動的に引けるため、コードのベタ書きを回避できます。


4. 実装ハイライト:DB情報を「マップ化」して再帰処理

下記実装の詳細を示します。

ポイント1:最初に「マップ化」する

パンくずの階層をたどるたびにDBにクエリを発行すると効率が悪いです。
そこで、処理の冒頭で必要な情報をすべてDBから取得し、PHPの連想配列(マップ)に展開します。

コードは一部抜粋しテーブル名やカラム名を加工してます

use App\Models\SlugMapping;	

Breadcrumbs::for('job-list', function (BreadcrumbTrail $trail, string $categoryASlug, ?string $categoryBSlug = null) {
	
	$trail->parent('home'); // 1. まずはTOP

	// 2. 親分類Aの情報をDBから"一度に"取得 ---
	$categoryAMap = [];
	// (※説明を簡略化するため、ここでは親分類Aの情報のみ取得)
	$allCategoryASlugMappings = SlugMapping::where('type', 1)->get();

	foreach ($allCategoryASlugMappings as $slugMapping){
		$currentSlug = $slugMapping->slug;
		$label = null;
		$parentSlug = null;

		// DBからラベルと親スラッグを取得するロジック
		// ここで $slugMapping->linkable (Polymorphic Relation) を使って
		// Category や CategoryItem を参照し、ラベル名と親スラッグを取得
		
		if ($slugMapping->linkable_type == 'App\Models\Category') { // 1階層目 (親分類の項目)
			$label = $slugMapping->linkable->name;
			$parentSlug = null; // 1階層目なので親はなし
		} else { // 2階層目 (子分類の項目)
			$item = CategoryItem::find($slugMapping->linkable_id);	
			if($item){
				$label = $item->name;
				// 親カテゴリのコードから親スラッグを逆引き
				$parentSlug = $this->findParentSlugByCode($item->parent_category_code);	
			}
		}
		
		// 3. スラッグをキーにした「マップ」を作成 ---
		$categoryAMap[$currentSlug] = ['label' => $label, 'parent' => $parentSlug];
	}
	// (categoryBMap も同様に作成 ... )

これで $categoryAMap['child-a-slug']['label' => '子分類の項目', 'parent' => 'parent-a-slug'] のようなデータを持つことになります。

ポイント2:再帰処理(クロージャ)で親をたどる

マップができたら、あとは「親」を辿ります。
私たちの要件は2階層ですが、将来的に階層が増える可能性もゼロではないため、再帰関数(クロージャ)を使いました。

	// 4. マップを使ってパンくずを"再帰的"に構築 ---
	// 親カテゴリを追加する再帰ヘルパー関数
	$addParentCategoryAItems = function(BreadcrumbTrail $trailInstance, $currentCategoryASlug) use (&$addParentCategoryAItems, $categoryAMap) {
		
		// 5. 現在のスラッグがマップにあり、かつ親(parent)が設定されているか
		if (isset($categoryAMap[$currentCategoryASlug]) && $categoryAMap[$currentCategoryASlug]['parent']) {
			
			$parentCategoryASlug = $categoryAMap[$currentCategoryASlug]['parent'];
			
			if (isset($categoryAMap[$parentCategoryASlug])) {
				
				// 再帰呼び出し
				// 親のスラッグで、さらにその親をたどる
				// (※2階層しかないので、この再帰は1回で終了する)
				$addParentCategoryAItems($trailInstance, $parentCategoryASlug);	
				
				// 6. 親の処理が終わったら、親のパンくずを追加(リンクあり)
				$trailInstance->push(
					$categoryAMap[$parentCategoryASlug]['label'],	
					route('job-list', ['categoryA' => $parentCategoryASlug])
				);
			}
		}
	};

	// 7. まず親の階層を(再帰で)全部追加
	// 例: $categoryASlug = 'child-a-slug' なら、'parent-a-slug' がpushされる
	$addParentCategoryAItems($trail, $categoryASlug);

	// 8. 最後に自分自身(現在のページ)を追加
	if (!empty($categoryAMap[$categoryASlug]['label'])) {
		if (!$categoryBSlug) { // 親分類Bの指定がないなら、ここが終点(リンクなし)
			$trail->push($categoryAMap[$categoryASlug]['label']);
		} else { // 親分類Bが続くなら、ここは通過点(リンクあり)
			$trail->push($categoryAMap[$categoryASlug]['label'], route('job-list', ['categoryA' => $categoryASlug]));
		}
	}

	// (親分類B $categoryBSlug がある場合も同様に再帰処理)
});

もし child-a-slug(子分類の項目)のページを開いた場合、このロジックは以下の順で動きます。

  1. $addParentCategoryAItems(trail, 'child-a-slug') が呼ばれる。
  2. child-a-slug の親 parent-a-slug を見つける。
  3. $addParentCategoryAItems(trail, 'parent-a-slug') を再帰呼び出し。
  4. parent-a-slug に親はいないので、再帰終了。
  5. parent-a-slug のパンくず("親分類Aの項目")が push される。
  6. addParentCategoryAItems の処理が完了。
  7. 最後に child-a-slug のパンくず("子分類の項目")が push される。

これで「TOP > 親分類Aの項目 > 子分類の項目」のパンくずが動的に完成しました。


📈 得られたメリット

今回の設計で得られたメリットは下記になります。

  • カテゴリ追加の修正コストの減少
    • もしベタ書きなら、カテゴリ追加のたびにエンジニアの手動修正・デプロイが必要になっていた。
    • DBにレコードを追加するだけで、パンくずが自動で正しく生成される仕組みができた。
  • コードの見通しが良くなる
    • パンくず定義ファイルがスッキリし、ロジックが1箇所に集約されました。
  • SEOの強化
    • パンくずの親子関係をDBで一元管理することで、構造化データ(JSON-LDなど)の出力もこのマップから容易に生成可能になり、Googleのクローラビリティが向上しました。

まとめ

今回は、複雑な親子関係を持つパンくずリストを diglactic/laravel-breadcrumbs とDB駆動の再帰処理で新規に導入した事例を紹介しました。

ポイントは、①処理の最初にDBアクセスを固めてマップ化し、②再帰的なクロージャで親をたどることでした。

これにより、導入初期から高いメンテナンス性を確保し、ビジネスサイドの「こういうカテゴリを追加したい」という要望にも応えやすいパンくずリストが作成できました。


最後に

Kaienでは様々なエンジニアが活躍しています。
ご興味のある方は、ぜひジョインしていただけると幸いです。
最後までお読みいただきありがとうございました。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?