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?

個人開発のバイクサイトにブログ機能を52ファイルでフルスクラッチした設計の全貌

1
Last updated at Posted at 2026-03-28

個人開発のバイクサイトにブログ機能を52ファイルでフルスクラッチした設計の全貌📝

はじめに

バイク中古・新車一括検索プラットフォーム「MotoHub」を個人開発しています。

SEOで検索流入を増やすために、ブログ機能が必要になりました。WordPressを別途立てる案もありましたが、Laravelの中にフルスクラッチで組み込むことにしました。

設計から実装・デプロイまで、Claude(claude.ai)で設計→コード生成、Claude Code(CLI)でプロジェクトに自律的に組み込み、という分業で進めた結果、全4フェーズ・52ファイルの構成になりました。

この記事では設計の全貌を書きます。


なぜWordPressではなくフルスクラッチなのか

比較 WordPress Laravelフルスクラッチ
初期構築 速い 時間かかる
デザインの統一感 テーマ依存 完全一致
認証・権限 別管理 Laravel既存のauth/RBACを流用
SEO制御 プラグイン依存 JSON-LD・OGP・サイトマップを自由自在
インフラ PHP+MySQL追加 or 別サーバー 既存のDockerに同居
将来の拡張 プラグインで頑張る Eloquent一発

決め手は既存のLaravel認証・Cloudflare連携・Docker環境にそのまま乗せられること。WordPressを別に立てるとインフラもドメインも二重管理になって地獄です。


技術スタック

バックエンド:
├── Laravel 12 / PHP 8.3
├── league/commonmark(Markdown → HTML変換)
├── MySQL(記事データ)
└── Redis(キャッシュ)

フロントエンド:
├── Blade テンプレート
├── marked.js(管理画面のリアルタイムプレビュー)
└── Tailwind CSS

インフラ:
├── さくらVPS 4GB / Docker
├── Cloudflare(CDN + キャッシュ + WAF)
└── 将来: Cloudflare R2(画像ストレージ)

全体アーキテクチャ:4フェーズ・52ファイル

最初にClaude(claude.ai)で全体設計を詰めて、4つのフェーズに分割しました。

Phase 内容 ファイル数
Phase 1: 基盤 DB・Model・認可・ルーティング 12
Phase 2: 管理画面 CRUD・Markdownエディタ・画像アップロード 16
Phase 3: 公開画面 一覧・詳細・タグ・シリーズ・RSS・SEO 13
Phase 4: 運用 サイトマップ・キャッシュ・R2移行・デプロイ 9+2
合計 52

設計判断①:「カテゴリ」を廃止して「タグ × シリーズ」の2軸に

従来のブログでよくある「カテゴリ > サブカテゴリ」の階層構造は採用しませんでした。

代わりに Zenn / Dev.to 風の「タグ × シリーズ」2軸アーキテクチャ を採用しました。

タグ(多対多)

記事に複数のタグを付けて、横断的にフィルタリングできる。

blog_posts ←→ blog_post_tag ←→ blog_tags

例:「Laravel」「WebAR」「メンテナンス」「CBR250RR」

シリーズ(1対多)

複数の記事を「順番」を持たせてまとめ読みさせる。チュートリアルや開発記に最適。

blog_series → blog_posts(series_id, series_order)

例:「MotoHub開発記」第1回〜第N回

なぜこの構造なのか

従来のカテゴリ タグ × シリーズ
1記事1カテゴリ 1記事に複数タグ
階層が深くなりがち フラットで管理しやすい
「カスタム」と「メンテ」どっち?問題 両方タグつければOK
連載の概念がない シリーズで順番管理

設計判断②:MarkdownのハイブリッドレンダリングMarkdownの保存・表示を「管理画面」と「公開画面」で分けました。

DBにはMarkdown生テキストを保存

// blog_posts テーブル
$table->longText('body'); // Markdown生テキスト

HTMLに変換したものをDBに入れない。理由は:

  • Markdownの記法を後から変えたい時に対応できる
  • 全文検索でMarkdownのままの方が扱いやすい
  • レンダリングはキャッシュすればパフォーマンス問題なし

管理画面:marked.js(クライアントサイド)

// リアルタイムプレビュー(150msデバウンス)
editor.addEventListener('input', function() {
    clearTimeout(previewTimer);
    previewTimer = setTimeout(() => {
        preview.innerHTML = marked.parse(editor.value);
    }, 150);
});

image.png

公開画面:league/commonmark(サーバーサイド)

// app/Services/MarkdownService.php
$this->environment->addExtension(new CommonMarkCoreExtension());
$this->environment->addExtension(new GithubFlavoredMarkdownExtension());
$this->environment->addExtension(new AutolinkExtension());
$this->environment->addExtension(new HeadingPermalinkExtension());

