Laravel × Vue チュートリアル with PokeAPI（ポケモン）

# はじめに
初心者向けのチュートリアルとして Laravel 11 と Vue 3 を組み合わせたアプリケーションを作成する方法を説明しています。

また、本記事では Vue 3 の記法として Options API を用いて記述しております。
Options API は Vue 2 の記法と同じであり、少し手順を変更すれば Vue 2 を用いたチュートリアルとしても活用できると考えたためです。

# 完成イメージ
ポケモンのデータを取得できる無料のAPI「PokeAPI」を用いて、ポケモンの一覧を表示します。また、お気に入りのポケモンを登録／解除できる機能を作成し、気になるポケモンに目印を付けることを可能とします。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/2d9c132d-bacc-6ccd-f085-8ee785ca4048.png" width="500" />

# 前提条件
- Docker が使用できる状態であること

# 用いる技術
- PHP 8.3
- Laravel 11.x
- Vue.js 3
- Vue Router 4
- TailwindCSS
- MySQL 8.4

# 手順

## 1. Docker コンテナの作成

AlmaLinux、Nginx、MySQL、phpMyAdmin のコンテナを作成するため、`docker-compose.yml` を新規作成して以下の内容を記述してください。

ローカルホスト側からコンテナにアクセスできるように、以下のポートにおいてローカルホスト - コンテナ間で接続する設定を行っております。

- ローカルホスト 80番　←　Nginxコンテナ 80番　
- ローカルホスト 5173番　←　AlmaLinuxコンテナ 5173番（Vite サーバ接続のため）
- ローカルホスト 8080番　←　phpMyAdminコンテナ 80番

```docker:docker-compose.yml
name: "laravel_vue_tutorial"

services:
  nginx:
    container_name: laravel_vue_tutorial_nginx
    image: nginx:1.26
    ports:
      - 80:80
    volumes:
      - ./laravel:/var/www/html/laravel
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - php

  php:
    container_name: laravel_vue_tutorial_php
    ports:
      - 5173:5173
    build: 
      context: .
      dockerfile: ./docker/php/dockerfile
    volumes:
      - ./laravel:/var/www/html/laravel
      - ./docker/php/www.conf:/etc/php-fpm.d/www.conf
    depends_on:
      - mysql
  
  mysql:
    container_name: laravel_vue_tutorial_mysql
    image: mysql:8.4
    environment:
      - MYSQL_DATABASE=tutorial
      - MYSQL_USER=tutorial
      - MYSQL_PASSWORD=password
      - MYSQL_ROOT_PASSWORD=password
  
  pma:
    container_name: laravel_vue_tutorial_pma
    image: phpmyadmin:5.2
    environment:
      - PMA_HOST=laravel_vue_tutorial_mysql
    ports:
      - 8080:80
    depends_on:
      - mysql
```

AlmaLinux のコンテナを用いて PHP を動作させるため、`docker/php` ディレクトリに `dockerfile` を作成して以下の内容を記述してください。PHP、Composer、Node.js をインストールした後、PHP-FPM を起動するように設定しております。

```dockerfile:docker/php/dockerfile
FROM almalinux:8.10

RUN dnf -y update
RUN dnf -y install unzip

# PHP
RUN mkdir /run/php-fpm
RUN dnf -y install epel-release
RUN dnf -y install https://rpms.remirepo.net/enterprise/remi-release-8.rpm
RUN dnf module enable -y php:remi-8.3
RUN dnf -y install php php-fpm php-mysqlnd

# Composer
COPY --from=composer:2.7.8 /usr/bin/composer /usr/bin/composer

# Node
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
RUN source ~/.bashrc && nvm install 20.17.0

COPY ./laravel /var/www/html/laravel
WORKDIR /var/www/html/laravel

EXPOSE 9000
CMD ["php-fpm", "-F"]
```

Laravel を動作させるため、`docker/nginx` ディレクトリに Nginx の設定ファイル `default.conf` を作成して以下の内容を記述してください。設定内容は Laravel 公式のマニュアルを参考にしております。

