LoginSignup
20
19

More than 1 year has passed since last update.

【Laravel 8 / Sail / Fortify / Sanctum】タスク管理アプリ (ポートフォリオ) の実装過程 (バックエンド編)

Last updated at Posted at 2021-08-25

主にスキル向上を目的に、ポートフォリオとしてタスク管理アプリを作成しました。このページでは、主にそのバックエンド部分の実装過程について触れていきます。

アプリケーションや作成したコード、フロントエンドの実装過程の説明については、以下のリンクからアクセスできます。

目次

開発環境

バックエンドの開発言語にはPHP、WebアプリケーションフレームワークにはLaravelを利用しました。開発環境の構築には、Laravelから公式に提供されているLaravel Sailを用いており、これを実行することで開発用のサーバーが起動し、データベースやセッションストアの他、メール送信まで行うことができる環境が整います。

Laravel Sailにデフォルトで用意されている環境はカスタマイズすることも可能ですが、今回は特に変更を行っておらず、構築した環境は以下のようなものです。

バージョンが異なる場合、実装方法や依存ライブラリなども大きく異なることがあるので、ここではLaravel 8.xであることを前提とします。

主要使用技術

主に使用した技術、パッケージを以下に列挙します。(括弧内の数字はバージョン)

  • PHP (8.0.5) - 開発言語
  • Laravel (8.32.1) - Webアプリケーションフレームワーク
  • MySQL (8.0.23) - RDB (開発環境)
  • Redis (6.0.10) - キャッシュ、セッションストア (開発環境)
  • MailHog - メール送受信 (開発環境)
  • PHPUnit (9.5.2) - テスト
  • Telescope (4.4.6) - デバッガー
  • Sanctum (2.9.3) - SPA認証 (セッション、CSRF & XSS 防衛)
  • Fortify (1.7.8) - 認証用バックエンド (ルーティング、コントローラー etc)
  • Bref (1.2.10) - PHP用Lambdaデプロイツール
  • Serverless (2.53.1) - サーバレスアプリケーション構成管理
  • AWS SDK for PHP (3.188.0) - AWS連携

Laravel 8

Laravel 8.xでは環境構築の方法が従来のものよりさらに容易になっています。以前は必要とされた手順であるComposerの導入やLaravelプロジェクトの作成、またデータベースのインストールや接続設定などが不要になり、それらを意識することなく開発準備を整えることができます。

Laravel Sail

簡単に環境構築を行うためには、Laravel Sailと呼ばれるものを使用します。これは公式サイトで紹介されているパッケージの一つです。前提としてDockerを使った構築方法になっているので、事前にDockerが利用できる環境が必要です。macOSであれば、Homebrewを利用したインストールが簡単です。

brew install --cask Docker # インストール
open /Applications/Docker.app # 起動

下記に以前の方法の一例とLaravel Sail を利用した場合の比較を行っていますが、かなり簡潔になっていることがわかります。

# 従来のインストール方法の例 (example-appは任意のプロジェクト名)

curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
composer global require laravel/installer
export PATH="$HOME/.config/composer/vendor/bin:$PATH"
laravel new example-app # バージョン指定する場合は下記のようになる
# composer create-project laravel/laravel example-app --prefer-dist "5.5.*"

# 次にデータベースの設定なども必要
# Laravel Sail を利用する方法 (example-appは任意のプロジェクト名)

curl -s https://laravel.build/example-app | bash
cd example-app && ./vendor/bin/sail up

上記のコマンドを実行すると端末上で起動していることが確認できます。ここには、MySQLなどのデータベースやRedisなどのインメモリデータベースも含まれています。

このようにSailを利用することで、簡単にLaravelによるアプリケーションを実行できる上、データベースやキャッシュの他メールなどの事前設定も不要です。

これらの設定については、プロジェクトルートに配置された、docker-compose.ymlから確認できます。

同時起動サービス

Laravel Sail を利用して環境構築する場合、実装時点のデフォルト設定では、MySQL, Redis, MeiliSearch, MailHog, Selenium のサービスが起動するようになっていました。しかし、代わりにPostgreSQLを利用する場合やRedisが不要といった場合は、それをインストール時に指定することができます。その場合はコマンドを以下のように変更し、mysql, pgsql, redis, memcached, meilisearch, selenium, mailhogの中からサービスを指定します。

curl -s "https://laravel.build/example-app?with=mysql,redis" | bash

このように、Laravel Sailを利用することによって、簡単にDocker環境でLaravelを利用した開発を始めることができるようになりました。

参考: Installation - Laravel # Choosing Your Sail Services

sailコマンド

上で使用している./vendor/bin/sailは、Laravel Sail によって利用することができるようになったコマンドで、docker-composeコマンドと同様の利用法が可能ですが、さらに./vendor/bin/sail mysql./vendor/bin/sail bashなど、短縮コマンドも用意されており幅広い使い方が可能です。頻繁に利用するので入力の手間を省くため公式と同じようにエイリアスを設定しておきます。

alias sail='bash vendor/bin/sail' # ~/.bashrc などに追記する

Laravelを利用する環境は概ね完了したので、次に設定の変更やツールの導入を行っていきます。

Laravel Debugbar

デバッガーは、アプリケーションの状態の把握の目的や、エラーが発生したときに速やかに原因を特定するために導入が必須と言えます。

そのためのツールとしてLaravel Debugbarを利用する方法が考えられます。これはインストールするだけでセッションやリクエスト情報等をブラウザ上で確認することができるものです。

しかし、SPAとして実装を進める場合、ブラウザで立ち上げているのはLaravelではなく、主にフロントエンドのアプリケーションとなるので今回の場合は用途に合致しないようです。そこで、次に言及するTelescopeを代わりに導入することにします。

Telescope

