Help us understand the problem. What is going on with this article?

【 Laravel 6 】投稿へのタグ付け機能

【 Laravel 6 】投稿へのタグ付け機能

2019年11月から、Laravel歴数ヶ月の初心者が投稿型ナレッジベースのコミュニティサイトを作るというチャレンジ中。作りたいアプリケーション→機能を因数分解→ググる→先人の轍をたどる(写経する)→ぬかるみにはまる→エラー解消の神を探す→解決を繰り返す日々( ·ㅂ·)و 。備忘録として、Qiitaに投稿しています。

SNSっぽい機能の代名詞である投稿へのタグ付を実装していきます(o´艸`)

テーブル設計

タグ機能に関するテーブル設計はこちらのサイトを参考にさせていただきました。アリガトウゴザイマス<(_ _)>

【MySQL】タグ付け機能の実装にオススメなテーブル設計(TOXI法)その2
https://senews.jp/toxi2/

postsテーブルは投稿記事を入れておく、
tagsテーブルはタグを入れる、
そして、どの投稿(post)にどのタグ(tag)が紐づいているかを繋ぐテーブル(=「中間テーブル(結合テーブル)」と呼ばれたりする)の3つのテーブルを使います。

テーブル posts 中間テーブル(結合テーブル) tags
役割 投稿記事を入れておくテーブル どの投稿(post)にどのタグ(tag)が紐づいているかの関係性を示す情報を入れておくテーブル タグを入れるテーブル

この「中間テーブル」には命名のルールがあるそうで、Laravel 6.xのドキュメントによれば、下記のようになっています。

(引用:Laravel 6.x Eloquent:リレーション)
多対多
多対多の関係はhasOneとhasManyリレーションよりも多少複雑な関係です。このような関係として、ユーザー(user)が多くの役目(roles)を持ち、役目(role)も大勢のユーザー(users)に共有されるという例が挙げられます。たとえば多くのユーザーは"管理者"の役目を持っています。users、roles、role_userの3テーブルがこの関係には必要です。role_userテーブルは関係するモデル名をアルファベット順に並べたもので、user_idとrole_idを持つ必要があります。

↑ということで、ドキュメントに習い以下のように、テーブル、それぞれにカラムを作成していきましょう。

posts post_tag tags
id id id
title post_id name
body tag_id tag_id
user_id

マイグレーションファイルの生成

投稿記事そのものを入れるpostsテーブルはすでにできている前提(【 Laravel 6 】投稿の表示・修正・更新・削除)ですので、今回はtagsとpost_tagの2つを作ります。

terminal
$ php artisan make:migration create_post_tag_table

tagの方にはモデルも必要なので、併せて作成します。-mを最後につけることで、データベースマイグレーションと同時に生成されます。(ちなみに、--allとすると、モデル作成時にマイグレーション、Factory、コントローラーを同時に作成することができる。)

terminal
$ php artisan make:model Tag --m

生成されたのは、以下の3つのファイル。

マイグレーション
①プロジェクトディレクトリ/database/migrations/YYYY_MM_DD_XXXXXX_create_tags_table.php
②プロジェクトディレクトリ/database/migrations/YYYY_MM_DD_XXXXXX_create_post_tag_table.php
モデル
③プロジェクトディレクトリ/app/Tag.php

すでにある、以下の2つを加えた、5つのファイルを使用します。

マイグレーション
④プロジェクトディレクトリ/database/migrations/YYYY_MM_DD_XXXXXX_create_post_table.php
モデル
⑤プロジェクトディレクトリ/app/Post.php

マイグレーション

①と②の2つのマイグレーションファイルを、以下のように修正。

# 2019_12_05_073929_create_tags_table.php
    public function up()
    {
        Schema::create('tags', function (Blueprint $table) {
            $table->increments('id'); // Laravel6.xでは初期設定はbigIncrements担っていますが、そんなに桁数の多いデータ量が必要だとも思えないので、incrementsに直す。
            $table->timestamps();
            $table->string('name'); // この行を追加
        });
    }

# 2019_12_05_074619_create_post_tag_table.php
public function up()
    {
        Schema::create('post_tag', function (Blueprint $table) {
            $table->increments('id');
            $table->timestamps();
            $table->unsignedInteger('tag_id'); //この行を追加
            $table->unsignedInteger('post_id'); //この行を追加
            $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade'); //この行を追加
            $table->foreign('post_id')->references('id')->on('posts')->onDelete('cascade'); //この行を追加
        });
    }

以下のコマンドを叩いてマイグレーションを実行してテーブルを作成します(ง ´͈౪`͈)ว

terminal
$ php artisan migrate

テーブルのリレーションを設定する

テーブルのリレーションの設定には、両端にある2つのテーブル(今回の場合ではpostsとtags)のそれぞれのModelに関係性を示すコードを追加します。これだけ!!

# Post.php
public function tags()
    {
        return $this->belongsToMany('App\Tag'); 
    }

# Tag.php
public function posts()
    {
        return $this->belongsToMany('App\Post'); 
    }

データの入力画面を作っていく

表側も触っていきましょう。
↓こんな感じの入力画面を作ります。投稿時に、タイトル、タグ、そして本文を記述して送信します。
スクリーンショット 2019-12-08 12.42.14.png

投稿画面(create.blade.php)というviewファイルをこんな感じ↓に書きます。

create.blade.php
@extends('layouts.app')