https://laravel.com/docs/11.x/deployment#nginx

```conf:docker/nginx/default.conf
server {
    listen 80;
    listen [::]:80;
    server_name localhost;

    root /var/www/html/laravel/public;
    index index.php;
    charset utf-8;
 
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
 
    location ~ \.php$ {
        fastcgi_pass laravel_vue_tutorial_php:9000;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }
}
```

Nginx からの要求に PHP-FPM が応答できるように、`docker/php` ディレクトリに `www.conf` を作成して以下の内容を記述してください。

```conf:docker/php/www.conf
[www]
user = nginx
group = nginx

listen = 9000
listen.acl_users = apache,nginx

pm = dynamic
pm.max_children = 50
pm.min_spare_servers = 5
pm.max_spare_servers = 35
```

動作確認のため、`laravel/public` ディレクトリに `index.php` を作成して以下の内容を記述してください。内容はシンプルに phpinfo を表示するだけです。

```php:laravel/public/index.php
<?php

echo phpinfo();
```

上記のファイルが全て作成できたら、以下のコマンドを実行してコンテナを作成および起動を実施してください。起動後にブラウザから http://localhost にアクセスして、phpinfo が表示されれば OK です。

```bash:ローカルホストで実行
docker compose up -d
```

表示が確認できたら、`laravel` ディレクトリ配下にある `public` ディレクトリを削除して、`laravel` ディレクトリを空にしておいてください。空になっていないと、Laravel のインストール時にエラーとなります。


## 2. Laravel のインストール

Laravel の公式マニュアルに従ってインストールを行っていきます。

https://laravel.com/docs/11.x/installation

以下のコマンドを実行して、laravel_vue_tutorial_php のコンテナに接続してください。

```bash:ローカルホストで実行
docker exec -it laravel_vue_tutorial_php bash
```

接続できたら、以下のコマンドを実行して Laravel をインストールしてください。

```bash:Docker コンテナで実行
composer create-project laravel/laravel ./
```

インストールが正常に実行できたら、`laravel` ディレクトリにある `.env` の設定を以下のように変更してください。デフォルトではデータベースとして SQLite を使用する設定となっておりますが、今回は MySQL を使用する設定に変更します。

```diff:laravel/.env
- DB_CONNECTION=sqlite
+ DB_CONNECTION=mysql
+ DB_HOST=laravel_vue_tutorial_mysql
+ DB_PORT=3306
+ DB_DATABASE=tutorial
+ DB_USERNAME=tutorial
+ DB_PASSWORD=password
```

`.env` を変更したら、データベースにテーブルを作成するため以下のコマンドを実行してください。

```bash:Docker コンテナで実行
php artisan migrate
```

また、Nginx が Laravel を実行してログファイル等を作成できるように、以下のコマンドを実行して権限設定を行ってください。

```bash:Docker コンテナで実行
chown -R nginx. /var/www/html/laravel/storage
```

コマンドを実行し終えたら、Laravel の動作確認のためブラウザから http://localhost にアクセスしてください。エラーが表示されず、Laravel の画面が表示されれば OK です。


## 3. Vue のインストール

以下のコマンドを実行して Vue 3 および VueRouter 4 をインストールしてください。また併せて、Laravel 11 で用いられるビルドツール Vite と Vue を連携させるプラグインもインストールしてください。

```bash:Docker コンテナで実行
npm install vue@3 vue-router@4
npm install @vitejs/plugin-vue --save-dev
```

インストールできたら、Vite の設定ファイル `vite.config.js` に以下の内容を追記してください。Vue を利用する設定とローカルホスト側からホットリロード機能を利用するための設定を記述しています。

```diff_javascript:laravel/vite.config.js
import { defineConfig } from "vite";
import laravel from "laravel-vite-plugin";
+ import vue from "@vitejs/plugin-vue";

export default defineConfig({
+ server: {
+   host: "0.0.0.0",
+   hmr: {
+     host: "localhost",
+   },
+ },
  plugins: [
    laravel({
      input: ["resources/css/app.css", "resources/js/app.js"],
      refresh: true,
    }),
+   vue(),
  ],
});

```