Telescopeとは、Laravel公式サイトでパッケージとして紹介されているデバッガーです。 これによってリクエストのあらゆる情報が記録され、それをブラウザで確認することができます。取得される情報はヘッダーやセッションの他クエリやキャッシュまで非常に広範囲にわたります。

Telescope provides insight into the requests coming into your application, exceptions, log entries, database queries, queued jobs, mail, notifications, cache operations, scheduled tasks, variable dumps, and more.

Laravel Telescope - Laravel # Introduction

インストールは以下のコマンドによって行います。

sail composer require laravel/telescope --dev # --dev: 開発環境でのみ利用する場合
sail artisan telescope:install # CSSなどアセットファイルの出力
sail artisan migrate # 記録データ格納用テーブルの作成

sail artisan route:listを実行してルートを確認すると、Telescope関連のものが追加されており、http://localhost/telescopeにアクセスすることでダッシュボードを確認することができます。

タイムゾーン/ロケール

日本語や日本時間を利用する指定を行うため、設定ファイルのapp/config/app.phpを以下のように修正します。

app/config/app.php
<?php

return [
    // ...
    'timezone' => 'Asia/Tokyo',

    'locale' => 'jp',

    'faker_locale' => 'ja_JP',
    // ...
];

上記のfaker_localeとは、テストデータ生成用のFakerを利用する際の設定です。ただし、この設定を行ったとしても多くは日本語を利用できません。これはデータが用意されていないためです。

参考: Installation - Laravel # Initial Configuration

リソース

Laravelでは、アプリケーションに一般的に必要なファイル (リソース) がコマンド一つで一気に生成可能です。

sail artisan make:model TaskCard --all --api

上記コマンドの--allオプションによって、modelと同時に、 migration, seeder, factory, controllerのファイルが生成されます。また--apiオプションの指定をすることで、controllerに、indexstoreなどの、APIに必要なアクションが追加された状態となります。その他利用可能なオプションは--helpを指定することで確認できます。

php artisan make:model --help

マイグレーション

リソース生成によって出力されたファイルの内、マイグレーションdatabase/migrations/{時刻}_create_task_cards_table.phpを以下のように書き換えます。

database/migrations/{時刻}_create_task_cards_table.php
public function up()
{
    Schema::create('task_cards', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')
            ->constrained() // 外部キー制約
            ->onUpdate('cascade')
            ->onDelete('cascade');
        $table->string('title', 191);
        $table->text('content')->nullable(); // null許容
        $table->boolean('done')->default(false); // 初期値設定
        $table->timestamps();
    });
}

まずusersテーブルとの外部キー制約の設定を行います。上記のような記述によって、参照整合性を保つことが可能です。即ち、user_idが参照しているusersテーブルのidが変更された場合には当該テーブルのuser_idの値も連動し、usersテーブルのidが削除された場合には参照元であるtask_cardsのレコードも同時に削除されされることになります。

次に、titlecontentなどの型を指定して作成するタスクに必要なカラムの設定を行っています。このとき要件によって、null許容やデフォルト値も設定します。

参考:
Database: Migrations - Laravel # Foreign Key Constraints
Database: Migrations - Laravel # Available Column Types
Database: Migrations - Laravel # Column Modifiers

モデル

リソース生成によって出力されたファイルの内、次にモデルを変更していきます。

始めに$fillableプロパティに、アプリ上で変更できるカラムを指定します。これはユーザーの通常の操作によって変更可能かを決定するもので、一般的にidcreated_at(timestamps) などは含めません。

次にリレーションを設定します。今回作成するのは、一人のUserTaskCardを複数持つことができる一対多の関係です。それにはuserプロパティを作成し、belongsToメソッドを追加することで実現します。

app/Models/TaskCard.php
class TaskCard extends Model
{
    use HasFactory;

    // アプリ上の操作で変更可能にしたいカラムを追加
    protected $fillable = [
        'title',
        'content',
        'done'
    ];

