Laravel vue3 サンプル作成

## Laravel:バックエンド vue:フロントエンドを同一プロジェクトで
Laravelの学習を進めて行くうちに、javascriptのフレームワークであるvue3が利用できることを知りました。そこで実際にCRUDするwebアプリを作ってみたのですが、直の参考書は存在せず、参考とするTipsではエコシステムの変更（mixからviteへ）など思いのほか壁に当たりましたので、同じことを思う方へと公開することにしました。

構成としてはLaravelのコントローラーとしてrest apiを実装し、viewに相当する部分をvueで実装しています。またvueのライブラリとしてuiにelement plusaを使用しています。
フィルター機能の実装にJSライブラリのlodashを使用しています。(23.7.31追記)

以下がサンプルの動作イメージになります。
<details>

![Laravel-Vite-Vue3-Google-Chrome-2023-06-27-22-04-40.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94957/7f319374-2fd0-e11a-6d00-ea56dedd8461.gif)

</details>

テキストフィルター機能を追加してみました。

<details>

![Laravel-Vite-Vue3-Google-Chrome-2023-07-31-22-20-45.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94957/f5b2039c-d5b8-bb29-c434-04d4095a18b0.gif)

</details>

ソースは以下です。
https://github.com/BlueMoonAz/fruits/tree/no_auth

ソースはComposition API/javascriptにて記述しています。
また、以下については事前に用意頂く必要が有ります。

phpインストール
composerインストール
nodejsインストール
DataBaseインストール及び、DB作成、アカウント作成、接続設定

### 環境

Windows11
laravel10

各ライブラリについてはソースのcomposer.json,package.jsonを参照ください

### プロジェクト作成
WPS(WindowsPowerShell)にてプロジェクトを作成したいディレクトリに移動して以下コマンドを実行

```WPS
composer create-project --prefer-dist laravel/laravel fruits
```
fruitsはプロジェクト名です。
プロジェクトディレクトリに移動して以下コマンドを実行
```WPS
npm install @vitejs/plugin-vue --save-dev
```
viteプラグインがインストールされました。

### プロジェクト起動
インストールの成功を確認するため、プロジェクトを起動してみます。viteとartisan serveを、それぞれ別のShell画面で起動します。以下コマンドでviteを起動します。
```WPS
npm run dev
```
別のShell画面（または別tab）を開き、以下コマンドでartisan serveを起動します。

```WPS
php artisan serve
```

ブラウザでlocalhost:8000にアクセスしてLaravel初期画面が表示されることを確認します。

### vue準備
laravelプロジェクト内でvueを使用可能とするための準備をします。
まずvue pluginを有効にするため、vite.config.jsを編集します。
```js:vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue'; //追加

export default defineConfig({
    plugins: [
        vue(),  //追加
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
    ],
});
```
Laravelが最初に呼び出すbladeファイルの作成とルーティングを設定します。app.blade.phpを新規作成し、web.phpも編集します。
```php:resources/views/app.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Laravel Vite Vue3</title>
        @vite(['resources/css/app.css', 'resources/js/app.js'])
    </head>
    <body>
        <div id="app">
        </div>
    </body>
</html>
```
```php:routes/web.php
<?php
use Illuminate\Support\Facades\Route;
/*Route::get('/', function () { コメントアウト
    return view('welcome');
});*/
Route::get('/{any}', function() {
    return view('app');
})->where('any', '.*');
```
続いてbladeファイルから呼び出されるapp.jsを編集します。app.jsではアプリケーションインスタンスのもととなるApp.vueファイルを指定しているので作成・編集します。

```js:resources/js/app.js
import './bootstrap';
/* 以下追記 */
import { createApp } from 'vue';
import App from './App.vue';

const app = createApp(App);
app.mount('#app');
```
```html:resources/js/App.vue
<script setup>
import List from './Pages/Fruits/List.vue'
</script>

<template>
    <List></List>
</template>
```
動作確認のため、List.vueには簡単なテキスト表示構文を書いてみます。
```html:resources/js/Pages/Fruits/List.vue
<script setup>
const testmsg='This is List.';
</script>

<template>
   <span v-text="testmsg"></span>
</template>
```
ここまでできましたらプロジェクトを起動し、localhost:8000をブラウザでアクセスしてみます。This is Listのメッセージが表示されれば、vueを利用する環境が整いました。

うまくいかない場合は、以下を確認してみます。
* ブラウザ画面 エラー表示
* npm エラー表示
* php artisan エラー表示