サーバーサイドでHTMLに変換して配信するので、SEOクローラーに完全なHTMLが見える。


設計判断③:Zennスタイルのランダムslug

日本語タイトル「マフラー交換した」をslugにすると %E3%83%9E%E3%83%95... という地獄のURLになります。

Zennと同じように、ランダム英数字14桁を自動生成 する方式を採用しました。

// app/Models/BlogPost.php
public static function generateUniqueSlug(): string
{
    do {
        $slug = Str::lower(Str::random(14));
    } while (static::withTrashed()->where('slug', $slug)->exists());

    return $slug;
}

URL例:https://motohub.jp/blog/a1b2c3d4e5f6g7

ただしSEOを狙いたい記事だけは手動で英語slugに上書き可能にしています。


設計判断④:軽量RBAC(role enum + Gate/Policy)

将来的にバイクショップやゲストライターを招待して複数人でメディア運営できるよう、RBAC(ロールベースアクセス制御)を組み込みました。

ただしSpatie Laravel Permissionは今の段階ではオーバーエンジニアリング。ロールが admin / writer の2種なので、enumカラム + Gate/Policy で十分です。

// マイグレーション
$table->enum('role', ['admin', 'writer'])->default('writer');

// Gate定義
Gate::define('manage-blog', function ($user) {
    return in_array($user->role, ['admin', 'writer']);
});

// Policy(writerは自分の記事のみ編集可)
public function update(User $user, BlogPost $post): bool
{
    return $user->role === 'admin' || $user->id === $post->author_id;
}

ロールが5種以上に増えたらSpatieに移行する設計です。


設計判断⑤:画像ストレージの抽象化

Phase 1〜3はローカルストレージ(storage/app/public)で開発速度を優先。Phase 4以降でCloudflare R2に移行できるよう、設定値で切り替え可能にしています。

// config/blog.php
'image_disk' => env('BLOG_IMAGE_DISK', 'public'),
'image_base_url' => env('BLOG_IMAGE_BASE_URL', '/storage'),
// 画像アップロード時
$path = $file->storeAs(
    "{$directory}/{$subDir}",
    $filename,
    config('blog.image_disk')  // ← ここで切り替え
);

DBには相対パスblog/images/2026/03/xxxx.webp)のみ保存。表示時にbase URLを付与するので、R2移行時にDB書き換え不要。.env を3行変えるだけです。


設計判断⑥:論理削除(Soft Deletes)

渾身の記事を操作ミスで消してしまった時の絶望は計り知れません。blog_postsblog_series には softDeletes() を追加しました。

Schema::create('blog_posts', function (Blueprint $table) {
    // ... 省略
    $table->softDeletes(); // ← これ
});

タグは軽量データなので物理削除のまま。


設計判断⑦:自動キャッシュパージ(BlogPostObserver)

MotoHubはCloudflareで全ページをキャッシュしているので、記事を公開・更新した時に関連ページのキャッシュを自動パージする必要があります。

// app/Observers/BlogPostObserver.php
public function saved(BlogPost $post): void
{
    if ($post->status !== 'published') return;

    $urls = $this->cloudflare->getBlogPurgeUrls($post);
    $this->cloudflare->purgeUrls($urls);

    Artisan::call('blog:generate-sitemap');
}

パージ対象:記事詳細、記事一覧、タグ別一覧、シリーズ、RSSフィード。記事を公開するだけで全部自動的にキャッシュが更新されます。


データベース設計

最終的なER図はこうなりました。

users (role追加)
  │
  ├── 1:N ── blog_posts (author_id)
  │              ├── N:M ── blog_tags (via blog_post_tag)
  │              └── N:1 ── blog_series (series_id, series_order)

blog_posts テーブル

id, author_id, title, slug(unique), body(longText),
excerpt, eyecatch_image, series_id, series_order,
status(enum: draft/published/scheduled),
published_at, reading_time_minutes,
meta_title, meta_description, og_image,
created_at, updated_at, deleted_at

インデックス:

  • slug(unique / 記事URL)
  • status, published_at(公開記事一覧)
  • series_id, series_order(シリーズナビ)
  • author_id(著者別一覧)

ディレクトリ構成