    // リレーション設定
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

一方、Userモデル側も編集し、taskCardsプロパティにhasManyメソッドを追加します。

app/Models/User.php
public function taskCards()
{
    return $this->hasMany(TaskCard::class);
}

このようにすることで、互いのモデルに容易にアクセスできるようになります。

// `id`が`1`である`User`が持つ`TaskCard`を取得
$task_cards = App\Models\User::find(1)->task_cards;

// `id`が`1`である`TaskCard`が属する`User`を取得
$user = App\Models\TaskCard::find(1)->user;

参考: Eloquent: Relationships - Laravel

データをブラウザに返却する際、必ずしも全てのカラムが必要ではなく、寧ろpasswordなどのように秘匿した方が好ましいものもあります。そのような場合はモデルの$hiddenプロパティにカラム名を追加します。

app/Models/TaskCard.php
protected $hidden = [
    'user_id',
];

また、データ返却時に値の表示方法を変更したい場合もあります。例えば、Boolean型のカラムは値が01で表されます。これをそれぞれ、falsetrueにするには$castプロパティにカラムとそのキャストタイプを指定します。

app/Models/TaskCard.php
protected $casts = [
    'done' => 'boolean',
];

参考: Eloquent: Mutators & Casting - Laravel # Attribute Casting

Seeder/Factory

動作確認用にテスト用のデータがあると便利です。Laravelでは簡単にそのようなデータを作成することができる機能が内包されています。それを利用するには、database/factoriesディレクトリに生成されたTaskCardFactory.phpdefinitionメソッドにどのようなデータを生成するかを定義します。

database/factories/TaskCardFactory.php
public function definition()
{
    return [
        'title' => $this->faker->jobTitle,
        'content' => $this->faker->sentence,
    ];
}

上記のように、fakerプロパティを通すことで、Laravelに備えられているFakerライブラリにアクセスし、事前に用意されたデータをランダムに生成することができるようになります。

次に、database/seeders/TaskCardSeeder.phprunメソッドに、データベースに対する処理を追加します。

database/seeders/TaskCardSeeder.php
public function run()
{
    // 作成する`TaskCard`が属する`User`を事前に作成
    $user = User::factory()->create();

    // 'User'に属するデータを10件生成
    TaskCard::factory()->count(10)->for($user)->create();
}

上の処理では、まずUserデータを作成しています。Userに属していないTaskCardは許容していないため、そのためのデータが必要となります。そして、そのUserに属するデータを10件生成するという処理を記述しています。

実際にデータ作成処理を走らせるには以下artisanコマンドを実行します。

sail artisan db:seed --class=TaskCardSeeder # Seederを指定してデータを生成

データベースを確認するとデータは作成できていますが、以上のような方法だとテーブルが増えてきたときには面倒になります。よってこれを統合するため、database/seeders/DatabaseSeeder.phpの内容を以下のように変更します。

database/seeders/DatabaseSeeder.php
public function run()
{
    $this->call(TaskCardSeeder::class);
}

このように記述することで、Seederを指定することなく実行可能です。

sail artisan db:seed

このように、リレーションのあるデータでも簡潔なコードで即座に大量のデータを生成できることが確認できました。

参考:
Database Testing - Laravel # Defining Model Factories
Database: Seeding - Laravel

ルーティング

データを準備が整ったのでここからはAPIとして利用できるように実装していきます。

まずは、データベースからデータを読み取ることから始めていきます。これはさらに"一覧"と"詳細"に分解でき、Laravelではそれぞれindexアクションとshowアクションに分類されます。基本的に"詳細"はデータベースでの個別のレコードを表し、"一覧"はそれ以外 (複数のレコード) を表します。

始めに行うことはルーティングの設定です。ここでは、ルートにアクセスしたときに全てのtask_cardsを表示するケースを想定し、routes/api.phpに設定を記述していきます。

routes/api.php
Route::group([
    'namespace' => 'App\Http\Controllers',
    'prefix' => 'v1',
], function () {
    Route::apiResource('users.task_cards', TaskCardController::class)
        ->only('index');
});

groupメソッドを利用することで共通する処理をまとめることが可能で、この第一引数には共有する機能、第二引数にはルート定義を指定しています。

namespaceにはControllerファイルが設置されている場所を指定します。これによって、Controllerを記述するときに毎回先頭に付与する必要がなくなります。

これは、Laravel 8における変更点の一つで、以前はアプリケーション側で用意されていました。ただし従来の方法でnamespaceを指定することも可能です。

参考: Release Notes - Laravel # Routing Namespace Updates

prefixにはv1としていますが、これはバージョンを表しておりAPI開発の際に一般的にこのように表記が利用されるようです。

次にルート定義で、apiResourceメソッドの第一引数にはテーブル名を、第二引数にはコントローラーを指定します。これだけでAPIにおけるCRUD機能に必要なURIやアクションの割り当てが完了です。リレーションを表すために階層のあるルートを作成したいときはusers.task_cardsのようにドット (.) で結合します。結果をsail artisan route:listにて確認すると以下のようなルートが生成されていました。

HTTPメソッド URI ({user}Userid) アクション
GET /api/v1/users/{user}/task_cards index
GET /api/v1/users/{user}/task_cards/create create
POST /api/v1/users/{user}/task_cards store
GET /api/v1/users/{user}/task_cards/{task_card} show
GET /api/v1/users/{user}/task_cards/{task_card}/edit edit
PUT/PATCH /api/v1/users/{user}/task_cards/{task_card} update
DELETE /api/v1/users/{user}/task_cards/{task_card} destroy

ルート定義時にonlyメソッドを使用することでその他のルートの出力を停止することができ、上記のコードではindexアクションのみを指定しています。

結局、ルート定義としては、api/v1/users/{user}/task_cardsにGETメソッドでアクセスしたときTaskCardControllerindexアクションを実行するというものになりました。

参考: Controllers - Laravel # Resource Controllers

routesディレクトリには、web.phpもありこちらにルーティングを設定することもできますが、今回はAPIとして利用するため、api.phpの方に記述します。

両者の違いは主に二つあり、1つはデフォルトのパスの違いです。api.phpでルーティングを行うとパスの先頭にapiが付与され、例えばlocalhost/api/usersのようになります。2つ目の違いは適用されるミドルウェア (後述) です。

これら設定はapp/Providers/RouteServiceProvider.phpにて確認が可能です。

app/Providers/RouteServiceProvider.php
public function boot()
{
    $this->configureRateLimiting();

    $this->routes(function () {
        Route::prefix('api') // パスの設定
            ->middleware('api') // 適用するミドルウェア
            ->namespace($this->namespace)
            ->group(base_path('routes/api.php')); // 適用するファイル

        Route::middleware('web')
            ->namespace($this->namespace)
            ->group(base_path('routes/web.php'));
    });
}

参考: Routing - Laravel

ミドルウェア

ここでミドルウェアとは、HTTPリクエストを検査しフィルタリングなど何らかの操作を行う役割を果たすものを指します。

上のファイルの中で、middlewareメソッドによって適用するミドルウェアを決定しています。引数となっているapi及びwebはミドルウェアグループと呼ばれるもので、複数のミドルウェアをグループ化して一括で設定するものです。それぞれどのようなミドルウェアが属しているのかについてはapp/Http/Kernel.phpに記述があります。

app/Http/Kernel.php
protected $middlewareGroups = [
    'web' => [
        \App\Http\Middleware\EncryptCookies::class,
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
        \Illuminate\Session\Middleware\StartSession::class,
        // \Illuminate\Session\Middleware\AuthenticateSession::class,
        \Illuminate\View\Middleware\ShareErrorsFromSession::class,
        \App\Http\Middleware\VerifyCsrfToken::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],

    'api' => [
        'throttle:api',
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],
];

このように、webapiでは属しているミドルウェアが異なるため、それぞれが割り当てられているweb.phpapi.phpでは適用されるミドルウェアに違いがあります。

参考: Middleware - Laravel

コントローラー

タスクの一覧を表示させるため、先のルーティング設定で確認したように、TaskCardControllerindexアクションを実装していきます。APIとして利用する場合は、"View"の代わりにJSON形式のデータを返却することに注意して以下のように記述します。

app/Http/Controllers/TaskCardController.php
namespace App\Http\Controllers;

use App\Models\TaskCard;
use App\Models\User; // 追記
use Illuminate\Http\Request;

class TaskCardController extends Controller
{
    public function index(User $user) // 引数追記
    {
        // JSONとして返却
        return $user->taskCards; // 追記
    }
// ...

上の処理で、$userが持つtask_cardsレコードを全て取得し、JSON形式として返却します。

変数$userには、usersテーブルからidで検索されたデータが自動的に入ります。引数に型ヒント (ここでは$user前のUser) を行うことで実現するこの手法を、依存性注入 (DI) と呼びます。

参考: Controllers - Laravel # Dependency Injection & Controllers

次に利用しているtaskCardsメソッドは、先述のリレーション (Model)の項目で設定したものです。

そしてデータをJSONとして返却する点ですが、Laravelでは、コントローラーから返却する際には自動的にJSONに変換するため特別の操作は不要です。

Laravel will automatically serialize your Eloquent models and collections to JSON when they are returned from routes or controllers:

Eloquent: Serialization - Laravel # Serializing To JSON

Seeder & Factoryの項目でデータを生成していれば、localhost/api/v1/users/1/task_cardsにアクセスすることで、id1であるUserが持つTaskCardのデータがJSON出力されていることが確認できるはずです。

ページネーション

取得するデータが非常に多い場合、データベースに負荷がかかり読み込みまで時間がかかることが予想されます。そのような場合にはページネーション機能を用いることで一定のデータ数に分割して取得することが可能です。

Laravelではこれがデフォルトで利用できるようになっており、以下のようにpaginateメソッドを使用して取得データ数を指定します。

app/Http/Controllers/TaskCardController.php
namespace App\Http\Controllers;

use App\Models\TaskCard;
use App\Models\User;
use Illuminate\Http\Request;

class TaskCardController extends Controller
{
    public function index(User $user)
    {
        // 一度に取得するデータ数を20とする
        return TaskCard::where('user_id', $user->id)->paginate(20);
    }
// ...

参考:Database: Pagination - Laravel

テスト

これまでの実装をテストによって確認してみます。現段階ではブラウザでリクエストを送って期待通りのレスポンスが返ってくることは確認済みですが、他の機能を実装する過程でこれが崩れてしまうこともあり、そのとき毎回手動で確認するには多くの手間が発生します。そこでテストコードを作成することで問題の解決を目指します。

Laravelにおいては、初めからテストに必要なライブラリであるPHPUnit及び設定ファイルのphpunit.xml、またtestsディレクトリに初期ファイルが用意されており、すぐにテストを開始することができます。そこで今、sail artisan testを実行すると、testsディレクトリ以下のUnit及びFeatureディレクトリに置かれているTest.phpで終わるファイルのテストが走ります。

このファイル名などのルールは、phpunit.xmlに規定されているものです。同様に、テストの実行環境がtestingになることも定められています。即ち、.env.testingファイルを用意することで普段と異なる環境で利用できるということです。

ただし注意点として、ファイルが存在しない場合は.envの値が用いられます。その場合データベースも同じものを使用しているので、これまでに作成したデータが削除されたり想定外のテスト結果となってしまったりすることがあります。

参考:Testing: Getting Started - Laravel # Environment

それでは以下のコマンドを実行して実際にテストを作成してみます。--unitオプションを付けない場合、Featureディレクトリにファイルが生成されます。

php artisan make:test TaskCardTest

以下ではページネーションによってデータ取得数が20になっていることをテストしています。

tests/Feature/TaskCardTest.php
<?php

namespace Tests\Feature;

use App\Models\TaskCard; // 追加
use App\Models\User; // 追加
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class TaskCardTest extends TestCase
{
    use RefreshDatabase; // DBリフレッシュ

    // テストの前に実行する処理を追加
    public function setUp(): void
    {
        parent::setUp(); // 必須

        $user = User::factory()->create(['id' => 1]); // TaskCardが属するUser
        TaskCard::factory()->count(21)->for($user)->create();
    }

    public function test_20_items_in_one_page()
    {
        $response = $this->get('/api/v1/users/1/task_cards');
        $response->assertJson(
            fn (AssertableJson $json) =>
            $json->has('data', 20)
        );
    }
}

まず、それぞれのテストで相互依存を避けるべくデータが存在しない状態で開始させるため、use RefreshDatabaseによってデータベースをリフレッシュする処理を先頭に置いています。

次に、setUpメソッドでテストに実行したい処理を追加します。ここでは複数のテストで共通で使用するデータの生成などを行います。

そして、テスト処理ではまずgetメソッドで該当ページへリクエストを送ります。次にそのレスポンスがdataキーを持っていてそのバリュー数が20であることをテストします。

テストの実行には、sail artisan testを使用し、成功すればPASSと出力されます。

参考: HTTP Tests - Laravel # Scoping JSON Collection Assertions

さらにテストを活用するため、GitHub Actionsを導入します。

GitHub Actions

GitHub Actions とは、事前に規定したイベントが発生した際に、自動的に任意のコマンドを実行することができるサービスです。イベントに指定可能なものとして、リポジトリへのPushやPull Request があり、特定のBranchの場合に限定するといった条件を指定することも可能です。また、イベント駆動に限らずスケジュールに従って実行することもできます。

参考: ワークフローをトリガーするイベント - GitHub Docs

GitHub Actions を導入することで、コードのビルドやテストの実行及びデプロイなどをイベントに従って自動で行うことができます。これによって、コードの変更による他の箇所への影響を早期に発見し対処することが可能となると同時に、このような頻繁に発生する定型業務を効率化しつつ強制することができます。

ここでは、Laravelにおけるテストの項目でテストを作成したのでそれを利用します。また今回は、Push及びPull Request のタイミングで対象Branchは問わずに実行する例を示します。

料金についてですが、パブリックリポジトリでは無料で利用することができます。一方プライベートリポジトリでは一定のリソース消費までは無料となります。GitHubの料金プランによってその範囲は異なりますが、現在は以下のような制限となっています。

製品 ストレージ 利用時間 (分) / 月
GitHub Free 500 MB 2,000

最新の料金体系については変更の可能性があるので、公式サイトを参照してください。

参考: GitHub Actionsの支払いについて - GitHub Docs

GitHubホストランナー

GitHub Actions では、GitHubホストランナーと呼ばれる仮想環境が提供されており、定義したコマンドが実際に実行される場所はこのGitHubホストランナー上となります。よって、それに対応させるようにコマンドの調整が必要になります。しかし、一般的なユースケースはテンプレートとして用意されているのでそれに従うことで導入コストを抑えることができます。

上記のような仮想環境ではなく独自で用意したホストを利用する方法もあります。

参考:
GitHubホストランナーについて - GitHub Docs
セルフホストランナーについて - GitHub Docs

ワークフロー作成

GitHub Actions は導入から動作させるまでに特段の準備は必要ありません。リポジトリのルートに.github/workflowsディレクトリを作成し、その配下に設定やコマンドなどの手順 (ワークフロー) を記述したYAMLファイル を設置するだけです。このように導入が容易なことも利点の一つと言えます。

このワークフローの作成においてもテンプレートを利用して簡単に始めることができます。次のGitHubリポジトリ actions/starter-workflows: Accelerating new GitHub Actions workflows に様々な言語でCI/CDに利用できるコードが提供されています。

また、GitHubコミュニティによって作成されたものを利用することもでき、GitHub Marketplace からそれらにアクセスできます。

今回の主な目的としてはテストを行うことです。即ちPHPUnitによるテスト実行コマンドを走らせることができればいいことになります。しかし、GitHubホストランナー上の環境はまだその準備ができていないので、初めにそれを整える必要があります。具体的には、.envファイルの生成やパッケージインストールなどです。

それも含めてワークフローを作成すると以下のようになります。

test.yml
# 任意のワークフロー名
name: PHPUnit
# トリガーとなるイベント
on: [push, pull_request]

jobs:
  test: # 任意のジョブ名
    runs-on: ubuntu-latest # GitHubホストランナー
    defaults:
      run:
        # テストを実行するディレクトリ
        working-directory: ./backend
    steps:
      - name: Check out repository code # 任意のステップ名
        uses: actions/checkout@v2 # アクションの使用
      - name: Install Dependencies
        run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
      - name: Generate APP_KEY
        run: php artisan key:generate --env=testing
      - name: Execute tests
        run: ./vendor/bin/phpunit

注意点として初めにworking-directoryに注目します。今回のリポジトリの構成としてルートにbackendディレクトリを作成し、そこにLaravelプロジェクトを構築しています。よってテストを実行すべき作業ディレクトリは相対パスで./backendとなり、それをworking-directoryの値に指定する必要があります。

次にYAML構文の最上位を見るとjobsが来ています。これはワークフロー内のジョブ (ここではtest一つのみ) をまとめるものです。複数のジョブが存在する場合は並行実行します。また、ジョブは一連のステップstepsから構成され、そのステップにおいてはアクションusesまたはシェルコマンドrunを指定します。

アクションには、上記のactionsの他、GitHub Marketplace で提供されているものを利用することも可能ですが、actions/checkoutは必ず必要です。

ワークフローがリポジトリのコードに対して実行されるとき、またはリポジトリで定義されたアクションを使用しているときはいつでも、チェックアウトアクションを使用する必要があります。

GitHub Actions 入門 - GitHub Docs # ワークフローファイルを理解する

次に行っているのが依存関係のインストール (Install Dependencies) です。指定しているオプションは主に余計な出力を制限するためのものです。詳細はsail composer install --helpでも確認できます。

そしてアプリケーションキーの生成 (Generate APP_KEY) を行っていますが、ここでのポイントとしては、.envファイル作成を行わない代わりに、php artisan key:generate実行時に--env=testingオプションを指定していることです。これにより、.env.testingAPP_KEYの値が生成されることになります。テスト (phpunit) 実行時に.env.testingが存在すればその値を参照するようになるため、.envは作成していません。

参考:
GitHub Actionsのワークフロー構文 - GitHub Docs
Laravel workflow - actions/starter-workflows/ci/laravel.yml - GitHub

データベースコンテナ

データベースを利用したテストを実行するため、事前にデータベースのサービスを起動する必要があります。今回は、MySQLのDockerコンテナを用いてこれを実現することにします。

ワークフローに設定する内容としては以下のようになります。

test.yml
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        ports:
          - 3306:3306
        env:
          MYSQL_DATABASE: backend
          MYSQL_ALLOW_EMPTY_PASSWORD: yes
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

このコンテナにアクセスするには上でマッピングしたポート3306DB_HOSTにローカルホストを指定します。ここでlocalhostではなく127.0.0.1を利用することに注意が必要です。localhostを指定した場合、SQLSTATE[HY000] [2002] No such file or directoryエラーが発生します。

test.yml
- name: Execute tests
  env:
    DB_HOST: 127.0.0.1
  run: ./vendor/bin/phpunit

このアクセス情報が.env.testingファイルに設定されていればコードは不要ですが、DB_HOSTの値はローカル環境でテスト用データベースを使用するためにホスト名mysql.testを指定してしまっているのでこれに上書きが必要です。

参考:
PostgreSQLサービスコンテナの作成 - GitHub Docs # ランナーマシン上で直接のジョブの実行
Workflow syntax for GitHub Actions - GitHub Docs

依存関係キャッシュ

GitHub Actions では、毎回仮想環境内にアプリケーション実行環境をセットアップする必要があります。特に、Composerパッケージなどの依存関係を毎回ダウンロードしなければならないというのは、実行時間やネットワークIOなどの面で余計なコストが発生します。プライベートリポジトリでは利用時間の制限があるので、より考慮すべき問題となります。

そこで依存関係をキャッシュしておき、これを次回以降利用することで環境構築を高速化することを目指します。

ワークフローに追加するをコードとしては以下のようになります。

test.yml
- name: Cache Composer packages
  id: composer-cache # actions/cache@v2 に対して付与
  uses: actions/cache@v2
  with:
    path: ./backend/vendor # `vendor/autoload.php`を作成するため
    key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
    restore-keys: |
      ${{ runner.os }}-composer-
- name: Install Dependencies
  if: steps.composer-cache.outputs.cache-hit != 'true' # キャッシュ存在すればスキップ
  run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist

基本的に参考サイトで提示されているテンプレートの流用ですが、相違点としてはキャッシュのパスpath./backend/vendorとしていることです。これは依存パッケージをvendor配下に設置し、キャッシュが存在する場合にインストール処理をスキップするためです。

他のディレクトリの指定ではvendor/autoload.phpが作成されず、composer installをスキップした場合にはそのファイルを要求する旨のエラーが発生します。

PHP Fatal error:  Uncaught Error: Failed opening required '/home/runner/work/{リポジトリ名}/backend/vendor/autoload.php'

尚、working-directory./backendを指定していましたが、ここではルートディレクトリからの相対パスまたは絶対パスを設定します。working-directoryから見た./vendorではないことに注意が必要です。

参考:
依存関係をキャッシュしてワークフローのスピードを上げる - GitHub Docs
PHP - Composer - actions/cache/examples.md - GitHub
Skipping steps based on cache-hit - actions/cache - GitHub
PHP workflow - actions/starter-workflows/ci/php.yml - GitHub

ワークフロー完成形

かくして、以上の設定を組み合わせたワークフローは次のようになりました。

test.yml
name: PHPUnit

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ./backend