また、ブラウザが空白表示となる場合はブラウザのデベロッパーツールのログにエラーが表示されていることが有ります。

### DB migration
テーブルを作成するため以下migrationファイルを作成します。

<details>

```php:database/migrations/2023_04_22_122840_create_fruits_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('fruits', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->integer('price')->nullable();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('fruits');
    }
};
```
</details>
<details>

```php:database/migrations/2023_05_01_124252_add_unique_const_column_name_table_fruits.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::table('fruits', function (Blueprint $table) {
            //
            $table->unique(['name']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('fruits', function (Blueprint $table) {
            //
            $table->dropUnique(['name']);
        });
    }
};
```
</details>

ファイルの編集保存ができたらShell画面より、以下コマンドでマイグレーションを実行します。
```
php artisan migrate
```
modelファイルを作成し、サンプルデータ作成のためseedファイルの作成と編集を行います。
<details>

```php:app/Models/Fruit.php
<?php

namespace App\Models;

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

class Fruit extends Model
{
    use HasFactory;
    protected $fillable = [
        'name',
        'price',
    ];
}
```

</details>



<details>

```php:database/seeders/FruitsTableSeeder.php
<?php

namespace Database\Seeders;

use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class FruitsTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //
        DB::insert('insert into fruits (name, price) values (?, ?)', ['りんご', NULL]);
        DB::insert('insert into fruits (name, price) values (?, ?)', ['メロン', 700]);
        DB::insert('insert into fruits (name, price) values (?, ?)', ['みかん', 300]);
        DB::insert('insert into fruits (name, price) values (?, ?)', ['バナナ', 400]);
        DB::insert('insert into fruits (name, price) values (?, ?)', ['苺',  400]);
        DB::insert('insert into fruits (name, price) values (?, ?)', ['ビワ', 600]);
        DB::insert('insert into fruits (name, price) values (?, ?)', ['キウイフルーツ', 450]);
        DB::insert('insert into fruits (name, price) values (?, ?)', ['もも',300]);
        DB::insert('insert into fruits (name, price) values (?, ?)', ['マスクメロン',1800]);
        DB::insert('insert into fruits (name, price) values (?, ?)', ['スイカ',1100]);
        DB::insert('insert into fruits (name, price) values (?, ?)', ['梨',300]);
        DB::insert('insert into fruits (name, price) values (?, ?)', ['パイナップル',500]);
        DB::insert('insert into fruits (name, price) values (?, ?)', ['ぶどう',500]);
        DB::insert('insert into fruits (name, price) values (?, ?)', ['マンゴー',700]);
        DB::insert('insert into fruits (name, price) values (?, ?)', ['パッションフルーツ',300]);
    }
}
```
</details>
<details>

```php:database/seeders/DatabaseSeeder.php
<?php

namespace Database\Seeders;

// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     */
    public function run(): void
    {
        // \App\Models\User::factory(10)->create();

        // \App\Models\User::factory()->create([
        //     'name' => 'Test User',
        //     'email' => 'test@example.com',
        // ]);
        $this->call([FruitsTableSeeder::class]);//追記

    }
}
```
</details>
Shell画面より、以下コマンドでseederを実行します。

```
php artisan db:seed
```
fruitsテーブルが作成され、サンプルデータが追加されます。
tinkerコマンドやDBツールなどで確認してみてください。

### api作成
apiの機能を新規controllerファイルに記述します。DBアクセスにEloquentを使用しています。メソッドは一覧(list)、追加(create)、更新(update)、削除(delete)の４つです。また、api.phpでルーティングを設定します。
<details>

```php:app/Http/Controllers/FruitsController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\Fruit;

class FruitsController extends Controller
{
    //
    public function list()
    {
        return Fruit::orderBy('id')->paginate(10);
    }

    public function update(Request $request,Fruit $fruit)
    {
        $fruit->update($request->all());

        return $fruit;
    }

    public function create(Request $request)
    {
        return Fruit::create($request->all());
    }

    public function delete(Fruit $fruit)
    {
        $fruit->delete();
        return $fruit;
    }

}
```
</details>
listで使用しているpaginateによって、ページサイズ（今回は10）の分だけデータが返ります。pageはリクエストで指定すればよく、レスポンスにはデータ全数などページングに必要な情報が付加されます。
<details>

