Laravel10とJavaScriptのFetchAPIでいいね機能を実装する【非同期処理】

## 本記事の内容
記事に対するいいね機能の実装方法です。認証ライブラリであるBreezeがインストールされている前提となります。また、fetchAPIを使用した非同期処理の実装をします。

<details><summary>Breezeインストールとその他の準備</summary>

```
composer require laravel/breeze --dev
```

```
php artisan breeze:install blade
```

```
npm install && npm run build
```
</details>

### 使用する技術
・Laravel10.*
・MySQL
・JavaScript
・CSS
・Font Awesome
・Breeze
・Vite(Laravel Breezeを使用している人はデフォルトで用意されています)

### アプリ画面はこちら

![Videotogif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3486141/3f74bce7-a652-4e3b-cb0f-a0ed13ef1de7.gif)
![Videotogif (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3486141/49cadfbb-e67b-8f5f-71e0-1d2d2a756b2e.gif)



### ER図はこちら
![スクリーンショット 2024-08-27 16.16.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3486141/2fbd5644-94af-1681-6a77-965eb602fc6f.png)




### 構成

#### バックエンド

**・model**
Post.php (postsテーブルと紐付け)
PostLike.php (post_likesテーブルと紐付け）
User.php (usersテーブルと紐付け）←laravelデフォルトのものを使用

**・controller**
PostController.php（記事のcrud ＊本記事では触れません）
**LikeController.php（いいね機能を担当）**

**・view**
index.blade.php　(記事の一覧表示ページ ＊本記事では触れません)
show.blade.php　(**記事の詳細表示ページ**)
app.blade.php　（これはBreezeにより提供されるブレード。共通パーツのトップレベルとして使える、またvite利用でバンドル済js/cssファイルも読み込んである）

#### フロントエンド

**like.js(いいね機能を担当)**
index.js（バンドル元）

## 実装1　マイグレーションファイルの作成

postsテーブル、post_likesテーブルをLaravelのマイグレーションで作成します。usersテーブル用のマイグレーションファイルはデフォルトで作成されているので用意する必要はありません。また本記事におけるpostsテーブルに相当するものが作成済の方はpostsテーブルも入りません。


```ターミナル
php artisan make:migration create_posts_table
```

```ターミナル
php artisan make:migration create_post_likes_table
```

### postsテーブルのスキーマ定義

特別な点はありません。いいね機能には特に関係ないため、例として単純なテキスト（下記contentカラム）とユーザーidのみカラムとして保持する想定で進めます。
```~create_posts_table.php
~略
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string("content");//記事の文章等
            $table->foreignId('user_id')->constrained()->onDelete('cascade');//投稿者id(usersテーブル主キー)
            $table->timestamps();
        });
    }
~略
```

### post_likesテーブルのスキーマ定義
このテーブルには誰がいいねをしたのか、どの記事がいいねされたのか、の二つをデータとして保持するため下記のような構造を定義します。

```~create_post_likes_table.php
~略
    public function up(): void
    {
        Schema::create('post_likes', function (Blueprint $table) {
            $table->id();
            //いいねしたユーザーのid
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            //いいねされた記事のid
            $table->foreignId('post_id')->constrained()->onDelete('cascade');
            $table->timestamps();
        });
    }
~略
```

mysql等を利用している場合、以下のように主キーを外部キー（user_idとpost_id）の組み合わせに設定することもできます。（組み合わせが重複しないようになるため、一つの記事に一回のみいいねできるようにしたい場合、下記のように設定することが推奨されます。）
```~create_post_likes_table.php
~略
    public function up(): void
    {
        Schema::create('post_likes', function (Blueprint $table) {
            //いいねしたユーザーのid
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            //いいねされた記事のid
            $table->foreignId('post_id')->constrained()->onDelete('cascade');
            //主キーをuser_idとpost_idの組み合わせにする
            $table->primary(['user_id','post_id']);
            $table->timestamps();
        });
    }
~略
```

マイグレーションファイルの記述が完了したら下記でマイグレートを実行しましょう。

```
php artisan migrate
```
以降、最低1レコードづつ、ユーザーと記事の作成は済ませてある想定で進めます。

## 実装２ ビュー（ブレードファイル）の作成
**記事の詳細ページ**のビューの大枠を作成します。resourses→views配下にshow.blade.phpなどを下記のように作成します。

```show.blade.php 
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>（記事の詳細ページ）</title>
    {{-- のちに使います --}}
    <style></style>
</head>
<body>
    <h1>いいね機能実装方法</h1>
    <div><p>{{$post->content}}</p></div> 
    <div></div>
    {{-- のちに使います --}}
    <script></script>
</body>
</html>

```


### *詳細ページのルーティング
```web.php
~
Route::get('/post/{post}',[PostController::class,'show']);
~
```

### *PostControllerクラスのshowメソッド

```PostController.php
~
public function show(Post $post){
    return view('post.show')->with(['post'=>$post]);
}
~
```
本題とはズレますが、web.phpのルートパラメータ{post}とPostController.phpのインスタンス変数($post)は同じ名前にする必要がありますのでご注意！
（詳しく知りたい方は「laravel暗黙の結合」で検索することをお勧めします）

### 星の準備 （Font Awesomeの準備）

**いいねボタンのデザインやcssにこだわりがない方、ご自身で行いたい方は飛ばしてください。**
本記事ではいいねボタンの星にfont awesomeを使います。font awesomeは無料で使えるアイコン素材を提供してくれるサービスです。また、cdn（世界のどこかにあるサーバーから必要なデータを取ってくる仕組み。自身のサーバーへの負荷低減＆簡単な実装のみで開発できるメリットがある）を利用するためのコードを取得していきます。
難しそうですが、簡単です。

下記にアクセスし、アカウントを作成していきます。赤線部にメールアドレスを入力し、send kitボタンを押下してください。

https://fontawesome.com/start
![スクリーンショット 2024-08-22 23.09.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3486141/222e66dc-2417-8702-a1dc-fa5f9cf39d99.png)

メールに届いたリンク（ボタン）を押下し、認証手続きをします。







下記のような画面になった場合、free stylesを選びます。こちらを選択すると無料で利用することができます。（無料でも十分な量のアイコンがあります。）
![スクリーンショット 2024-08-22 21.56.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3486141/f6d528e2-084b-fc33-18b5-7a836c6fc53c.png)








下記のように「easiest」を選択します。
![スクリーンショット 2024-08-22 21.57.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3486141/c33bca9d-4ca3-6a6d-1736-166cb68cee09.png)


下記のようになったらOKです。コードが取得できたので、赤線部のコードをコピーし、作成した詳細ページ用のブレードファイルのheadタグの中にペーストしましょう。ちなみこちらがfont awesomeの提供するcdnになります。
![スクリーンショット 2024-08-22 21.57.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3486141/a4e249f3-e256-c1cf-64c7-b3f7329bca8e.png)

```show.blade.php (.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>（記事の詳細ページ）</title>
    {{-- 追加します（xxxxxにはご自身が取得したコードを入れてください） --}}
    <script src="https://kit.fontawesome.com/xxxxx.js" crossorigin="anonymous"></script>
    <style></style>
</head>
<body>
    <h1>いいね機能実装方法</h1>
    <div><p>{{$post->content}}</p></div> 
    <div></div>
    <script></script>
</body>
</html>
```

次に利用するアイコンを探します。ヘッダーのiconsを押下し、お好きなアイコンを選んでください。本記事ではstarを使います。
![スクリーンショット 2024-08-22 22.06.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3486141/779e930b-29bf-3715-1038-056579b4b242.png)

使いたいアイコンをクリックすると下記のようになるので、htmlのコードをコピーし記事詳細ページへペーストします。
![スクリーンショット 2024-08-23 0.04.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3486141/9c8e3ec0-01c7-4c6a-4575-65944d7c9cfa.png)

```show.blade.php (.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>（記事の詳細ページ）</title>
    {{-- xxxxxにはご自身が取得したコードを入れてください --}}
    <script src="https://kit.fontawesome.com/xxxxx.js" crossorigin="anonymous"></script>
    <style></style>
</head>
<body>
    <h1>いいね機能実装方法</h1>
    <div><p>{{$post->content}}</p></div> 
    {{-- 追加します --}}
    <div><i class="fa-solid fa-star"></i></div>
    <script></script>
</body>
</html>
```
上記のように記述し終えたら、実行画面でアイコンが確認できるようになります。サイズ等は後半に整えます。
![スクリーンショット 2024-08-23 0.11.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3486141/11a838a6-9af5-7ec3-380a-13e8524cc54e.png)


## 実装3 モデルの実装

post_likesテーブルのデータを管理するため、PostLikeというモデルを作成します。ターミナルで以下を実行します。
```
php artisan make:model PostLike
```
次に用意されていると想定されるPostモデルを編集します。具体的には

・特定の記事のいいねの数を取得する際に使うリレーションメソッド
・認証済ユーザー（自分自身）が記事にいいねしたのか判定するメソッド
の定義を行います。
```Post.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    use HasFactory;

    //post_likesテーブルへのリレーションメソッド。Postモデルのインスタンス＄postに、$post->likes->count()とすると記事のいいね数を取得できるようになった。
    public function likes()
    {
        return $this->hasMany(PostLike::class);
    }
    //自身がいいねしているのかどうか判定するメソッド（しているならtrue,していないならfalseを返す）
    public function isLikedByAuthUser() :bool
    {
        //認証済ユーザーid（自身のid）を取得
        $authUserId = \Auth::id();
        //空の配列を定義。後続の処理で、いいねしたユーザーのidを全て格納していくときに使う。
        $likersArr = array();
       
        //$thisは言葉の似た通り、クラス自身を指す。具体的にはこのPostクラスをインスタンス化した際の変数のことを指す。（後続のビューで登場する$postになります）
        foreach($this->likes as $postLike){
            //array_pushメソッドで第一引数に配列、第二引数に配列に格納するデータを定義し、配列を作成できる。
            //今回は$likersArrという空の配列にいいねをした全てのユーザーのidを格納している。
            array_push($likersArr,$postLike->user_id);

        }
        //in_arrayメソッドを利用し、認証済ユーザーid（自身のid）が上記で作成した配列の中に存在するかどうか判定している
        if (in_array($authUserId,$likersArr)){
            //存在したらいいねをしていることになるため、trueを返す
            return true;
        }else{
            return false;
        }
    }
}

```

## 実装4 再びビュー

上記で作成したメソッドをビュー（ブレードファイル）で利用していきます。fontawesomeのコード以外全て置き換えてしまって問題ありません。

``` show.blade.php (.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    {{-- csrfトークン --}}
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>（記事の詳細ページ）</title>
    <script src="https://kit.fontawesome.com/xxxxx.js" crossorigin="anonymous"></script>
    <style>
        /* いいね押下時の星の色 */
        .liked{
            color:orangered;
            transition:.2s;
        }
        .flexbox{
            align-items: center;
            display: flex;
        }
        .count-num{
            font-size: 20px;
            margin-left: 10px;
        }
        .fa-star{
            font-size: 30px;
        }
    </style>
</head>
<body>
    <h1>いいね機能実装方法</h1>
    <div><p>{{$post->content}}</p></div> 
    {{-- @authはログイン済ユーザーのみに閲覧できるものを中に定義できます。 --}}
    @auth
        {{-- 前章のPostモデルで作成したメソッドを利用し、自身がこの記事にいいねしたのか判定します。 --}}
        @if($post->isLikedByAuthUser())
            {{-- こちらがいいね済の際に表示される方で、likedクラスが付与してあることで星に色がつきます --}}
            <div class="flexbox">
                <i class="fa-solid fa-star like-btn liked" id={{$post->id}}></i>
                <p class="count-num">{{$post->likes->count()}}</p>
            </div>
        @else
            <div class="flexbox">
                <i class="fa-solid fa-star like-btn" id={{$post->id}}></i>
                <p class="count-num">{{$post->likes->count()}}</p>
            </div>
        @endif
    @endauth

    @guest
        <p>loginしていません</p>
    @endguest
    <script>
    //いいねボタンのhtml要素を取得します。
        const likeBtn = document.querySelector('.like-btn');
        //いいねボタンをクリックした際の処理を記述します。 
        likeBtn.addEventListener('click',async(e)=>{
            //クリックされた要素を取得しています。
            const clickedEl = e.target
            //クリックされた要素にlikedというクラスがあれば削除し、なければ付与します。これにより星の色の切り替えができます。      
            clickedEl.classList.toggle('liked')
            //記事のidを取得しています。
            const postId = e.target.id
            //fetchメソッドを利用し、バックエンドと通信します。非同期処理のため、画面がかくついたり、真っ白になることはありません。
            const res = await fetch('/post/like',{
                //リクエストメソッドはPOST
                method: 'POST',
                headers: {
                    //Content-Typeでサーバーに送るデータの種類を伝える。今回はapplication/json
                    'Content-Type': 'application/json',
                    //csrfトークンを付与
                    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
                },
                //バックエンドにいいねをした記事のidを送信します。
                body: JSON.stringify({ post_id: postId })
            })
            .then((res)=>res.json())
            .then((data)=>{
                //記事のいいね数がバックエンドからlikesCountという変数に格納されて送信されるため、それを受け取りビューに反映します。
                clickedEl.nextElementSibling.innerHTML = data.likesCount;
            })
            .catch(
            //処理がなんらかの理由で失敗した場合に実施したい処理を記述します。
            ()=>alert('処理が失敗しました。画面を再読み込みし、通信環境の良い場所で再度お試しください。'))

        })
    </script>
</body>
</html>

```

## 実装5 いいねボタン押下時に発火するルーティング

```web.php
//jsのfetchメソッドで'/post/like'としているため、ルーティングも以下のように'/post/like'とします。
Route::post('/post/like', [LikeController::class, 'likePost']);
```

## 実装6 いいね機能を担うコントローラーの実装
```LikeController.php
<?php

namespace App\Http\Controllers;

use App\Models\Post;
use App\Models\PostLike;
use Illuminate\Http\Request;

class LikeController extends Controller
{

    public function likePost(Request $request)
    {
        $user_id = \Auth::id();
        //jsのfetchメソッドで記事のidを送信しているため受け取ります。
        $post_id = $request->post_id;
        //自身がいいね済みなのか判定します
        $alreadyLiked = PostLike::where('user_id', $user_id)->where('post_id', $post_id)->first();

        if (!$alreadyLiked) {
        //こちらはいいねをしていない場合の処理です。つまり、post_likesテーブルに自身のid（user_id）といいねをした記事のid（post_id）を保存する処理になります。
            $like = new PostLike();
            $like->post_id = $post_id;
            $like->user_id = $user_id;
            $like->save();
        } else {
            //すでにいいねをしていた場合は、以下のようにpost_likesテーブルからレコードを削除します。
            PostLike::where('post_id', $post_id)->where('user_id', $user_id)->delete();
        }
        //ビューにその記事のいいね数を渡すため、いいね数を計算しています。
        $post = Post::where('id', $post_id)->first();
        $likesCount = $post->likes->count();

        $param = [
            'likesCount' =>  $likesCount,
        ];
        //ビューにいいね数を渡しています。名前は上記のlikesCountとなるため、フロントでlikesCountといった表記で受け取っているのがわかると思います。
        return response()->json($param);
    }
}
```
余力のある方は上記のコントローラ内のデータ取得に関わる処理をモデルに任せられるようにリファクタリングしてみると良いと思います！
以上で動くものは完成です。

## 実装7 jsとcssを別ファイルで読み込む

breezeをインストールされている方はそれを最大限活かしていきます。

### ビューの書き換え
ビューの継承を行い、app.blade.phpに記述したものを利用できるようにします。まずはapp.blade.phpの**headタグ**内に以下を追記します。
```app.blade.php (.html
{{-- 追加（！！コードは自身のものに書き換えてください！！） --}}
<script src="https://kit.fontawesome.com/xxxxx.js" crossorigin="anonymous"></script>
{{-- 追加 --}}
<meta name="csrf-token" content="{{ csrf_token() }}">
```

```show.balde.php (.html
<x-app-layout>
    <h1>いいね機能実装方法</h1>
    <div><p>{{$post->content}}</p></div> 
    {{-- @authはログイン済ユーザーのみに閲覧できるものを中に定義できます。 --}}
    @auth
        {{-- 前章のPostモデルで作成したメソッドを利用し、自身がこの記事にいいねしたのか判定します。 --}}
        @if($post->isLikedByAuthUser())
            <div class="flexbox">
                <i class="fa-solid fa-star like-btn liked" id={{$post->id}}></i>
                <p class="count-num">{{$post->likes->count()}}</p>
            </div>
        @else
            <div class="flexbox">
                <i class="fa-solid fa-star like-btn" id={{$post->id}}></i>
                <p class="count-num">{{$post->likes->count()}}</p>
            </div>
        @endif
    @endauth

    @guest
        <p>loginしていません</p>
    @endguest
</x-app-layout>
```

### jsファイルの作成とimport
resourcesフォルダ内にあるjsフォルダ内にて、like.jsを作成しましょう。
```like.js
const likeBtn = document.querySelector(".like-btn");
likeBtn.addEventListener("click", async (e) => {
    const clickedEl = e.target;
    clickedEl.classList.toggle("liked");

    const postId = e.target.id;

    const res = await fetch("/post/like", {
        //リクエストメソッドはPOST
        method: "POST",
        headers: {
            //Content-Typeでサーバーに送るデータの種類を伝える。今回はapplication/json
            "Content-Type": "application/json",
            //csrfトークンを付与
            "X-CSRF-TOKEN": document
                .querySelector('meta[name="csrf-token"]')
                .getAttribute("content"),
        },
        body: JSON.stringify({ post_id: postId }),
    })
        .then((res) => res.json())
        .then((data) => {
            clickedEl.nextElementSibling.innerHTML = data.likesCount;
        })
        .catch(() =>
            alert(
                "処理が失敗しました。画面を再読み込みし、通信環境の良い場所で再度お試しください。"
            )
        );
});

```
作成したlike.jsを読み込みます。
```app.js
import "./bootstrap";
//追加します
import "./like";
import Alpine from "alpinejs";

window.Alpine = Alpine;

Alpine.start();

```
### cssファイルの作成とimport
resourcesフォルダ内にあるcssフォルダ内にて、style.cssを作成しましょう。


```style.css
/* いいね押下時の星の色 */
.liked {
    color: orangered;
    transition: 0.2s;
}
.flexbox {
    align-items: center;
    display: flex;
}
.count-num {
    font-size: 20px;
    margin-left: 10px;
}
.fa-star {
    font-size: 30px;
}

```
作成したstyle.cssを読み込みます。
```app.css
/* 追加する */
@import "./style.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
```

以上で完成です。お疲れ様でした。