app/
├── Console/Commands/
│   ├── PublishScheduledPosts.php       # 予約投稿
│   ├── GenerateBlogSitemap.php        # サイトマップ生成
│   ├── MigrateImagesToR2.php          # R2移行ツール
│   └── PurgeBlogCache.php            # キャッシュパージ
├── Http/Controllers/
│   ├── BlogController.php             # 公開: 一覧・詳細・タグ別
│   ├── BlogSeriesController.php       # 公開: シリーズ
│   ├── BlogFeedController.php         # RSS 2.0
│   ├── Admin/
│   │   ├── BlogPostController.php     # 管理: 記事CRUD
│   │   ├── BlogSeriesController.php   # 管理: シリーズCRUD
│   │   ├── BlogTagController.php      # 管理: タグCRUD
│   │   └── BlogImageController.php    # 画像アップロードAPI
│   └── Api/
│       ├── BlogTagController.php      # タグサジェストAPI
│       └── BlogPreviewController.php  # サーバーサイドプレビューAPI
├── Http/Middleware/
│   └── BlogCacheHeaders.php           # Cache-Control設定
├── Models/
│   ├── BlogPost.php                   # slug自動生成、読了時間算出
│   ├── BlogSeries.php                 # SoftDeletes
│   └── BlogTag.php                    # slug自動生成
├── Observers/
│   └── BlogPostObserver.php           # キャッシュパージ自動化
├── Policies/
│   └── BlogPostPolicy.php            # RBAC
├── Providers/
│   └── BlogServiceProvider.php        # Gate/Policy/Observer登録
└── Services/
    ├── MarkdownService.php            # commonmark + TOC生成
    ├── BlogRelatedPostService.php     # タグスコアリング関連記事
    └── CloudflareCacheService.php     # Cloudflare API

config/blog.php                        # 画像disk、ページネーション等
routes/blog.php                        # 公開/管理/APIルート
deploy-blog.sh                         # デプロイスクリプト

resources/views/
├── blog/                              # 公開画面 (8ファイル)
│   ├── index / show / tag / series/show
│   └── partials/ (_post_card, _series_nav, _related_posts, rss)
└── admin/blog/                        # 管理画面 (6ファイル)
    ├── posts/ (index, create, edit)
    ├── series/ (index, form)
    └── tags/ (index)

SEO対策

JSON-LD構造化データ

<script type="application/ld+json">
{
    "@@context": "https://schema.org",
    "@@type": "BlogPosting",
    "headline": "{{ $post->title }}",
    "datePublished": "{{ $post->published_at->toIso8601String() }}",
    "author": { "@@type": "Person", "name": "{{ $post->author->name }}" },
    "wordCount": {{ mb_strlen(strip_tags($post->body)) }},
    "timeRequired": "PT{{ $post->reading_time_minutes }}M"
}
</script>

@context はBladeで @@ にエスケープが必要(後述の地雷編で詳しく書きます)

OGP + Twitter Card

<meta property="og:type" content="article">
<meta property="og:title" content="{{ $post->seo_title }}">
<meta property="og:image" content="{{ $post->og_image_url }}">
<meta name="twitter:card" content="summary_large_image">

目次(TOC)の自動生成

league/commonmarkのAST(抽象構文木)からh2/h3を抽出して、サーバーサイドで目次HTMLを生成しています。

public function extractToc(string $markdown): array
{
    $parser = new MarkdownParser($this->environment);
    $document = $parser->parse($markdown);
    // h2/h3 ノードを収集してTOC配列を生成
}

JSに依存しないので、SEOクローラーにも目次が見えます。

関連記事(タグスコアリング)

同じタグを持つ記事をスコアリングして関連記事を表示。同シリーズの記事は除外(シリーズナビで別途表示するため)。


開発フロー:claude.ai × Claude Code の分業

今回の開発で面白かったのは、2つのClaudeを使い分けたことです。

役割 ツール やったこと
設計・コード生成 claude.ai(Webチャット) 全体設計、52ファイルの生成、tarball出力
プロジェクト組み込み Claude Code(CLI) 既存コードとの統合、エラー修正、自律デバッグ
レビュー Gemini 設計レビュー(論理削除・slug・R2の提案)

claude.aiで設計書とコード群をPhase単位で生成 → tarballでダウンロード → Claude Codeに「これをプロジェクトに組み込んで」と投げる。Claude Codeは既存のLaravel 12のコード構造を読んで、<x-layout> コンポーネントへの変換やFilament管理画面との共存を自動で判断してくれました。


まとめ

設計判断 選択 理由
ブログ基盤 Laravelフルスクラッチ 既存インフラ・認証を流用
分類体系 タグ × シリーズ Zenn/Dev.to風、カテゴリ廃止
Markdown ハイブリッド(marked.js + commonmark) 管理UX + SEO両立
slug ランダム14桁 + 手動上書き可 Zennスタイル
RBAC role enum + Gate/Policy 軽量、将来Spatie移行可
画像 Storage::disk 抽象化 R2移行時にDB変更不要
削除 Soft Deletes 誤削除からの復元保証
キャッシュ Observer自動パージ Cloudflare連携

次回は実装編として、Markdownのハイブリッドレンダリングとエディタの詳細を書きます。


前回の記事:楽天・Yahoo・AmazonのAPIを横断して同一商品の最安値を見つける実装の話

MotoHub: https://motohub.jp
MotoHubブログ: https://motohub.jp/blog

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?