```php:routes/api.php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

use App\Http\Controllers\FruitsController;  //追記

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "api" middleware group. Make something great!
|
*/

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

/* 以下追記 */
Route::get('/list',[FruitsController::class,'list']);
Route::put('/update/{fruit}',[FruitsController::class,'update']);
Route::post('/create',[FruitsController::class,'create']);
Route::delete('/delete/{fruit}',[FruitsController::class,'delete']);
```
</details>
Shellでrouter:listコマンドを実行し、定義が反映されていることを確認します。
<details>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94957/eecfa047-ef14-8458-1346-af638cb22a0e.png)
</details>
以上できましたらプロジェクトを起動し、ブラウザからlocalhost:8000/api/listをアクセスしてみます。成功すれば、日本語部分は文字化けしますがjson形式のデータが表示されます。

### 一覧画面作成
前準備としてelement plusをnpmコマンドでインストールします。この時に依存関係があるためvue本体も同時にインストールします。
```
npm install vue element-plus
```
app.jsを編集してelemen plusを有効化します。
```php:resources/js/app.js
import './bootstrap';

import { createApp } from 'vue';
import App from './App.vue';
import ElementPlus from 'element-plus'; //追記
import 'element-plus/dist/index.css'; //追記

const app = createApp(App);

app.use(ElementPlus); //追記

app.mount('#app');
```
一覧画面を作成します。List.vueファイルを以下書き替えます。
<details>

```php:resources/js/Pages/Fruits/List.vue
<script setup>
import { onMounted,ref } from 'vue';

const items = ref(null);
const itemsTotal = ref(0);
const page = ref(1);

const setPage = (val)=>{
  page.value = val;
  reLoadItems();
}

import axios from 'axios';
const reLoadItems = ()=>{
  axios.get('/api/list',{
                params:{
                    page:page.value
                }
            })
            .then((res)=>{
              items.value = res.data.data;
              itemsTotal.value = res.data.total;
            });
}

onMounted(()=>{
    reLoadItems();
})
</script>

<template>
    <el-table :data="items" style="width: 100%">
    <el-table-column prop="id" label="id" width="80" />
    <el-table-column prop="name" label="名称" />
    <el-table-column prop="price" label="価格" width="100"
      header-align="center" align="right">
      <template #default="scope">
        <div v-if="scope.row.price" >
          {{scope.row.price.toLocaleString()}}
        </div>
      </template>
    </el-table-column>
    <el-table-column fixed="right" label="操作" width="120">
      <template #default="scope">
        <el-button link type="primary" >
          編集
        </el-button>
        <el-button link type="primary" >
          削除
        </el-button>
      </template>
    </el-table-column>
  </el-table>
  <el-pagination layout="prev, pager, next" :total="itemsTotal" @current-change="setPage"></el-pagination>
</template>
```
</details>

* ページ生成のタイミングで、axiosを使用してapiからデータを取得します。URLに"?page=n"を指定することで該当pageのデータを取得します。コーディングではparamsで指定しています。
* 取得したデータは変数itemsに格納し、同じくapiから取得した全データ数をitemsTotalに格納しています。
* priceをカンマ区切りで表示したいので、toLocaleStringを使用しています。
* 編集、削除機能はこの時点では未実装です。

ファイルが編集できましたら、プロジェクトを起動し、localhost:8000をアクセスします。成功すれば一覧画面が表示されます。うまくいかない場合は前記同様にエラー情報を確認してみてください。

### 登録画面作成
Add.vueを新規作成します。
<details>

```php:resources/js/Pages/Fruits/Add.vue
<script setup>
import {reactive,ref,defineEmits} from 'vue'
import axios from 'axios';
import {ElNotification} from 'element-plus'

const isVisible=ref(false);
const form=reactive({
  name:'',
  price:null,
});
const emit = defineEmits(['reLoad']);
const create=()=>{
  axios.post('/api/create',form)
    .then((res)=>{
      ElNotification({
        title: 'Success',
        message: form.name+'を作成しました',
        type: 'success',
      })
      emit('reLoad');
      isVisible.value=false;
    }).catch((error)=>{
      ElNotification({
        title: 'Error',
        message: form.name+'の作成に失敗しました',
        type: 'error',
      })
    });
}

const open=()=>{
  isVisible.value=true;
}
defineExpose({
  open,
})
</script>

<template>
<el-dialog v-model="isVisible" title="フルーツ作成">
    <el-form :model="form">
      <el-form-item label="名称" :label-width="140">
        <el-input v-model="form.name" autocomplete="off" />
      </el-form-item>
      <el-form-item label="価格" :label-width="140">
        <el-input v-model="form.price" autocomplete="off" />
      </el-form-item>
    </el-form>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="create" >作成</el-button>
        <el-button @click="isVisible = false">取消</el-button>
      </span>
    </template>
  </el-dialog> 
</template>
```
</details>