各コンポーネントの親要素となるルートコンポーネントを作成します。`laravel/resources/js` ディレクトリに `App.vue` を作成して以下の内容を記述してください。

```vue:laravel/resources/js/App.vue
<template>
  <router-view></router-view>
</template>

<script>
export default {
  name: "App",
};
</script>
```

ルートコンポーネントの子要素となる Vue ファイルを作成します。`laravel/resources/js/pages` ディレクトリに `Index.vue` を作成して以下の内容を記述してください。

```vue:laravel/resources/js/pages/Index.vue
<template>
  <h1>Laravel Vue App</h1>
</template>
```

作成したコンポーネントをインポートして、Vue のインスタンスを作成します。`laravel/resources/js` ディレクトリの `app.js` に以下の内容を記述してください。

```diff_javascript:laravel/resources/js/app.js
- import './bootstrap';
+ import { createApp } from "vue";
+ import { createRouter, createWebHistory } from "vue-router";
+ import App from "./App.vue";
+ import Index from "./pages/Index.vue";

+ const routes = [{ path: "/", component: Index }];

+ const router = createRouter({
+   history: createWebHistory(),
+   routes,
+ });

+ const app = createApp(App);
+ app.use(router);
+ app.mount("#app");
```

作成した Vue インスタンスを HTML 要素にマウントするため、Laravel の blade.php ファイルを作成します。`laravel/resources/views` ディレクトリにある既存の `welcome.blade.php` を削除した後、新たに `index.blade.php` を作成して以下の内容を記述してください。

```php:laravel/resources/views/index.blade.php
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Laravel Vue App</title>
    @vite('resources/js/app.js')
  </head>
  <body>
    <div id="app"></div>
  </body>
  <body></body>
</html>
```

また、作成した `index.blade.php` を画面に表示するため、`laravel/routes` ディレクトリにある `web.php` を以下の記述に変更してください。

```diff_php:laravel/routes/web.php
<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
-   return view('welcome');
+   return view('index');
});
```

ファイル作成および変更がすべて完了したら、以下のコマンドを実行して laravel_vue_tutorial_php のコンテナに接続してください。

```bash:ローカルホストで実行
docker exec -it laravel_vue_tutorial_php bash
```

以下のコマンドを実行してコンパイルを行った後、ブラウザから http://localhost にアクセスしてください。「Laravel Vue App」と画面に表示されれば OK です。

```bash:Docker コンテナで実行
npm run dev
```

## 4. TailwindCSS のインストール

本記事ではなるべくスタイルの調整に費やす時間を減らすため、オープンソースの CSS フレームワークの 1つである TailwindCSS を利用したいと思います。インストール手順は TailwindCSS のサイトに記載されている手順に従って実施します。

https://tailwindcss.com/docs/guides/laravel#vite

以下のコマンドを実行して TailwindCSS のインストールを行ってください。

```bash:Docker コンテナで実行
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
```

コマンドを実行すると `tailwind.config.js` が生成されますので、以下の内容に変更してください。

```diff_javascript:laravel/tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
- content: [],
+ content: [
+   "./resources/**/*.blade.php",
+   "./resources/**/*.js",
+   "./resources/**/*.vue",
+ ],
  theme: {
    extend: {},
  },
  plugins: [],
};
```

変更できたら、`laravel/resources/css` ディレクトリの `app.css` に以下の内容を追記してください。

```diff_css:laravel/resources/css/app.css
+ @tailwind base;
+ @tailwind components;
+ @tailwind utilities;
```

`app.css` を読み込むため、`index.blade.php` を以下の内容に変更してください。

```diff_php:laravel/resources/views/index.blade.php

  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Laravel Vue App</title>
-   @vite('resources/js/app.js')
+   @vite(['resources/js/app.js','resources/css/app.css'])
  </head>
```

