SymfonyとVue RouterでTODOリストをつくる

Laravelで[Vue Router](https://router.vuejs.org/ja/)を使って、SPAを作るというのを見かけてSymfonyでもできないかと思いやってみました。

## Symfony側実装
### Symfonyインストール
Symfonyの空プロジェクトを作成します。今回はWebアプリケーションなので、必要なパッケージも合わせてインストールしていきます。


```sh
cd /path/to/
composer create-project symfony/skeleton todo
cd todo
composer require maker
composer require api
composer require webpack-encore-bundle
composer require --dev symfony/profiler-pack # デバッグツールバーが必要な場合
```

makerはSymfony Consoleでのクラス生成ツールです。apiは[API Platform](https://api-platform.com/)で、モデルに対してRestful APIを生成することができます。webpack-encore-bundleは[Webpack Encore](https://www.google.com/search?q=symfony+webpack+encore&rlz=1C5CHFA_enJP719JP719&oq=symfony+webpack+encore&aqs=chrome..69i57j0l7.3132j0j4&sourceid=chrome&ie=UTF-8)で、簡単に言うと[Laravel Mix](https://readouble.com/laravel/5.5/ja/mix.html)です。これでVueをビルドしていきます。

### タスク制御API開発
TODOリストのタスクを制御するAPIを開発していきます。まずはタスクのエンティティを作成します。

```
 bin/console make:entity

 Class name of the entity to create or update (e.g. GrumpyChef):
 > Task

 Mark this class as an API Platform resource (expose a CRUD API for it) (yes/no) [no]:
 > yes

 created: src/Entity/Task.php
 created: src/Repository/TaskRepository.php
 
 Entity generated! Now let's add some fields!
 You can always add more fields later manually or by re-running this command.
```

Taskエンティティには以下のプロパティを用意します。

| 名前 | 型 | 内容 |
|----|---|---|
| id | int | ID（自動生成） |
| title | string | タスクタイトル |

シンプルですね。生成されたエンティティですが、
`Mark this class as an API Platform resource` をyesにすることで、
通常のEntityにちょっと追記されます。

```diff:src/Entity/Task.php
<?php

namespace App\Entity;
+
+ use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\ORM\Mapping as ORM;

/**
+  * @ApiResource
 * @ORM\Entity(repositoryClass="App\Repository\TaskRepository")
 */
class Task
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;


```

``` @ApiResource ```アノテーションを追加されることで、API-Platformが以下のようなAPIを提供してくれます。

| URI | Method | 処理 
|---|---|---|
| /api/tasks | GET | 一覧取得 |
| /api/tasks | POST | 追加 |
| /api/tasks/{id} | GET | 1件取得 |
| /api/tasks/{id} | PUT | 置換 |
| /api/tasks/{id} | PATCH | 更新 |
| /api/tasks/{id} | DELETE | 削除 |

以上で、タスク制御API開発完了です。

### Vue用のHTML出力ページ作成
まずはコントローラーを準備します。

```
bin/console make:controller AppController
```

生成されたコントローラーを変更していきます。

```diff:src/Controller/AppController.php
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;

class AppController extends AbstractController
{
    /**
+     * @Route("{wildcard}", name="app", requirements={"wildcard"="^(?!build|api|_(profiler|wdt)).*"})
-     * @Route("/app", name="app")
     */
    public function index()
    {
        return $this->render('app/index.html.twig');
    }
}

```

**ここが一番のポイントです。** ルーティングの変更になります。API-Platformが提供する/api/\*\*\*、ビルドしたスクリプトが格納される/build、Symfonyのデバッグツールバーで利用される/_profiler, /_wdt **以外**のURIは全てAppController::index()が実行されるようにします。つづいて、twig（テンプレート）を変更していきます。twigファイルはごそっと変えます。

```php:templates/app/index.html.twig
{% extends 'base.html.twig' %}

{% block title %}Hello AppController!{% endblock %}

{% block stylesheets %}
  {{ encore_entry_link_tags('app') }}
{% endblock %}
{% block body %}
  <div id="app"></div>
{% endblock %}
{% block javascripts %}
  {{ encore_entry_script_tags('app') }}
{% endblock %}
```

stylesheets, javascriptsでwebpack-encoreでビルドするファイルを読み込むために、`{{ encore_entry_link_tags('app') }}`、`{{ encore_entry_script_tags('app') }}`を記述します。あとはbodyブロックにVueを実行するdivタグ、`<div id="app"></div>`を記述します。

これで、Vue用のHTMLは準備できました。

## Vue側実装
では、今回の要であるVue側の実装をしていきます。

### インストール
まずはもろもろインストールします。

```
yarn add vue vuex bootstrap-vue bootstrap axios
yarn add vue-router vue-loader vue-template-compiler --dev
```

ストア機能を使いたいので[vuex](https://vuex.vuejs.org/ja/)、HTTP通信のための[axios](https://github.com/axios/axios)、見た目を整えたいので[bootstrap](https://getbootstrap.com/), [bootstra-vue](https://bootstrap-vue.js.org/)をインストールします。
続いて、Vue側のルーティング用に[vue-router](https://router.vuejs.org/ja/)と、ロード、テンプレートコンパイルのために[vue-loader](https://vue-loader-v14.vuejs.org/ja/), [vue-template-compiler](https://www.npmjs.com/package/vue-template-compiler)をインストールします。

#### ストア実装
インストールが終わったら、まずはストアから作ってきます。

```js:assets/js/stores/modules/task.js
import axios from 'axios';

const task = {
    state: {
        tasks: []
    },
    mutations: {
        addTask(state, task) {
            state.tasks.push(task);
        },
        deleteTask(state, index) {
            state.tasks.splice(index, 1);
        },
        setTasks(state, tasks) {
            state.tasks = tasks;
        }
    },
    actions: {
        async fetchTasks({commit}) {
            const list = await axios.get('/api/tasks', {
                data: {},
                headers: {'Accept': 'application/json'}
            }).then(res => {
                commit('setTasks', res.data);
            });
        },
        async addTask({state, commit}, task) {
            await axios.post('/api/tasks', task, {headers: {'Accept': 'application/json'}})
                .then(res => {
                    commit('addTask', res.data);
                });
        },
        async deleteTask({state, commit}, task) {
            const index = state.tasks.indexOf(task);
            await axios.delete('/api/tasks/' + task.id, {headers: {'Accept': 'application/json'}})
                .then(res => {
                    commit('deleteTask', index);
                });
        }
    }
};

export default task;

```

タスク用のストアです。先ほどSymfonyで作成したAPIを呼び出して一覧取得、追加、削除の処理を記載しています。

```js:assets/js/store/index.js
import Vue from 'vue';
import Vuex from 'vuex';
import task from './modules/task'
Vue.use(Vuex);

export default new Vuex.Store({
    modules: {
        task
    }
});
```

こちらはstoreのindexです。上記のtask.jsをインポートして、ストアのモジュールとして設定しています。


#### コンポーネント実装
次にコンポーネントを実装します。

```vue:assets/js/components/Tasks.vue
<template>
    <div class="container">
        <div class="row">
            <h2>TODOリスト！</h2>
        </div>
        <div class="form-group">
            <label for="title">タスク</label>
            <input id="title" type="text" class="form-control" name="title" v-model="title">
        </div>
        <div class="form-group">
            <button @click="add">登録</button>
        </div>
        <ul class="list-group">
            <li v-for="task in tasks" class="list-group-item d-flex justify-content-between align-items-center">
                {{ task.title }}
                <button class="btn btn-danger" @click="deleteTask(task)">削除</button>
            </li>
        </ul>
    </div>
</template>

<script>
    export default {
        name: "Tasks",
        data() {
            return {
                title: null
            };
        },
        computed: {
            tasks() {
                // ストアのタスク一覧を出力
                return this.$store.state.task.tasks;
            }
        },
        methods: {
            // ストアのタスク一覧取得アクション実行
            async getTasks() {
                await this.$store.dispatch('fetchTasks');
            },
            // ストアのタスク追加アクションを実行
            async add()
            {
                await this.$store.dispatch('addTask', {title: this.title});
            },
            // ストアの削除アクションを実行
            async deleteTask(task)
            {
                await this.$store.dispatch('deleteTask', task);
            }
        },
        // 描画時にタスク一覧を取得
        mounted() {
            this.getTasks();
        }
    }
</script>

<style scoped>

</style>
```

本当は、フォームやタスクリスト内のタスクは別コンポーネントにした方がいいと思いますが、まだVueよくわからないので、ひとつにしちゃいました。このコンポーネントは

- リスト取得
- タスク追加
- タスク削除

の機能があります。そもそもの機能はストアに実装しているので、ストアのプロパティやアクションを参照しています。

#### ルーティング設定
続いてVue Routerによるルーティングを行っていきます。

```js:assets/js/routes.js
import Vue from 'vue';
import VueRouter from "vue-router";

import Tasks from "./components/Tasks";

Vue.use(VueRouter);
const router = new VueRouter({
    mode: 'history',
    routes: [
        {path: '/tasks', name: 'tasks', component: Tasks}
    ]
});

export default router;
```

/tasksに先ほど作成したTasksコンポーネントを割り当てます。これで、/tasksにアクセスした際に、Tasksコンポーネントが実行されます。

### View実装
Viewの実装です。

```js:assets/js/views/App.vue
<template>
    <router-view>

    </router-view>
</template>

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

<style scoped>

</style>
```

ルーティングされたコンポーネントを表示するViewを作成します。

#### アプリの設定

最後にアプリの設定を行います。まずはwebpackの設定に追記します。

```diff:webpack.config.js
var Encore = require('@symfony/webpack-encore');

// Manually configure the runtime environment if not already configured yet by the "encore" command.
// It's useful when you use tools that rely on webpack.config.js file.
if (!Encore.isRuntimeEnvironmentConfigured()) {
    Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}

Encore
    // directory where compiled assets will be stored
    .setOutputPath('public/build/')
    // public path used by the web server to access the output path
    .setPublicPath('/build')
    // only needed for CDN's or sub-directory deploy
    //.setManifestKeyPrefix('build/')

    /*
     * ENTRY CONFIG
     *
     * Add 1 entry for each "page" of your app
     * (including one that's included on every page - e.g. "app")
     *
     * Each entry will result in one JavaScript file (e.g. app.js)
     * and one CSS file (e.g. app.css) if your JavaScript imports CSS.
     */
    .addEntry('app', './assets/js/app.js')
    //.addEntry('page1', './assets/js/page1.js')
    //.addEntry('page2', './assets/js/page2.js')

    // When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
    .splitEntryChunks()

    // will require an extra script tag for runtime.js
    // but, you probably want this, unless you're building a single-page app
    .enableSingleRuntimeChunk()

    /*
     * FEATURE CONFIG
     *
     * Enable & configure other features below. For a full
     * list of features, see:
     * https://symfony.com/doc/current/frontend.html#adding-more-features
     */
    .cleanupOutputBeforeBuild()
    .enableBuildNotifications()
    .enableSourceMaps(!Encore.isProduction())
    // enables hashed filenames (e.g. app.abc123.css)
    .enableVersioning(Encore.isProduction())

    // enables @babel/preset-env polyfills
    .configureBabelPresetEnv((config) => {
        config.useBuiltIns = 'usage';
        config.corejs = 3;
    })

    // enables Sass/SCSS support
    //.enableSassLoader()

    // uncomment if you use TypeScript
    //.enableTypeScriptLoader()

    // uncomment to get integrity="..." attributes on your script & link tags
    // requires WebpackEncoreBundle 1.4 or higher
    //.enableIntegrityHashes(Encore.isProduction())

    // uncomment if you're having problems with a jQuery plugin
    //.autoProvidejQuery()

    // uncomment if you use API Platform Admin (composer req api-admin)
    //.enableReactPreset()
    //.addEntry('admin', './assets/js/admin.js')

+    // Enable VueJs
+    .enableVueLoader()

;

module.exports = Encore.getWebpackConfig();
```
Vueを有効にします。

```js:assets/js/app.js
/*
 * Welcome to your app's main JavaScript file!
 *
 * We recommend including the built version of this JavaScript file
 * (and its CSS file) in your base layout (base.html.twig).
 */

const axios = require('axios');

// any CSS you import will output into a single css file (app.css in this case)
import 'bootstrap/dist/css/bootstrap.css';
import 'bootstrap-vue/dist/bootstrap-vue.css';
import '../css/app.css';

// Need jQuery? Install it with "yarn add jquery", then uncomment to import it.
// import $ from 'jquery';

import Vue from 'vue';
import Routes from './routes.js';
import App from './views/App';
import store from './stores/index';
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue';
Vue.use(BootstrapVue);
Vue.use(IconsPlugin);


const app = new Vue({
    el: '#app',
    router: Routes,
    render: h => h(App),
    store
});

export default app;
```

作ってきたものをインポートして、appに紐付けます。これで開発完了です。

## 実行
### DB設定
SQLiteで動作するよう、.envを設定します。
今回はローカル環境でのみSQLiteにしたかったので、.env.localを設定します。


```.env.local
APP_ENV=dev
DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db
```

設定したら、DBとテーブルを作成します。

```sh
bin/console doctrine:database:create
bin/console doctrine:schema:update --force
```

### ビルド＆サーバ起動

DBが設定できたら、ローカルでの実行はPHPのビルトインサーバを起動し、Vueをビルドします。

```sh
symfony server:start -d # もしくは php -S localhost:8000 -t public/
yarn run watch # yarn encore dev --watch
```

起動したら、http://localhost:8000/tasks にアクセスします。


![Screen Shot 2020-02-27 at 18.10.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9882/ed8d1576-6209-5adf-c8b0-21a1b6dca328.png)

タスクを登録したり、登録したタスクが削除できたりしました。
（見た目が悪かったのでassets/css/app.cssのbackground-colorをwhiteにしました）

これで、完成です。