* モーダル・ダイアログで実装しています。変数isVisibleで表示・非表示をコントロールします。
* openメソッドは本画面を出現させます。親コンポーネントから呼べるようにdefineExposeします。
* 登録の成否を通知するのにElNotificationを使用しています。axiosは非同期処理なので、通信の終了後に表示させるためにthenとcatchの中で使用します。
* 登録終了後、親画面が再表示されるようにemitを使用して親コンポーネントのメソッドを呼び出しています。

一覧画面から呼び出せるようにList.vueにボタンを追加します。
<details>

```php:resources/js/Pages/Fruits/List.vue
<script setup>
import Add from '@/Pages/Fruits/Add.vue' //追加
const addRef = ref();　//追加
<setup>
<template>
  <!--以下追加-->
  <el-button @click="addRef.open()">
    新規作成
  </el-button>
  <Add ref="addRef" @reLoad="reLoadItems"></Add>
</template>
```
</details>

* Add.vueのimportとタグの追加、そしてコンポーネントのメソッドが呼べるように参照(ref)を定義しています。
* コンポーネント側からもメソッドが呼べるようにreLoadを定義しています。
* ボタンのクリックイベントで参照を経由してopenメソッドを呼んでいます。
### 編集画面作成
仕組としては、「編集対象のデータをopenメソッド引数として受け取る」以外は登録画面と大きく変わりません。Edit.vueを作成します。
<details>

```php:resources/js/Pages/Fruits/Edit.vue
<script setup>
import {reactive,ref,defineEmits} from 'vue'
import axios from 'axios';
import {ElNotification} from 'element-plus'

const emit = defineEmits(['reLoad']);
const isVisible=ref(false);
const form=reactive({
  id:null,
  name:'',
  price:null,
});
const update=()=>{
  axios.put('/api/update/'+form.id,form)
    .then((res)=>{
      ElNotification({
        title: 'Success',
        message: form.name+'を更新しました',
        type: 'success',
      })
      emit('reLoad');
    }).catch((error)=>{
      ElNotification({
        title: 'Error',
        message: form.name+'の更新に失敗しました',
        type: 'error',
      })
    });
}

const open=(item)=>{
  form.id=item.id;
  form.name=item.name;
  form.price=item.price;
  isVisible.value=true;
}
defineExpose({
  open,
})
</script>

<template>
<el-dialog v-model="isVisible" title="フルーツ編集">
    <el-form :model="form">
      <el-form-item label="id" :label-width="140">
        {{ form.id }}
      </el-form-item>
      <el-form-item label="名称" :label-width="140">
        <el-input v-model="form.name" autocomplete="off" />
      </el-form-item>
      <el-form-item label="価格" :label-width="140">
        <el-input v-model="form.price" autocomplete="off" />
      </el-form-item>
    </el-form>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="update" >更新</el-button>
        <el-button @click="isVisible = false">取消</el-button>
      </span>
    </template>
  </el-dialog> 
</template>
```
</details>
一覧画面から呼び出せるようにList.vueを編集します。
<details>

```php:resources/js/Pages/Fruits/List.vue
<script setup>
import Edit from '@/Pages/Fruits/Edit.vue'　//追加
const editRef = ref();　//追加
<setup>
<template>
  <el-table-column fixed="right" label="操作" width="120">
      <template #default="scope">
        <el-button link type="primary" 
          @click.prevent="editRef.open(items[scope.$index])"><!--編集-->
          編集
        </el-button>
      </template>
    </el-table-column>
  <Edit ref="editRef" @reLoad="reLoadItems"></Edit><!--追加-->
</template>
```
</details>

* el-tableから行に該当するデータ（編集対象）を特定するためにはtemplateタグでcolumnを展開する必要が有ります。
* templateで定義した記述子scopeの$indexメソッドをitemsのインデックスとすれば、編集対象のデータにアクセスできます。これをopenメソッドの引数として渡します。

### 削除画面作成

簡単な画面（確認と実行）になるので、scriptタグのみで実装します。
<details>