@section('content')
    <div class="container mt-4">
        <div class="border p-4">
            <h1 class="h5 mb-4">
                投稿の新規作成 
            </h1>

            <form method="POST" action="{{ route('posts.store') }}">
                @csrf

                <fieldset class="mb-4">
                    <div class="form-group">
                        <label for="title">
                            タイトル
                        </label>
                        <input
                            id="title"
                            name="title"
                            class="form-control {{ $errors->has('title') ? 'is-invalid' : '' }}"
                            value="{{ old('title') }}"
                            type="text"
                        >
                        @if ($errors->has('title'))
                            <div class="invalid-feedback">
                                {{ $errors->first('title') }}
                            </div>
                        @endif
                    </div>

                    <div class="form-group">
                        <label for="tags">
                            タグ
                        </label>
                        <input
                            id="tags"
                            name="tags"
                            class="form-control {{ $errors->has('tags') ? 'is-invalid' : '' }}"
                            value="{{ old('tags') }}"
                            type="text"
                        >
                        @if ($errors->has('tags'))
                            <div class="invalid-feedback">
                                {{ $errors->first('tags') }}
                            </div>
                        @endif
                    </div>

                    <div class="form-group">
                        <label for="body">
                            本文
                        </label>

                        <textarea
                            id="body"
                            name="body"
                            class="form-control {{ $errors->has('body') ? 'is-invalid' : '' }}"
                            rows="4"
                        >{{ old('body') }}</textarea>
                        @if ($errors->has('body'))
                            <div class="invalid-feedback">
                                {{ $errors->first('body') }}
                            </div>
                        @endif
                    </div>

                    <div class="mt-5">
                        <a class="btn btn-secondary" href="{{ route('top') }}">
                            キャンセル
                        </a>
                        <button type="submit" class="btn btn-primary" >
                            投稿する
                        </button>
                    </div>

                </fieldset>
            </form>
        </div>
    </div>
@endsection

ルーティング

投稿はログイン済みのユーザーにのみ許可することとしているので、middleweare auth傘下にcreate(投稿入力画面表示)とstore(DBへのレコード処理)を配置しています。

web.php
Route::group(['middleware' => ['auth']], function () {

    // 投稿の作成画面の表示、作成処理、詳細画面、更新、削除
    Route::resource('posts', 'PostsController', ['only' => ['create', 'store', 'show', 'edit', 'update', 'destroy']]);

});

投稿入力画面表示とDBへのレコード処理

投稿入力画面表示とDBへのレコード処理をコントローラーに記述していきます。

PostController.php
namespace App\Http\Controllers;

use Illuminate\Support\Facades\Auth; //追加
use Illuminate\Support\Facades\DB; //追加
use App\Tag; //追加
use App\Post; //追加

class PostsController extends Controller
{
    // 投稿の作成画面の表示
    public function create()
    {
        return view('posts.create');
    }

    //投稿のDBへのレコード作成
    public function store(Request $request)
    {
        $post = $request->validate([
            'title' => 'required|max:50',
            'body' => 'required|max:2000',
        ]);

        // #(ハッシュタグ)で始まる単語を取得。結果は、$matchに多次元配列で代入される。
        preg_match_all('/#([a-zA-z0-90-9ぁ-んァ-ヶ亜-熙]+)/u', $request->tags, $match);
     
        // $match[0]に#(ハッシュタグ)あり、$match[1]に#(ハッシュタグ)なしの結果が入ってくるので、$match[1]で#(ハッシュタグ)なしの結果のみを使います。
        $tags = [];
        foreach ($match[1] as $tag) {
            $record = Tag::firstOrCreate(['name' => $tag]); // firstOrCreateメソッドで、tags_tableのnameカラムに該当のない$tagは新規登録される。
            array_push($tags, $record); // $recordを配列に追加します(=$tags)
        };

        // 投稿に紐付けされるタグのidを配列化
        $tags_id = [];
        foreach ($tags as $tag) {
            array_push($tags_id, $tag['id']);
        };
        $post->tags()->attach($tags_id); // 投稿ににタグ付するために、attachメソッドをつかい、モデルを結びつけている中間テーブルにレコードを挿入します。
     
     // 投稿はposts_tableへレコードしましょう。
        $post = new Post;
        $post->title = $request->title;
        $post->body = $request->body;
        $post->user_id = Auth::user()->id;
        $post->save();

        return redirect()->route('top');
    }
}

|ω·`) 最後に、詰んだポイント:中間テーブル(結合テーブル)の命名ルール

前述の中間テーブルの命名ルールに付いて知らなかったため、中間テーブルをテキトーに名付けていたら、以下のエラーが出てきて焦った。

SQLSTATE[42S02]: Base table or view not found: 1146 Table 'your_project.post_tag' doesn't exist (SQL: insert into post_tag (post_id, tag_id) values (74, 20), (74, 8))

以下のドキュメントにある通り、Modelの多対多リレーションを設定するbelongsToManyメソッドの記述に、第2引数として中間テーブル(結合テーブル)名を渡してあげる方法でも解決できたようです。やはり原典は丁寧に読むべしというのが、今回の(毎度毎度)の反省です( ˃̣̣̥ω˂̣̣̥ ) 。

(引用:Laravel 6.x Eloquent:リレーション)
前に述べたようにリレーションの結合テーブルの名前を決めるため、Eloquentは2つのモデル名をアルファベット順に結合します。しかしこの規約は自由にオーバーライドできます。belongsToManyメソッドの第2引数に渡してください。

return $this->belongsToMany('App\Role', 'role_user');

以上です!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away