上記で TailwindCSS の利用準備が整いました。動作確認のため、Index.vue を以下のように変更した後、ブラウザから http://localhost にアクセスしてください。「Laravel Vue App」の文字が赤文字になっていれば OK です。

```diff_vue:laravel/resources/js/pages/Index.vue
<template>
- <h1>Laravel Vue App</h1>
+ <h1 class="text-red-600">Laravel Vue App</h1>
</template>
```

## 5. ポケモン一覧の作成

準備が整ったので、ここからはアプリケーションの中身を作成していきます。
本手順では以下の画面のようにポケモンの一覧を表示できるように作成を進めていきます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/5af2026e-43aa-8f52-64c2-e4b410c89c39.png" width="500" />

まずは、実際の画面に表示される HTML 要素を Vue ファイルに記述します。
先ほど作成した `laravel/resources/js/pages` ディレクトリの `Index.vue` を削除した後、新たに `PokemonList.vue` を作成して以下の内容を記述してください。

```vue:laravel/resources/js/pages/PokemonList.vue
<template>
  <div class="p-6">
    <h1 class="text-3xl font-black">ポケモン一覧</h1>
    <div class="grid py-6 lg:px-8">
      <ul role="list" class="grid gap-x-8 gap-y-4 sm:grid-cols-3 xl:col-span-2">
        <li v-for="pokemon in pokemons" :key="pokemon.name">
          <div class="flex items-center">
            <img class="h-24 w-24 rounded-full" :src="pokemon.imageUrl" />
            <span class="text-base font-semibold text-gray-900 ml-4">
              {{ pokemon.name }}
            </span>
          </div>
        </li>
      </ul>
    </div>
  </div>
</template>
```

上記のままではポケモンの情報を取得できていないため、画面にポケモンは表示されません。
ポケモンの情報を取得するため、PokeAPI を呼び出す処理を記述します。先ほど作成した `PokemonList.vue` に以下の内容を追記してください。

```vue:laravel/resources/js/pages/PokemonList.vue
<script>
import axios from "axios";

const DEFAULT_OFFSET = 0;
const DEFAULT_LIMIT = 20;

export default {
  data() {
    return {
      pokemons: [],
    };
  },

  async created() {
    await this.fetchPokemonList();
  },

  computed: {
    offset() {
      return Number(this.$route.query.offset || DEFAULT_OFFSET);
    },
    limit() {
      return Number(this.$route.query.limit || DEFAULT_LIMIT);
    },
  },

  methods: {
    /**
     * 一覧表示するポケモンのリストを取得
     */
    async fetchPokemonList() {
      const { data } = await axios.get("https://pokeapi.co/api/v2/pokemon", {
        params: { offset: this.offset, limit: this.limit },
      });

      this.pokemons = [];
      for (const result of data.results) {
        const imageUrl = await this.fetchPokemonImage(result.name);
        const name = await this.fetchPokemonName(result.name);
        this.pokemons.push({ name, imageUrl });
      }
    },

    /**
     * ポケモンの画像を取得
     */
    async fetchPokemonImage(name) {
      const url = `https://pokeapi.co/api/v2/pokemon/${name}`;
      const { data } = await axios.get(url);
      return data?.sprites?.other?.["official-artwork"]?.front_default;
    },

    /**
     * ポケモンの名前を取得
     */
    async fetchPokemonName(name) {
      const url = `https://pokeapi.co/api/v2/pokemon-species/${name}`;
      const { data } = await axios.get(url);
      return data.names.find((name) => name.language.name === "ja").name;
    },
  },
};
</script>
```

ポケモンの情報を取得する処理の内容について詳しく説明します。まず最初に created プロパティで methods プロパティに定義している `fetchPokemonList()` が実行されます。

```javascript
  async created() {
    await this.fetchPokemonList();
  },