```js:resources/js/Pages/Fruits/Delete.vue
<script setup>

import axios from 'axios';
import {ElNotification,ElMessageBox } from 'element-plus';
import {defineEmits} from 'vue';

const emit = defineEmits(['reLoad']);

const open=(item)=>{
  
        ElMessageBox.confirm(
          item.name+'を削除します。よろしいですか?',
          'Warning',
          {
            confirmButtonText: 'OK',
            cancelButtonText: 'Cancel',
            type: 'warning',
          }
        )
          .then(() => {
            axios.delete('/api/delete/'+item.id)
              .then((res)=>{
                ElNotification({
                  type: 'success',
                  message: item.name+'を削除しました',
                });
                emit('reLoad');
              }).catch((error)=>{
                ElNotification({
                  type: 'error',
                  message: item.name+'の削除に失敗しました',
                });
              })
          })
          .catch(() => {
            ElNotification({
              type: 'info',
              message: '削除をキャンセルしました',
            });
          });
}

defineExpose({
  open,
})
</script>
```
</details>
一覧画面から呼び出せるようにList.vueを編集します。
<details>

```php:resources/js/Pages/Fruits/List.vue
<script setup>
import Delete from '@/Pages/Fruits/Delete.vue' //追加
const deleteRef = ref(); //追加
<script>
<template>
    <el-button link type="primary"
 @click.prevent="deleteOpen(items[scope.$index])" <!--追加-->
    > 
      削除
    </el-button>

    <Delete ref="deleteRef" @reLoad="reLoadItems"></Delete> <!--追加-->
</template
```
</details>

### フィルタ機能実装
テキスト入力でフィルタリングする機能を実装します。
まずコントローラの修正です。リクエストのパラメータに'filter'が無いかチェックし、ある場合はwhereメソッドでテキストの前後部分一致条件で検索するよう修正します。

<details>

```app/Http/Controllers/FruitsController.php
    public function list(Request $request)
    {
        $filter = $request->query('filter');

        if($filter){
            return Fruit::where('name','LIKE','%'.$filter.'%')->orderBy('id')->paginate(10);
        }
        return Fruit::orderBy('id')->paginate(10);
    }
```

</details>

フィルターコンポーネント作成の前に、同コンポーネントで使用するユーティリティ(lodash)をインストールします。
```
npm install lodash
```

新規にフィルターコンポーネントを作成します。

<details>

```resources/js/Pages/Fruits/Filter.vue
<script setup>
import {ref,defineEmits,watch } from 'vue'
import {throttle} from 'lodash'

const filterText=ref('');
const emit = defineEmits(['reLoad']);

watch(filterText,
        throttle(()=>{
            if(filterText.value!==''){
                emit('reLoad',filterText.value);
            }else{
                emit('reLoad');
            }
        },150)
);

</script>
<template>
    <el-form-item label="絞り込み" >
        <el-input v-model="filterText" autocomplete="off" />
    </el-form-item>
</template>
```

</details>

* テンプレートにテキスト入力フィールドを配置します。
* バインドしたテキスト(filterText)はwatchで変更を監視します。
* 変更を検出した場合は親コンポーネントのreLoadをemitします。その際にテキストを引数とします。
* キー入力が連続してもemitの回数を抑えるようにthrottleを使用しています。150msに一回の発行となります。

一覧コンポーネントにフィルターコンポーネントを実装します。

<details>

```resources/js/Pages/Fruits/List.vue
<script setup>
const reLoadItems = (filter='')=>{
  const params={page:page.value};
  if(filter!==''){
    params['filter']=filter;
  } 
  axios.get('/api/list',{
                params:params
            })
            .then((res)=>{
              items.value = res.data.data;
              itemsTotal.value = res.data.total;
            });
}
import Filter from '@/Pages/Fruits/Filter.vue'
</script>
<template>
    <Filter @reLoad="reLoadItems"></Filter>
</template>

```

</details>

* reLoadItemsは引数filterに文字列がセットされているとき、apiのパラメータにfilterを指定します。
* これにより前述のコントローラでテキスト検索が実行されます。

### 最後に
vueとの連携にはinertiaを使用する手段も有りましたが、DB更新を終えてから結果をメッセージ出力したいときに不便を感じました（コントローラからsessionで処理結果を引き渡す手法や、viewの先頭にメッセージハンドラが必要なことなど）。疎結合なコンポーネントをいくつも作り、組み合わせたい場合はapi分離するほうがすっきりできるかと考え、この手法で組んでみました。
参考になれば幸いです。最後までご覧いただきありがとうございました。