    services:
      db:
        image: mysql:8.0
        ports:
          - 3306:3306
        env:
          MYSQL_DATABASE: backend
          MYSQL_ALLOW_EMPTY_PASSWORD: yes
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

    steps:
      - name: Check out repository code
        uses: actions/checkout@v2

      - name: Cache Composer packages
        id: composer-cache
        uses: actions/cache@v2
        with:
          path: ./backend/vendor
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: |
            ${{ runner.os }}-composer-
      - name: Install Dependencies
        if: steps.composer-cache.outputs.cache-hit != 'true'
        run: composer install --no-interaction --prefer-dist
      - name: Generate APP_KEY
        run: php artisan key:generate --env=testing
      - name: Execute tests
        env:
          DB_HOST: 127.0.0.1
        run: ./vendor/bin/phpunit

このYAMLファイルがリポジトリのルートに設置されている.github/workflowsディレクトリに格納することで、以降のPush及びPull Request のタイミングでテストが実行され、その結果はリポジトリの"Actions"タブから確認することができるようになります。

認証

Laravelで認証機能を実装する場合、選択肢が複数存在します。特に、BreezeまたはJetstreamのパッケージを用いた方法では、MVCの"View"にあたるUIも内包された状態で認証機能を導入することができます。ただ今回はこれを利用せず、FortifySanctumという二つのパッケージを組み合わせて認証を実装します。

前述のパッケージを利用しない理由の一つは、"View"の部分で対応しているのが基本的にVue.jsのみであるということです。学習済みである"React"を使用してさらに理解を深めることが目的でもあるので敬遠する要因となっています。その他パッケージ化されている分カスタマイズするには複雑になることも考えられます。一方、Fortifyを利用する場合にはUIは提供されていないので自由にフロントエンドを選ぶことが可能です。

If you are building a single-page application (SPA) that will be powered by a Laravel backend, you should use Laravel Sanctum. When using Sanctum, you will either need to manually implement your own backend authentication routes or utilize Laravel Fortify as a headless authentication backend service that provides routes and controllers for features such as registration, password reset, email verification, and more.

Authentication - Laravel # Summary & Choosing Your Stack

Fortify

Fortifyとは、ログインやユーザー登録、メール認証など基本的な認証機能を提供するパッケージです。先述のBreezeやJetstreamは認証部分にこのFortifyを利用しています。

Fortifyを導入することで、認証に必要なルーティングやコントローラーを用意することができます。これらは自前で実装することも可能ですが、複雑故に知識不足やコードの過不足によって脆弱性の存在を作り出してしまう原因にもなりえます。

認証で実装する内容はアプリケーションによってそれほど違いはないことが多いため、認証についてはパッケージに任せるのが簡単で無難な方法です。

Fortifyは初期状態では含まれていないので、初めにComposerパッケージからインストールを行います。

sail composer require laravel/fortify

次に、アクションやコンフィグ、マイグレーションを出力するためのコマンドを実行します。

php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider"

次に、マイグレーションの内容をデータベースに反映させます。

sail artisan migrate

最後に、Fortify Service Providerクラスをconfig/app.phpに登録することで、アクションを有効化します。

config/app.php
App\Providers\FortifyServiceProvider::class,

Fortifyを利用するための準備が整ったので次に設定を変更していきます。

まず、SPAの場合はログイン画面やユーザー登録画面のViewをバックエンドで提供する必要はないので、それらのルートを無効化するために設定ファイルconfig/fortify.phpviewsの値をfalseに切り替えます。

config/fortify.php
'views' => false,

次に、ルート名の先頭にこれまで同様のapiを付与するためにprefixを指定します。これにより、例えば元々loginだったルートがapi/loginに変更されます。

config/fortify.php
'prefix' => 'api',

この時点で既にconfig/fortify.phpfeaturesで指定した機能が利用できるようになっており、データベースに存在するユーザー情報でapi/loginPOSTリクエストを行うことでログインが可能です。

しかし、別オリジンであるフロントエンドからのリクエストの場合は拒否されます。これを回避するためには後述のCORS及びCSRFトークンの設定に加えて認証パッケージ Sanctumの導入が必要となります。

config/fortify.php
'features' => [
    Features::registration(),
    Features::resetPasswords(),
    // Features::emailVerification(),
    Features::updateProfileInformation(),
    Features::updatePasswords(),
    Features::twoFactorAuthentication([
        'confirmPassword' => true,
    ]),
],

参考:
Laravel Fortify - Laravel
Laravel Fortify SPA Authentication with Laravel Sanctum without Jetstream - YouTube
Getting started with Laravel Fortify and Sanctum - YouTube
Updates to the Laravel Fortify SPA Authentication, Improvements & Routes File Cleanup - YouTube

Sanctum

Sanctumはアプリケーションに認証機能を提供するパッケージです。Fortifyと異なりこちらはルーティングやコントローラーでの処理は含まれておらず、リクエストの正当性を検証するための方法を提供します。

Laravel Sanctum is only concerned with managing API tokens and authenticating existing users using session cookies or tokens. Sanctum does not provide any routes that handle user registration, password reset, etc

Laravel Fortify - Laravel # Laravel Fortify & Laravel Sanctum

先述のとおり、Fortifyを利用しない場合であっても代わりのコードを用意することは可能です。一方、Sanctumが提供する機能は、Jetstreamなどのパッケージを採用する場合を除いて、API認証を行う上で基本的に必要となります。

認証方式

認証の方法として、APIトークンを利用した認証とSPA認証という二つが用意されていますが、SPAのバックエンドとして用いる場合にはSPA認証の方を利用するべきとの記載があるのでそれに従います。これはAPIトークンの代わりにCookieとセッションを利用した認証方式です。

You should not use API tokens to authenticate your own first-party SPA. Instead, use Sanctum's built-in SPA authentication features.

Laravel Sanctum - Laravel # API Token Authentication

SanctumはLaravelの初期状態に含まれていないので、初めにComposerパッケージからインストールを行います。

sail composer require laravel/sanctum

次に、コンフィグ及びマイグレーションを出力します。

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

最後に、マイグレーションの内容をデータベースに反映させます。

sail artisan migrate

Sanctumを利用するための準備が整ったので次に設定を変更していきます。

まずフロントエンドでCookieを受け入れるようにするため、使用しているドメインをconfig/sanctum.phpに追加します。利用している環境によって異なりますが、今回の場合はlocalhost:3000です。コンフィグでは環境変数を参照するようになっているので.envファイルの方に記入します。

config/sanctum.php
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/

'stateful' => explode(',', env(
    'SANCTUM_STATEFUL_DOMAINS',
    'localhost,127.0.0.1,127.0.0.1:8000,::1'
)),
.env
SANCTUM_STATEFUL_DOMAINS=localhost:3000

加えて、app/Http/Kernel.phpのミドルウェアグループapiEnsureFrontendRequestsAreStatefulを追加します。

app/Http/Kernel.php
protected $middlewareGroups = [
    'web' => [
        \App\Http\Middleware\EncryptCookies::class,
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
        \Illuminate\Session\Middleware\StartSession::class,
        // \Illuminate\Session\Middleware\AuthenticateSession::class,
        \Illuminate\View\Middleware\ShareErrorsFromSession::class,
        \App\Http\Middleware\VerifyCsrfToken::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],

    'api' => [
        \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, // 追加
        'throttle:api',
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],
];

先述のように、Sanctumは、Cookieとセッションを利用した認証方式です。しかしデフォルトではミドルウェアグループapiに含まれていません。EnsureFrontendRequestsAreStatefulはそれらの他必要なミドルウェアの代替も果たすものです。そして、上記のSANCTUM_STATEFUL_DOMAINSからのリクエストの場合にそれを有効にさせるようになっています。

認証ルーティング

アクセスにログインを必要とするルートを定義するには、sanctum"guard"を追加します。

routes/api.php
Route::middleware('auth:sanctum')
    ->apiResource('users.task_cards', TaskCardController::class)
    ->only('store');

これにより、未ログインの状態でこのルートにアクセスした場合には401エラーが発生します。

ログインリクエスト

ログインを行うには設定済みのFortifyによって提供されるルートapi/loginにユーザー情報を持ったPOSTリクエストを送ります。後述のCORSの設定は完了済みとすると、Axiosを利用する場合フロントエンドのコードは例えば以下のようになります。

apiClient.post('/api/login', {
    email: 'username@example.com',
    password: 'password'
}).then(response => {
    console.log(response)
})

このとき、emailに'username@example.com'を持ちpasswordの値が'password'であるUserが存在しない場合、422エラーが発生します。

注意点として、データベースとの値と照合するときのpasswordの値はハッシュ値であるということです。テストを行う際には、データ生成用のUserFactoryにおけるpasswordの値がハッシュ化されていることを確認します。尚、デフォルトでは'password'のハッシュ値になっているようです。

database/factories/UserFactory.php
public function definition()
{
    return [
        'name' => $this->faker->name,
        'email' => $this->faker->unique()->safeEmail,
        'email_verified_at' => now(),
        'password' => Hash::make("password"), // 明示的なハッシュ化
        'remember_token' => Str::random(10),
    ];
}

まだCSRF保護機能への対応を行っていないため、このリクエストに対しては419 (CSRF token mismatch)エラーを返します。

CSRFトークン

LaravelではセッションごとにCSRFトークンを生成し、リクエスト時にそれを検証することで正当なユーザーからのアクセスであることを確認しています。このCSRFトークンをCookieXSRF-TOKENにセットする必要がありますが、そこで行うのがsanctum/csrf-cookieに対するGETリクエストです。このリクエストはログインリクエストの直前に行います。

apiClient.get('/sanctum/csrf-cookie').then(response => {
    apiClient.post('/api/login', {
        email: 'username@example.com',
        password: 'password'
    }).then(response => {
        console.log(response)
    })
});

CORSを利用するため、config/cors.phppathsanctum/csrf-cookieの追加が必要です。(CORSの項を参照)

config/cors.php
'paths' => ['api/*', 'sanctum/csrf-cookie'],

そして、リクエスト時にこのXSRF-TOKENの値をヘッダーX-XSRF-TOKENにセットすることを要しますが、フロントエンド側でリクエストにAxiosを利用している場合にはこの動作は自動的に行われます。

Cookieが有効であり、XSRF-TOKENの値がX-XSRF-TOKENに入っていれば、その後のリクエストで419エラーは発生しなくなり成功します。

尚、セッションの期限切れなどによって有効でなくなった場合には401又は419エラーが返されます。その場合再度ログインが必要となるので、フロントエンド側ではログインページにリダイレクトを行うなどの対応が求められます。

参考:
Authentication - Laravel
CSRF Protection - Laravel
Laravel Sanctum - Laravel
Using Sanctum to authenticate a React SPA | Laravel News
Laravel Sanctum SPA Tutorial - React SPA Authentication With Sanctum - YouTube
Getting started with Laravel Fortify and Sanctum - YouTube

CORS

異なるオリジン間でサーバーからのレスポンスを受け取るには、CORS (Cross-Origin Resource Sharing) の設定が必要になります。これはブラウザに備えられた同一オリジンポリシーの機能によって、他のオリジンのリソースにアクセス制限がかけられているためです。

参考:
オリジン間リソース共有 (CORS) - HTTP | MDN
同一オリジンポリシー - Web セキュリティ | MDN
Using Sanctum to authenticate a React SPA | Laravel News - A digression on CORS

Access-Control-Allow-Origin

CORSを有効化するには、まずレスポンスヘッダーAccess-Control-Allow-Originの値にフロントエンドで利用しているオリジン (ここではhttp://localhost:3000) を指定する必要がありますが、Laravelでは、config/cors.phpでそれを行います。

現在の設定は以下のようになっており、許可されるオリジンの指定にはワイルドカードが使用されており任意の値を示している一方でパスの指定では制限がかけられています。

config/cors.php
'paths' => ['api/*', 'sanctum/csrf-cookie'], // CORSを許可するパス
// ...
'allowed_origins' => ['*'], // CORSを許可するオリジン

ルート設定でroutes/api.phpを利用しているので、リクエストは基本的に許可されるパスapi/*に該当します。一方、Sanctumを利用する際にsanctum/csrf-cookieへのアクセスを行いますが、これは先頭がapiでないため上記pathsの配列に追加します。

このヘッダーと、リクエスト側のヘッダーであるOriginの値が一致してしる場合、CORSは有効に作用します。尚、このOriginはリクエストヘッダーに自動的に付与されるので特に設定の必要はありません。

プリフライトリクエスト

CORSを利用するにあたって、ブラウザは本来のリクエストの前にそれが許可されているかをサーバーに問い合わせる目的でOPTIONSリクエストを送信します。これをプリフライトリクエストと呼びます。

ここで前述のconfig/cors.php設定によってリクエストが許可されていれば、CORSポリシーによるブロックは解かれるようになります。

Access-Control-Allow-Credentials

加えて、Cookieを利用したリクエストの場合にはレスポンスヘッダーにAccess-Control-Allow-Credentialstrueにして追加することも必要です。Laravelでそれを行うには、config/cors.phpsupports_credentialstrueに設定します。

config/cors.php
'supports_credentials' => true,

次に、リクエスト側でも上記に対応する設定が必要で、Axiosを利用する場合、withCredentialsオプションをtrueにして追加します。

import axios from 'axios';

const apiClient = axios.create({
    baseURL: 'http://localhost',
    withCredentials: true,
});

// 例: axios.get() の代わりに、apiClient.get() を使用

参考: The Axios Instance | Axios Docs

Domain属性

Cookieに関して、上記に加えてもう一つ設定があります。それはCookieを受信することができるドメインを指定することです。そのためにはconfig/session.phpdomeinの値を設定しますが、これはCookieのDomain属性を指定することに相当します。

config/session.php
'domain' => env('SESSION_DOMAIN', null),

これを指定していない場合はCookieを設定したのと同じオリジンになりますがサブドメインは除外されます。サブドメインでも利用する場合には以下のように先頭にドット(.)を用います。

.env
SESSION_DOMAIN=.domain.com

参考:
Laravel Sanctum - Laravel # CORS & Cookies
HTTP Cookie の使用 - HTTP | MDN

以上で、Cookieを利用したSPA認証を利用することができるようになりました。

まとめ

ここまでLaravel実装の中でも特に序盤で行う内容について説明してきました。今回はここまでとなりますが、まだ触れられていないことも多く存在するので折に触れて追記していきたいと思います。

各種リンク

20
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
19