```

`fetchPokemonList()` では画面に表示するポケモン一覧を取得するため、axios を用いて PokeAPI にリクエストを送信しています。リクエストのパラメータとしては `offset` と `limit` を設定しており、これらのパラメータを指定することで一覧取得したいポケモンを制御することが可能です。

```javascript
  computed: {
    offset() {
      return Number(this.$route.query.offset || DEFAULT_OFFSET);
    },
    limit() {
      return Number(this.$route.query.limit || DEFAULT_LIMIT);
    },
  },
  
  methods: {
    async fetchPokemonList() {
      const { data } = await axios.get("https://pokeapi.co/api/v2/pokemon", {
        params: { offset: this.offset, limit: this.limit },
      });
```

表示したいポケモン一覧が取得できたのですが、先ほどの API ではポケモンの名前（日本語）や画像に関する情報が含まれておりません。そこで、一覧に含まれる各ポケモンごとに異なる API を実行して、ポケモンの名前（日本語）と画像の URL を取得しています。

:::note info
本記事では PokeAPI についての詳細な説明は省略させていただきます。ご了承ください。
:::

```javascript
    async fetchPokemonList() {
      ~ 省略 ~ 
      
      this.pokemons = [];
      for (const result of data.results) {
        const imageUrl = await this.fetchPokemonImage(result.name);
        const name = await this.fetchPokemonName(result.name);
        this.pokemons.push({ name, imageUrl });
      }
    },

    async fetchPokemonImage(name) {
      const url = `https://pokeapi.co/api/v2/pokemon/${name}`;
      const { data } = await axios.get(url);
      return data?.sprites?.other?.["official-artwork"]?.front_default;
    },

    async fetchPokemonName(name) {
      const url = `https://pokeapi.co/api/v2/pokemon-species/${name}`;
      const { data } = await axios.get(url);
      return data.names.find((name) => name.language.name === "ja").name;
    },
```

これで一覧に取得するポケモンの情報を取得することができました。

次に、作成した `PokemonList.vue` を Vue Router に認識させるため、`laravel/resources/js` ディレクトリの `app.js` の内容を以下のように変更してください。

```diff_javascript:laravel/resources/js/app.js
- import Index from "./pages/Index.vue";
+ import PokemonList from "./pages/PokemonList.vue";

- const routes = [{ path: "/", component: Index }];
+ const routes = [{ path: "/pokemons", component: PokemonList }];
```

また併せて、Laravel 側のルーティング定義を記述する `laravel/routes` ディレクトリの `web.php` も以下のように変更してください。

```diff_php:laravel/routes/web.php
- Route::get('/', function () {
+ Route::get('/pokemons', function () {
    return view('index');
});
```

上記のファイルの作成および変更がすべて完了したら、ブラウザから http://localhost/pokemons にアクセスしてください。以下の画面が表示されていれば OK です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/5af2026e-43aa-8f52-64c2-e4b410c89c39.png" width="500" />

## 6. お気に入り登録機能の作成（Laravel）

お気に入り登録したポケモンのデータを保存しておくためにはデータベース（MySQL）を用いる必要がありますので、Laravel を用いてデータの操作を行いたいと思います。

まずはデータベースにテーブルを作成するマイグレーションファイルを作成します。以下のコマンドを実行してください。

```bash:Docker コンテナで実行
php artisan make:migration create_favorites_table
```

実行できたら、`laravel/database/migrations` ディレクトリに`YYYY_MM_DD_hhmmss_create_favorites_table.php` が作成されているので、以下の内容を追記してください。

:::note info
本記事ではポケモンの英語名が一意である前提で進めていきます。
:::

```diff_php:laravel/database/migrations/YYYY_MM_DD_hhmmss_create_favorites_table.php
<?php
    public function up(): void
    {
        Schema::create('favorites', function (Blueprint $table) {
            $table->id();
+           $table->string('pokemon_english_name')->unique();
            $table->timestamps();
        });
    }
```

上記で記述した favorites テーブルを作成するため、以下のコマンドを実行してください。

```bash:Docker コンテナで実行
php artisan migrate
```

これでテーブル作成が完了しました。テーブルが作成されたことを確認したい場合は http://localhost:8080 にアクセスし、phpMyAdmin を用いて確認を行ってください。

次に作成したテーブルに対応する Model を作成します。以下のコマンドを実行してください。

```bash:Docker コンテナで実行
php artisan make:model Favorite
```

実行できたら、`laravel/app/Models` ディレクトリに`Favorite.php` が作成されているので、以下の内容を追記してください。

```diff_php:laravel/app/Models/Favorite.php
class Favorite extends Model
{
    use HasFactory;

+   protected $fillable = ['pokemon_english_name'];
}
```

Model が作成できたらお気に入り登録処理を記述する Controller を作成するため、以下のコマンドを実行してください。

```bash:Docker コンテナで実行
php artisan make:controller PokemonController
```

実行できたら、`laravel/app/Http/Controllers` ディレクトリに `PokemonController.php` が作成されているので、以下の内容を記述してください。

Controller には以下の3つの処理を記述しています。
- お気に入り登録したポケモンの英語名を一括出力する `fetchFavorites()`
- 指定されたポケモンをお気に入りに登録する `addFavorite()`
- 指定されたポケモンをお気に入りから解除する `deleteFavorite()`

```php:laravel/app/Http/Controllers/PokemonController.php
<?php

namespace App\Http\Controllers;

use App\Models\Favorite;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class PokemonController extends Controller
{
    /**
     * お気に入り取得
     */
    public function fetchFavorites(Request $request): JsonResponse
    {
        $favorites = Favorite::get()->pluck('pokemon_english_name');
        return response()->json($favorites);
    }

    /**
     * お気に入り登録
     */
    public function addFavorite(Request $request): JsonResponse
    {
        Favorite::updateOrCreate(
            ['pokemon_english_name' => $request->pokemon_english_name],
            ['pokemon_english_name' => $request->pokemon_english_name]
        );
        return response()->json();
    }

    /**
     * お気に入り解除
     */
    public function deleteFavorite(Request $request): JsonResponse
    {
        Favorite::where('pokemon_english_name', $request->pokemon_english_name)->delete();
        return response()->json();
    }
}
```

作成した Controller を呼び出す API のルーティングを定義します。以下のコマンドを実行して、API のルーティングを定義するためのファイル `api.php` を作成してください。

```bash:Docker コンテナで実行
php artisan install:api

One new database migration has been published. Would you like to run all pending database migrations? (yes/no) [yes]:
 > yes
```

`laravel/routes` ディレクトリに `api.php` が作成できたら、以下の記述に変更してください。

```diff_php:laravel/routes/api.php
- Route::get('/user', function (Request $request) {
-     return $request->user();
- })->middleware('auth:sanctum');
- 
+ Route::get('/pokemons/favorites', [App\Http\Controllers\PokemonController::class, 'fetchFavorites']);
+ Route::post('/pokemons/add-favorite', [App\Http\Controllers\PokemonController::class, 'addFavorite']);
+ Route::post('/pokemons/delete-favorite', [App\Http\Controllers\PokemonController::class, 'deleteFavorite']);
```

これで Laravel でお気に入り登録／解除を行う API が作成できました。

## 7. お気に入り登録機能の作成（Vue）

先ほど Laravel で作成した お気に入り登録／解除を行う API を用いて、画面からお気に入り登録／解除の操作を行えるようにします。

`PokemonList.vue` に以下の内容を追記してください。

```diff_vue:laravel/resources/js/pages/PokemonList.vue
<template>
  <div class="p-6">
    <h1 class="text-3xl font-black">ポケモン一覧</h1>
    <div class="grid py-6 lg:px-8">
      <ul role="list" class="grid gap-x-8 gap-y-4 sm:grid-cols-3 xl:col-span-2">
        <li v-for="pokemon in pokemons" :key="pokemon.name">
          <div class="flex items-center">
            <img class="h-24 w-24 rounded-full" :src="pokemon.imageUrl" />
            <span class="text-base font-semibold text-gray-900 ml-4">
              {{ pokemon.name }}
            </span>
+           <template v-if="isIncludeFavorites(pokemon.englishName)">
+             <svg
+               xmlns="http://www.w3.org/2000/svg"
+               class="h-8 w-8 text-yellow-500 ml-3"
+               viewBox="0 0 24 24"
+               fill="currentColor"
+               stroke="none"
+               @click="deleteFavorite(pokemon)"
+             >
+               <path
+                 d="M12 17.27L18.18 21l-1.45-6.36L22 9.27l-6.36-.55L12 2 8.36 8.73 2 9.27l4.27 5.37L4.82 21z"
+               />
+             </svg>
+           </template>
+           <template v-else>
+             <svg
+               xmlns="http://www.w3.org/2000/svg"
+               class="h-8 w-8 text-gray-300 ml-3"
+               viewBox="0 0 24 24"
+               fill="none"
+               stroke="currentColor"
+               @click="addFavorite(pokemon)"
+             >
+               <path
+                 d="M12 17.27L18.18 21l-1.45-6.36L22 9.27l-6.36-.55L12 2 8.36 8.73 2 9.27l4.27 5.37L4.82 21z"
+               />
+             </svg>
+           </template>
          </div>
        </li>
      </ul>
    </div>
  </div>
+ <div
+   class="fixed top-8 right-8 border border-black bg-white rounded shadow-lg py-4 px-6"
+   :class="{ hidden: isModalHidden }"
+ >
+   <p>{{ modalMessage }}</p>
+ </div>
</template>

<script>

~ 省略 ~ 

export default {
  data() {
    return {
      pokemons: [],
+     favoriteNames: [],
+     isModalHidden: true,
+     modalMessage: "",
+     modalTimeout: null,
    };
  },

  async created() {
    await this.fetchPokemonList();
+   await this.fetchFavorites();
  },

  ~ 省略 ~ 

  methods: {
    /**
     * 一覧表示するポケモンのリストを取得
     */
    async fetchPokemonList() {
      const { data } = await axios.get("https://pokeapi.co/api/v2/pokemon", {
        params: { offset: this.offset, limit: this.limit },
      });

      this.pokemons = [];
      for (const result of data.results) {
        const imageUrl = await this.fetchPokemonImage(result.name);
        const name = await this.fetchPokemonName(result.name);
-       this.pokemons.push({ name, imageUrl });
+       this.pokemons.push({ name, imageUrl, englishName: result.name });
      }
    },

    ~ 省略 ~ 

+   /**
+    * お気に入り取得
+    */
+   async fetchFavorites() {
+     const { data } = await axios.get("/api/pokemons/favorites");
+     this.favoriteNames = data;
+   },
+
+   /**
+    * お気に入り登録／解除メッセージ表示
+    */
+   showModal(message) {
+     this.modalMessage = message;
+     this.isModalHidden = false;
+     if (this.modalTimeout !== null) {
+       clearTimeout(this.modalTimeout);
+     }
+     this.modalTimeout = setTimeout(() => {
+       this.isModalHidden = true;
+     }, 3000);
+   },
+
+   /**
+    * お気に入り登録されているか
+    */
+   isIncludeFavorites(englishName) {
+     return this.favoriteNames.some(
+       (favoriteName) => favoriteName === englishName
+     );
+   },
+
+   /**
+    * お気に入り登録
+    */
+   async addFavorite(addPokemon) {
+     const { status } = await axios.post("/api/pokemons/add-favorite", {
+       pokemon_english_name: addPokemon.englishName,
+     });
+     if (status === 200) {
+       this.favoriteNames.push(addPokemon.englishName);
+       this.showModal(`${addPokemon.name}をお気に入り登録しました。`);
+     }
+   },
+
+   /**
+    * お気に入り解除
+    */
+   async deleteFavorite(deletePokemon) {
+     const { status } = await axios.post("/api/pokemons/delete-favorite", {
+       pokemon_english_name: deletePokemon.englishName,
+     });
+     if (status === 200) {
+       this.favoriteNames = this.favoriteNames.filter(
+         (favoriteName) => favoriteName !== deletePokemon.englishName
+       );
+       this.showModal(`${deletePokemon.name}をお気に入り解除しました。`);
+     }
+   },
  },
};
</script>
```

画面からお気に入り登録／解除の操作を行うための処理内容について詳しく説明します。

ポケモンの名前の右側に表示される星の画像をクリックしたタイミングで処理を実行するため、SVG 要素にクリックイベントを設定しております。無色の星をクリックした場合はお気に入り登録、黄色の星をクリックした場合はお気に入り解除を行います。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/6750e527-0098-5279-24db-f4c5c2b975a0.png" width="500" />

お気に入り登録の処理は以下の通りです。
1. Laravel のお気に入りポケモン登録 API を呼び出す
2. 成功レスポンスが返却されたら、data プロパティで保持している `favoriteNames` の配列にポケモンの英語名を追加する
3. 成功メッセージを一定時間（3秒）表示する

```javascript
    async addFavorite(addPokemon) {
      const { status } = await axios.post("/api/pokemons/add-favorite", {
        pokemon_english_name: addPokemon.englishName,
      });
      if (status === 200) {
        this.favoriteNames.push(addPokemon.englishName);
        this.showModal(`${addPokemon.name}をお気に入り登録しました。`);
      }
    },
    
    showModal(message) {
      this.modalMessage = message;
      this.isModalHidden = false;
      if (this.modalTimeout !== null) {
        clearTimeout(this.modalTimeout);
      }
      this.modalTimeout = setTimeout(() => {
        this.isModalHidden = true;
      }, 3000);
    },
```

お気に入り解除の処理は以下の通りです。
1. Laravel のお気に入りポケモン解除 API を呼び出す
2. 成功レスポンスが返却されたら、data プロパティで保持している `favoriteNames` の配列から削除したポケモン以外の英語名を抽出して `favoriteNames` に上書きする
3. 成功メッセージを一定時間（3秒）表示する

```javascript
    async deleteFavorite(deletePokemon) {
      const { status } = await axios.post("/api/pokemons/delete-favorite", {
        pokemon_english_name: deletePokemon.englishName,
      });
      if (status === 200) {
        this.favoriteNames = this.favoriteNames.filter(
          (favoriteName) => favoriteName !== deletePokemon.englishName
        );
        this.showModal(`${deletePokemon.name}をお気に入り解除しました。`);
      }
    },
```

また、お気に入り登録されているポケモンは初期表示の時点で星を黄色にする必要があるため、created プロパティで `fetchFavorites()` を呼び出して、Laravel のお気に入りポケモン取得 API を実行しています。
また、星の配色（無色と黄色）を切り替えるために `isIncludeFavorites()` を用いて `favoriteNames` に該当のポケモンの英語名が含まれているかどうかを判定しています。

```javascript
    async fetchFavorites() {
      const { data } = await axios.get("/api/pokemons/favorites");
      this.favoriteNames = data;
    },

    isIncludeFavorites(englishName) {
      return this.favoriteNames.some(
        (favoriteName) => favoriteName === englishName
      );
    },
```

これでお気に入り登録機能を作成することができました。
動作確認のため、ブラウザから http://localhost/pokemons にアクセスして お気に入り登録／解除を行ってください。星の配色が切り替わり、メッセージが表示されれば OK です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/2d9c132d-bacc-6ccd-f085-8ee785ca4048.png" width="500" />

# 追加課題

本記事で作成したアプリケーションには不足している機能が多くあります。
チュートリアルの応用として、以下のような機能を追加で作成してみてください。

## 追加機能例

### ページング機能
現状では最初の20匹しか表示されないため、21匹目以降も表示可能とする。

### お気に入りポケモン一覧表示
お気に入り登録したポケモンのみを一覧表示する機能を作成する。

### 表示件数変更
現状では20匹ずつ表示しているが、ユーザーが任意に表示件数を変更可能とする。

# GitHub

完成版のプロジェクトはこちら

https://github.com/falya128/laravel-vue-tutorial