LaravelでSPA認証を実装する

LaravelでSPA認証を実装する

概要

LaravelでのSPA認証の実装方法の覚書。 使用技術は以下の通り。

  • 手動による認証+SanctumによるSPA認証
  • セッション+クッキー認証

環境

  • PHPのバージョン
    • 8.1.27
  • Laravelのバージョン
    • v9.52.16
  • Sanctumのバージョン
    • v3.3.3

参照ドキュメント

構成

今回はログイン画面はSSRで実装しログイン後はSPAとなる構成としている。

実装手順

  1. ログインの実装
    1. ログイン画面はSSRで実装する
  2. ログアウトの実装
  3. ルートを認証で保護する
  4. フロントの実装

ログインの実装

ログインAPIの実装

ログイン用のコントローラを実装する。実装コードはほぼ以下URLの「ユーザーを手作業で認証する」の通り実装。コード中の詳細についてもリンク先を参照。

https://readouble.com/laravel/9.x/ja/authentication.html

<?php

namespace App\Http\Controllers;

use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;

class LoginController extends Controller
{
    /**
     * 認証の試行を処理
     */
    public function authenticate(Request $request): RedirectResponse
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required'],
        ]);

        if (Auth::attempt($credentials)) {
            $request->session()->regenerate();

            return redirect()->intended('/');
        }

        return back()->withErrors([
            'email' => 'The provided credentials do not match our records.',
        ])->onlyInput('email');
    }
}

web.phpにルーティングを追加する。

Route::post('/login', [LoginController::class, 'authenticate']);

ログイン画面の実装

ログイン画面はSSRで実現するためLaravelのBladeを使用する。

resources/views/login.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>Login</title>
</head>

<body>
    <section>
        <h1>Login</h1>

        <div>
            <form action="{{ url('/login') }}" method="POST">
                {{ csrf_field() }}
                <div>
                    <p>email</p>
                    <input type="email" name="email" value="{{ old('email') }}" required autofocus>

                    @error('email')
                        <div>
                            {{ $message }}
                        </div>
                    @enderror

                </div>
                <div>
                    <p>password</p>
                    <input type="text" name="password">
                </div>
                <div>
                    <!-- 送信ボタン -->
                    <input type="submit" value="送信">
                </div>
            </form>
        </div>

    </section>

</body>

</html>
  • formタグで先ほど作成した認証ルートにCSRFトークン付きでPOST
  • 認証がエラーした場合はフラッシュメッセージとして@error('email')部分を表示

ログイン画面のコントローラを作る。LoginController.phpに以下のメソッドを追加する。

    public function index(): View
    {
        return view('login');
    }

web.phpにルーティングを追加する。

Route::get('/login', [LoginController::class, 'index'])->name('login');

ログイン先の画面を作る

ログイン成功時の遷移先の画面を作る。resources/views/index.blade.phpを作る。後ほどSPAログインに変更する際にまた書きかえるので一旦は遷移したことがわかる程度の内容にする。

<! DOCTYPE html>
<html>
<head></head>
<body>
    <div>ログインしました</div>
</body>
</html>

web.phpにルーティングを追加する。

Route::get('/', function () {
    return view('index');
});

ログアウトの実装

ログアウトの処理を作成する

コントローラの作成。 これも基本的には以下の参照先に記載の「ログアウト」の項目の通り実装する。

https://readouble.com/laravel/9.x/ja/authentication.html

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;

class LogoutController extends Controller
{
    /**
     * ユーザーをアプリケーションからログアウトさせる
     */
    public function logout(Request $request): RedirectResponse
    {
        Auth::logout();

        $request->session()->invalidate();

        $request->session()->regenerateToken();

        return redirect('/login');
    }
}
  • セッションを無効にする
  • CSRFトークンを初期化する
  • ログアウト後の画面にリダイレクトする

コントローラが完成したらweb.phpにルーティングを追加する。

Route::post('/logout', [LogoutController::class, 'logout']);

ログアウトボタンを画面に追加する

ログアウトするためのボタンをindex.blade.phpに追加する。

<! DOCTYPE html>
<html>
<head></head>
<body>
    <div>ログインしました</div>
    <div>
        <form action="{{ url('/logout') }}" method="POST">
            {{ csrf_field() }}
            <div>
                <input type="submit" value="ログアウト">
            </div>
        </form>
    </div>
</body>
</html>

ログイン後、ログアウトボタンを押すとログイン画面にリダイレクトされればOK。

ルートを認証で保護する

ログイン状態でないと/にはアクセスできないようにweb.php実装する。参照先の「ルートの保護」の通り実装する。

https://readouble.com/laravel/9.x/ja/authentication.html

Route::get('/', function () {
    // 認証済みユーザーのみがこのルートにアクセス可能
    return view('index');
})->middleware('auth');

->middleware()の追加でこのルートには認証済みのユーザしかアクセスできないようになる。

フロントの実装

ここまででSSRによる認証の機構は完成したので、最終のゴール地点である「ログインまでSSRで行いログイン後はSPAとして動かす」 ところまでを実装する。なお、フロントのフレームワークとしてはVue.jsを使用するが、こちらの細かい実装内容については割愛する。

index.blade.phpにVueを埋め込めるよう変更した上、resources/js/components/App.vueを以下の通り実装する。

<template>
    <div>
        <p>Vueのページ</p>
    </div>
    <div>
        <button type="button" v-on:click="logout"> ログアウト </button>
    </div>
</template>

<script lang="ts" setup>
import axios from "axios";

const logout = (): void => {
    axios.post("logout");
    window.location.href = "/login";
};
</script>

ログアウトをリクエスト後、window.location.hrefを更新してフロント側で自前でロケーションを切り替える必要がある。

Sanctumについて

フロント側で認証する場合、Sanctumによる以下の手順が必要だが。

  1. Sanctumのミドルウェアapp/Http/Kernel.phpに追加
  2. ログイン前にsanctum/csrf-cookieにアクセスしCSRFトークンを初期化
    • XSRF-TOKENを取得する
  3. リクエスト時にXSRF-TOKENをヘッダに付与する
  4. ログイン後にアクセスするルーティングをSanctumミドルウェアで保護する

1に関しては自分の環境ではLaravelにデフォルトで設定されていた。 throttle:apiが該当。

app/Http/Kernel.php

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

2のsanctum/csrf-cookieのルーティングもデフォルトで設定されている。 また、初めのSSRの認証時にCSRFトークンを取得するため、特に以下へのアクセスは本手順では不要。

php artisan route:list

  GET|HEAD   sanctum/csrf-cookie ............................................... sanctum.csrf-cookie › Laravel\Sanctum › CsrfCookieController@show

3のトークンの付与はaxiosなどのライブラリを使用した場合自動で付与してくれる。

4のログイン後にアクセスするルーティングの保護はroutes/api.phpにデフォルトで参考となるソースがあるためこちらを元に実装する。

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

以上でLaravelによるSPA認証の実装が完了。

Laravelで自作の認証を作成する

Laravelで自作の認証を作成する

Laravelデフォルトのusersプロバイダーではなく、自作で認証機構を作成する方法をまとめる。

環境

  • PHPのバージョン
    • 8.1.27
  • Laravelのバージョン
    • v9.52.16

参照ドキュメント

Laravelでの認証方法の概要

Laravelで認証機構を自作する場合は自作のユーザープロパイダーを作成する必要がある。 また、そのユーザープロバイダーの一部のメソッドで返すクラスはAuthenticatableインタフェースを実装する必要がある。 ユーザープロバイダーを作成したら、認証で使用しているガードにそのプロバイダーを設定する。

自作で認証機構を作る方法

大まかな手順は以下の通り。

  1. Authenticatableインタフェースを実装した認証済ユーザーの情報を持つクラスを作成する
  2. 自作のユーザープロバイダーを作成する
  3. サービスプロバイダーに自作のユーザープロバイダーを登録する
  4. 認証で使用しているガードに作成したユーザープロバイダーを設定する

作成手順

Authenticatableを実装したクラスを作成する

Illuminate\Contracts\Auth\Authenticatableインタフェースを実装したクラスを作成する。 implementsするには以下のメソッドの実装が必要。 具体的な実装例はEloquentモデルのUserクラスなどを参考にするとよい。

  • getAuthIdentifierName
    • ユーザをユニークに識別できるIDのキー名を返す
    • RDBMSなどではテーブルのカラム名にだいたいはなる
  • getAuthIdentifier
    • ユーザのIDの値を返す
  • getAuthPassword
    • ユーザのパスワードを返す
  • getRememberToken
    • remember tokenの値を返す
  • setRememberToken
    • remember tokenを設定する
  • getRememberTokenName

自作のユーザープロバイダーを作成する

Illuminate\Contracts\Auth\UserProviderインタフェースを実装した自作のユーザープロバイダーを作成する。 既存のEloquentUserProviderクラスなどを参考に実装する。実装が必要なメソッドは以下。

  • retrieveById($identifier);
    • IDから該当するユーザを取得する
    • 返すクラスは先程実装したAuthenticatableを実装した自作ユーザクラスを返す
  • retrieveByToken($identifier, $token);
    • IDとremember me tokenからユーザを取得する
    • こちらも戻り値として自作ユーザクラスを返す
  • updateRememberToken(Authenticatable $user, $token);
    • 自作ユーザクラスとremember me tokenを引数で受け取りremember me tokenを更新する
  • retrieveByCredentials(array $credentials);
    • 引数でemailやパスワードなどの認証情報を受け取り自作ユーザを取得する
    • 戻り値は自作ユーザ
  • validateCredentials(Authenticatable $user, array $credentials);
    • 引数で自作ユーザとログイン時に入力された認証情報を受け取り合致するか検証し結果を返す

サービスプロバイダーに自作のユーザープロバイダーを登録する

config/auth.phpで自作プロバイダーを使用できるようAuthServiceProviderに設定を追加する。 \Auth::providerメソッドの第一引数にconfig/auth.phpで使用する名前を任意で指定する。 第二引数で自作プロバイダーを生成するメソッドを指定する。

    public function boot()
    {
        $this->registerPolicies();

        //カスタムプロバイダの名前を定義
        \Auth::provider(
            // config/auth.php には、この名称で設定を行う。
            'my_user',
            function ($app, array $config) {
                // MyUserProviderは自作プロバイダー
                return new MyUserProvider($app['hash']);
            }
        );
    }

認証で使用しているガードに作成したユーザープロバイダーを設定する

作成したユーザープロバイダーを使用するためconfig/auth.phpの設定を変更する。 現在使用しているガードが使用しているユーザープロバイダーを自作したユーザープロバイダーに変更する。

例えばデフォルトで使用しているガードが以下のwebガードで使用しているproviderusersの場合、

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
    ],

providers.usersに以下のように自作ユーザープロバイダーを設定する。ここで設定する値が先程AuthServiceProviderで設定した文字列になる。

    'providers' => [
        'users' => [
            'driver' => 'my_user',
        ],
    ],

おわり

以上で自作ユーザープロバイダーと自作ユーザーを使用した認証の作成は完了。 Auth::user()メソッドやテスト時のTestCase::actingAsメソッドなどで自作のAuthenticatableを実装したユーザクラスが使用できるようになる。

はてなブログの WEB API を使う

はてなブログの WEB API を使う

やりたいこと

はてなブログが公開している WEB API, Atom Pub API を使用した 記事の作成, 更新をするスクリプトを作成したため覚書記事を作成します.

本記事では Atom Pub API を使用した記事の取得, 新規作成, 更新の方法についてまとめます.

今回作成したスクリプト(TypeScript製)は以下にあります. こちらは GitHub で管理している記事をはてなブログ 側にアップロードするスクリプトになります. 詳細は README.md を参照してください.

基本事項

API 使用のために以下の情報が必要になります.

  • user ID
  • ルートエンドポイントのURL
    • https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom みたいなやつ
  • API KEY

user IDはてなブログの管理画面(ダッシュボード)のアカウントから確認できます. ルートエンドポイントと API KEY は「管理画面」→「設定」→「詳細設定」から確認できます.

認証情報

認証に使用する手段は複数あるようですが, 今回は一番手っ取り早い Basic 認証を 用いた方法を使用します.

ユーザー名を user ID, パスワードを API KEY としてコロンでつなげて Base64 エンコードした文字列を使用します.

TypeScript では以下のように auth オブジェクトに usernamepassword を設定します.

axios.get(url, {
            headers: {
                'Content-Type': 'application/xml',
            },
            auth: {
                username: userId,
                password: apiKey,
            },
        })

もしくは自前でユーザーID, API KEYをコロンで連結した文字列を Base64 エンコード し, Authorization ヘッダにBasic認証として設定します.

import { Buffer } from 'buffer';

const basicAuth = Buffer.from(`${userId}:${apiKey}`).toString('base64');

axios.get(url, {
            headers: {
                'Authorization': 'Basic ${basicAuth}',
                'Content-Type': 'application/xml',
            },
        })

curl なら以下のような感じです.

curl -u userId:apiKey https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry

記事一覧の取得

記事一覧の取得はルートの URL + /entry のエンドポイントに対して GET リクエスト で取得できます. Basic認証も忘れずに.

TypeScript では以下のような雰囲気になります. (実際に動かしていないので動くかわからないです. あくまで雰囲気です. 以降のコードサンプルも動作確認はしていませんのでご了承ください.)

const url = 'https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry';

response = await axios.get(url, {
            headers: {
                'Content-Type': 'application/xml',
            },
            auth: {
                username: USER_ID,
                password: API_KEY,
            },
        })

console.log(response.data);

レスポンスは以下のような xml で返ってきます.

<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
      xmlns:app="http://www.w3.org/2007/app">

  <link rel="first" href="https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry" />

  
  <link rel="next" href="https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry?page=1658475002" />
  

  <title>userIdのブログ</title>
  
  <link rel="alternate" href="https://userId.hatenablog.com/"/>
  <updated>2023-05-12T00:56:57+09:00</updated>
  <author>
    <name>userId</name>
  </author>
  <generator uri="https://blog.hatena.ne.jp/" version="xxxxxxxxxxxxxxxxxxxxxx">Hatena::Blog</generator>
  <id>hatenablog://blog/yyyyyyyyyyyyyyyy</id>

一覧取得のエンドポイントはデフォルトで7件ほどしか記事情報を返しません. 続きを取得する場合は <link rel="next" のタグの href 属性に 続きの記事を取得するための URL が設定されているため, そこにリクエストすれば続きを取得できます.

こんな感じ

  <link rel="next" href="https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry?page=nnnnnnnnnn" />

全件記事情報を取得する場合は, <link rel="next" がなくなるまで繰り返す感じになります.

記事の新規作成

記事の新規作成はルートの URL + /entry に対して記事の情報を xml で作成したデータ とともに POST メソッドリクエストで行います.
TypeScript の例では以下のような感じになります.

function create(title: string, contents: string): void {
    const url = 'https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry';
    const xmlData = `<?xml version="1.0" encoding="utf-8"?>
    <entry xmlns="http://www.w3.org/2005/Atom">
      <title>${title}</title>
      <content>${contents}</content>
      <updated>${new Date().toISOString()}</updated>
    </entry>`;

    // POSTリクエストを送信
    axios.post(url, xmlData, {
        headers: {
            'Content-Type': 'application/xml',
        },
        auth: {
            username: USER_ID,
            password: API_KEY,
        },
    }).then((response) => {
        if (response.status !== 201) {
            throw new Error(`HTTPステータスコード ${response.status}`)
        }
    }).catch((error) => {
        throw new Error(`${error}`);
    });
}

記事の更新

既存の記事の更新はルートの URL + /entry/記事のID に対して更新する記事の情報を xml で作成したデータ とともに PUT メソッドリクエストで行います.
ここで「記事のID」の取得方法についてですが, 記事一覧で取得した xml に含まれる <link rel="edit" タグの href がそれに該当します.

こんな感じ.

<link rel="edit" href="https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry/3824838384928"/>

この URL に対して PUT します.

TypeScript の例では以下のような感じになります.

function update(title: string, contents: string): void {
    const url = 'https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry/3824838384928';
    const xmlData = `<?xml version="1.0" encoding="utf-8"?>
    <entry xmlns="http://www.w3.org/2005/Atom">
      <title>${title}</title>
      <content>${contents}</content>
      <updated>${new Date().toISOString()}</updated>
    </entry>`;

    // POSTリクエストを送信
    axios.put(url, xmlData, {
        headers: {
            'Content-Type': 'application/xml',
        },
        auth: {
            username: USER_ID,
            password: API_KEY,
        },
    }).then((response) => {
        if (response.status !== 201) {
            throw new Error(`HTTPステータスコード ${response.status}`)
        }
    }).catch((error) => {
        throw new Error(`${error}`);
    });
}

注意事項

記事のコンテンツは HTML エスケープすること

<content> タグ内のテキストに HTML のタグがあるとサーバ側に怒られるため, 記事の内容はエスケープすること.

import * as he from 'he';

    const escaped = he.escape(contents);

    // 更新するためのXMLデータを作成
    const xmlData = `<?xml version="1.0" encoding="utf-8"?>
    <entry xmlns="http://www.w3.org/2005/Atom">
      <title>${entry.title}</title>
      <content>${escaped}</content>
      <updated>${new Date().toISOString()}</updated>
    </entry>`;

Web API 作成において心得たいことまとめ

Web API 作成において心得たいことまとめ

最近読んだ書籍「Web API The Good Parts」について個人的に心に留めておきたいことをまとめます. なお, 本書籍は発行年2014年と今となっては若干古いため, 現在では古い情報もあることを念頭において読み進めました.

Web API The Good Parts

そのため, 本記事のまとめも今では古いノウハウがあるかもしれません. そういった情報に対してはツッコミをいただけると幸いです.

だいたい, リクエスト, レスポンス, その他といった感じで大まかに3つのセクションにまとめます.

2.2.1 などの数値はそのノウハウが書籍に記載されている章番号です.

リクエストとエンドポイント関連

主に2章の内容.

URI でなんとなく使い方の想像がつく

ドキュメントとかいちいち見たくないため. http://example.com/v1/user/123 だったら ID 123 のユーザーを取得するみたいに容易に 想像がつく URI が理想.

URI に大文字小文字が混ざっていない

基本はすべて小文字を使用する. ホスト名が大文字小文字を無視するため, ホスト部のルールに足並みを揃える.

エンドポイントには複数形の名詞

2.4.1.1

  • users, friends, updates など
  • 集合を表しているものは複数形のほうが適切であるという考え
  • そもそもなぜ名詞なのか?
    • URIがそもそもリソースを表すものであるため
    • そして HTTP のメソッドが動詞を表す

エンドポイントの単語のつなぎは「ハイフン」

2.4.4

  • 区切り候補
    • キャメルケース
    • スネークケース
    • スパイナルケース
  • Google がハイフンを推奨しており SEO 的によいっぽい
  • アンダースコアだとリンクアドレスに下線が引かれて重なって見づらい
  • URIホスト部はハイフンは許可されているがアンダースコアは使えず、大文字小文字の区別がないためホスト部とルールを統一するとハイフンとなる
  • 実際のところ最も良いのは極力単語を繋げないこと
    • パスで区切る
    • 短い表現を目指す
    • クエリパラメータにする
  • しかし割と好みなので、別にアンスコ、キャメルケースでもよい

一括取得系は絞り込みも対応

2.5

GET: /users/ とか

一括取得系のエンドポイントはクエリパラメータを利用して絞り込み 取得などできるとよいかも.

クエリパラメータとパスの使い分け

2.5.5

パラメータをクエリパラメータにするかパスに入れるかの使い分け.

クエリパラメータは以下のような感じのパスの後に?hoge=123などがつく形式.

http://example.com/v1/user/123?hoge=123&fuga=xyz

パスに入れるとは ID 123 のユーザーを取得したい場合は以下のように ID をパスに入れる形式.

http://example.com/v1/user/123

リソースを指定する場合にどちらを採用するかの判断基準は以下.

① 一意なリソースを表すのに必要な情報かどうか

URI がそもそもリソースを表すものであるため, 参照したい情報が一意に決まる場合はパスにしたほうがよい.

② 省略しても良いようなパラメータはクエリにする

メソッド

PUT と PATHCH はどちらも更新系のメソッドだが具体的な使い分けについて.

  • PUT
    • 2.3.3
    • リソースを完全上書きする場合に使用
    • 送信するデータでもともとのリソースを完全に上書きする
  • PATCH
    • 2.3.5
    • リソースの一部を修正する場合に使用

エンドポイントの共通部分にバージョンを付与する

2.7

以下の v1 のような感じ.

http://example.com/v1/xxx

レスポンス関連

レスポンスの内容をユーザーが選べるようにする

3.3.1

ユーザーがほしい情報だけを選べるような作りにする. (名前がほしいだけなら名前だけとか) ほしくない情報が含まれるとデータサイズ的にも無駄になってしまう.

クエリパラメータで指定できるようにする.

http://example.com/v1/users/123?fields=name,age

レスポンスデータは配列ではなくなるべくオブジェクトで?

3.3.4

レスポンスデータに配列を使用するかオブジェクトで配列を包むかで 迷った場合はオブジェクトに統一するほうがややオススメ.

  • レスポンスデータが何を示しているのかわかりやすくなる
  • レスポンスデータをオブジェクトに統一できる
    • クライアント側で処理を共通化できる
  • JSONインジェンクションのリスクが軽減する

読み込んだJSONファイルがオブジェクトの場合, トップレベルにブレースがきて JavaScriptの構文としておかしいためJSONインジェクションを防ぐ可能性がやや上がる.

単/複数形に気をつける

3.4.1

データが複数(配列)になる可能性があるなら「複数形」, 1つだけなら単数形というかたちできちんと使い分ける.

日付はRFC3339を使用する

3.4.3

RFC3339 とは以下のようなフォーマットの日付.

2015-10-15T11:30:22+09:00

  • 数ある日時を表すフォーマットの問題を解決したフォーマットだから
  • 読みやすく使いやすいを目指したインターネット上で用いる標準形式だから
  • 冗長な表現もない
    • 他であるような日付と曜日両方を含むなど

ただし, HTTPヘッダの Date や Expires などには使用できない...

エラー時のステータスコードは適切に

3.6.1

ちゃんとステータスコードでエラーの状態を示す. リクエストが成功していないのに200番台を返すなどはしない.

クライアントライブラリとかでステータスコードを見て処理を分岐するものなども あるため, ちゃんと作法に従う.

データをエンベロープ(封筒)で包まない

3.3.2

エンベロープとはAPI的にはすべてのデータを同じ構造でくるむこと.

「HTTPの仕様をフル活用する」にも書いているがそもそもHTTPヘッダがエンベロープなので冗長になる.

その他

HTTPの仕様をフル活用する

4

  • HTTPヘッダ
  • ステータスコード
  • キャッシュ
    • クライアント側にキャッシュをさせたい/させたくないなどHTTPヘッダである程度コントロールできる
    • Cache-Contorol
      • キャッシュしてほしくない場合は no-cache, no-store
    • Vary
      • URI以外にデータを一意に特定するリクエストヘッダを指定する
      • URIが同じでもVaryで指定したヘッダに変化があった場合キャッシュではなくデータをとりに行く
  • メディアタイプ

Laravel : Docker + VSCode でデバッガ環境構築

Laravel : Docker + VSCode でデバッガ環境構築

目的

Docker コンテナ上で動作してる Laravel を xdebugデバッグできる環境を構築する.

環境

  • macOS
  • Docker
    • 20.10.12
  • Laravel
    • 9.21.5
  • PHP
    • 8.1.8
  • Composer
    • 2.3.10
  • MySQL
    • 8.0.29

手順

Docker コンテナを使用した環境構築は完了している前提で記事を書きます. Docker を使ったサーバー構築手順は以下の記事にまとめています.

https://kita127.hatenablog.com/entry/2022/10/02/145614

編集するファイルは以下の4ファイル.

  • Dockerfile
  • php.ini
  • docker-compose.yaml
  • .vscode/launch.json

Dockerfile

サーバー用のコンテナの Dockerfile に xdebug をインストールする記述を追加する.
pecl install xdebugxdebug をインストールする.
docker-php-ext-enable xdebugxdebug を有効化する.

# xdebug のインストール
RUN pecl install xdebug \
  && docker-php-ext-enable xdebug

php.ini

コンテナサーバーに配置する php.ini に以下を追加する. 9012 は xdebug のポート

[xdebug]
xdebug.mode = debug
xdebug.start_with_request = yes
xdebug.client_host = "host.docker.internal"
xdebug.client_port = 9012
xdebug.log = "/var/log/xdebug.log"

docker-compose.yaml

サーバーコンテナの volumes に xdebug のログをマウントするよう- ./log:/var/log を追加する. 左側はローカルのログ置き場のパス, 右側は php.ini で設定した xdebug のログのディレクトリ.

    volumes:
      - ./webapp:/var/www/html
      - ./docker/apache/php.ini:/usr/local/etc/php/php.ini
      - ./log:/var/log

launch.json

portxdebug のポート 9012 を設定する
pathMappings は左に Docker コンテナ上に配置している Laravel アプリケーションのパス
右にローカルで作業している Laravel アプリケーションのパスを設定する.

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Listen for XDebug",
            "type": "php",
            "request": "launch",
            "port": 9012,
            "pathMappings": {
                "/var/www/html": "${workspaceFolder}/webapp"
            }
        }
    ]
}

試す

以上で xdebug を使用する環境構築は完了. 任意の箇所にブレイクポイントを設定し, start debugging をすればステップ実行や変数の状態の確認などができるようになる.

参考記事

以下の記事を参考にさせていただきました.

https://maasaablog.com/development/backend/php/laravel/2308/

Docker + Apache + Laravel で Web アプリケーションつくる

Docker + Apache + Laravel で Web アプリケーションつくる

やりたいこと

Laravel 製の Web アプリケーションを作成し, 自宅サーバ PC にデプロイしたい. とりあえず, 自宅のプライベートネットワーク内だけの運用を想定しているがそのうち VPN などで外部からもアクセスできるようにしたいかも.

デプロイ作業を楽にしたい&環境構築とかでカオスになりたくないため Web サーバーやデータベースは Docker コンテナを利用し, 各コンテナを連携させる構成とする.

Docker コンテナを利用した Laravel 開発には Sail があるが, Apache への載せ替え方がわからなかったのと, Docker の勉強も兼ねてコンテナ連携の構築から自前で作成する.

また, 自身の勉強も兼ねているため各技術要素についてなるべく詳細に残していく予定.

要件

  • Web サーバーとデータベースはそれぞれ Docker コンテナ化し連携する
  • Web サーバーは Apache を使用する
  • データベースは MySQL を使用する
  • Web アプリケーションは PHPフレームワーク Laravel を使用し作成する

本記事の内容

本記事では要件のうち基盤づくりまで, つまり Docker で ApacheMySQL のコンテナ を作成・連携, Apache コンテナ上に構築した Laravel から簡単なレスポンスをクライアント返すところまでを作る.

また, 作成したプロジェクトのリポジトリは以下.
https://github.com/kita127/docker-apache-example

環境

ホストPCで使用する各種ツールのバージョンは以下. 開発は Mac PC で行う.

サーバ PC は準備中. おそらく ubuntu を採用する見込み. 現段階ではホストPCのみで開発をすすめる.

環境構築

必要なアプリケーションを Homebrew を使ってインストールする.

# brew install --cask docker
# brew install php
# brew install composer

構成

今回作成するプロジェクトの構成は以下の通り.

プロジェクトトップ
├── docker/
│      ├── apache/
│      │      ├── Dockerfile
│      │      ├── config/
│      │      │      └── 000-default.conf
│      │      └── php.ini
│      └── db/
│              ├── Dockerfile
│              └── initdb.d/
│                      └── master.sql
├── docker-compose.yaml
└── webapp/
  • docker/
    • 各コンテナの Dockerfile やコンフィグ系のファイルを格納する
  • docker/apache/
    • Apache コンテナの設定や Dockerfile など
    • Web サーバの設定である .conf ファイルもここで管理する
    • php.ini もここで管理
  • docker/db/
    • DB(MySQL) コンテナの Dockerfile や設定ファイル等を管理
    • コンテナ起動時に実行される SQL ファイル(master.sql)も管理
  • docker-compose.yaml
    • コンテナの連携のための Docker Compose 設定ファイル
  • webapp/
    • Web アプリケーション(Laravel プロジェクト)

手順

Docker の構築

docker-compose.yaml の作成

Webサーバと DB のコンテナを作成する設定をつくる. 構成に記載の docker-compose.yaml を以下の通り作成.

version: '3'

services:
  apache:
    container_name: apache
    build:
      context: .
      dockerfile: ./docker/apache/Dockerfile
    ports:
      - 80:80
    environment:
      COMPOSER_ALLOW_SUPERUSER: 1
    volumes:
      - ./webapp:/var/www/html
      - ./docker/apache/php.ini:/usr/local/etc/php/php.ini
    depends_on:
      - db
    networks:
      - net1
  db:
    container_name: db
    build:
      context: .
      dockerfile: ./docker/db/Dockerfile
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_USER: docker
      MYSQL_PASSWORD: docker
      TZ: 'Asia/Tokyo'
    networks:
      - net1
    ports:
      - 3306:3306
networks:
  net1:
  • services 以下に apache コンテナと db コンテナを作成
  • services.apache
    • Apache のコンテナ
    • image の指定やコンテナ独自の設定は Dockerfile で行う
    • container_name
      • コンテナの名前
      • 各コンテナはこの名前で IP の名前解決がされるためコンテナ名での通信が互いに可能となる
        • 例えば apache コンテナ内で $ ping dbdb コンテナに対して ping を投げたりもできる
    • build
      • ビルド時の設定
      • context
        • ビルドする際のビルドコンテキストをプロジェクトトップとする
        • コンテナがビルドされる際のカレントディレクトリを決める
        • Dockerfile にパスを記述する際の基準となるパス
          • この場合, 相対パスを記述する際はプロジェクトトップがカレントとなる
    • dockerfile
      • ビルド時の Dockerfile ファイルを指定
    • ports
      • ホストの TCP 80 番 ポートへコンテナの TCP 80 番ポートをフォワード
    • environment
      • コンテナに環境変数 COMPOSER_ALLOW_SUPERUSER を 1 で設定
        • root ユーザへの Composer インストールを許可するとのこと
        • Do not run xxx のような警告が出るらしくそれを抑えるため設定
    • volumes
      • Laravel プロジェクト(webapp/)を apache コンテナの /var/www/html にマウント
      • ローカルの php.ini をコンテナにマウントする
        • - ./docker/apache/php.ini:/usr/local/etc/php/php.ini
    • depends_on
      • Laravel から DB にアクセスするため db コンテナの起動後に apache コンテナを起動する
      • なくても大丈夫な気もする・・・
    • net1
      • apache コンテナと db コンテナは互いにやりとりする必要があるため同一ネットワーク net1 に属させる
  • services.db
    • DB のコンテナ
    • environment
      • MYSQL_ROOT_PASSWORD
        • root ユーザーのパスワード
    • port
      • ホストの TCP 3306 番 ポートへコンテナの TCP 3306 番ポートをフォワード
      • Sequel などの MySQL 用の GUI ツールを使用する際, ホストから 3306 ポートでコンテナの MySQL を操作できる
    • 他の設定値については services.apache の内容を参照
  • networks.net1
    • apache コンテナと db コンテナで通信するためのネットワークを定義

apache コンテナの Dockerfile の作成

プロジェクトトップ/docker/apache/ に以下の apache コンテナ用の Dockerfile を作成する.

FROM php:8.1-apache-bullseye

# apt install iputils-ping net-tools で ping を導入
RUN apt-get update \
 && apt-get install -y zlib1g-dev libzip-dev unzip vim iputils-ping net-tools sudo\
 && docker-php-ext-install zip

# node と npm をインストール
RUN apt-get install -y gnupg
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -\
 && apt-get install -y nodejs\
 && npm install npm@8.12.1 --global


# a2emod rewrite をして apache に rewrite モジュールを追加
# これをしないと Laravel でルート以外にアクセスできない
RUN a2enmod rewrite

# docker php には mysql 用のドライバが未インストールのため追加する
RUN docker-php-ext-install pdo_mysql

COPY --from=composer:2.4.1 /usr/bin/composer /usr/bin/composer

ADD docker/apache/php.ini /usr/local/etc/php/

# Apache の conf は seites-available に作成し
# a2ensite コマンドでシンボリックリンクを sites-enabled に作成する
ADD docker/apache/config/000-default.conf /etc/apache2/sites-available/
RUN a2ensite 000-default

WORKDIR /var/www/html

COPY ./webapp /var/www/html

RUN chown www-data storage/ -R \
 && composer install\
 && npm install
  • FROM
    • 元となる Docker イメージの指定
    • ホスト環境と同じ PHP 8.1 系をセレクト
    • Docker Hub の php イメージ, 8.1-apache-bullseye タグを指定
    • 8.1-apache-bullseyeDebian Apachemod_php が含まれたタグ
  • RUN apt-get update ....
    • コンテナ起動時にパッケージ情報のアップデートと必要なパッケージをインストール
    • iputils-pingnet-tools
      • ping を利用するためインストール
      • コンテナ間の疎通確認とかで使用したいがデフォルトではインストールされていないため追加
    • docker-php-ext-install zip
  • node と npm のインストールの詳細についてはnode/npmのインストール詳細を参照
  • RUN a2enmod rewrite
    • Apacherewrite モジュールを追加する
    • Laravel でのルーティングにはこのモジュールの有効化が必要
  • RUN docker-php-ext-install pdo_mysql
    • Docker の PHP コンテナには MySQL 用のドライバがインストールされていないため追加する
  • COPY --from=composer:2.4.1 /usr/bin/composer /usr/bin/composer
    • composer:2.4.1 イメージをビルドし作成した composer の実行形式をコンテナの /usr/bin/composer にコピーしているぽい
    • Composer のバージョンはホスト環境と合わせる
    • COPY --from=name src dest
      • FROM <image> as <name> として名前をつけて構築したステージをコピー元として指定できる
      • composer:2.4.1 を指定しているので名前つけをしたステージ以外にも image を直接指定もできるぽい?
      • 詳細は Docker Hub の Composer イメージのページや Dockerfile リファレンスの COPY を参照
  • ADD docker/apache/php.ini /usr/local/etc/php/
    • PHP の設定ファイル(php.ini)をコンテナの然るべき場所に置く
  • ADD docker/apache/config/000-default.conf /etc/apache2/sites-available/
  • RUN a2ensite 000-default
    • docker/apache/config にある Apache のコンフィグファイル(000-default.conf)を sites-available に置く
    • 大元のコンフィグファイルである apache2.conf では sites-enabled/ のみ IncludeOptional ディレクティブにより有効化される. sites-available/ は有効化されない
    • sites-available に置いたコンフィグファイルのシンボリックリンクsites-enabled に置くことにより sites-available/ 内の任意のコンフィグを有効にする
    • シンボリックリンクの作成は直接作成して sites-enabled においても構わないが, a2ensite コマンドで作成可能
    • https://nanbu.marune205.net/2021/12/debian-apache2-dir.html
  • WORKDIR /var/www/html
  • COPY ./webapp /var/www/html
    • Web アプリケーション(Laravelプロジェクト)をコンテナの /var/www/html にコピーする
  • RUN chown www-data storage/ -R \
    • www-dataApache がデフォルトで通常操作に使用するユーザー
    • www-data がアクセスできるファイルに Apache もアクセスできる
    • Laravel プロジェクトの storage フォルダ以下の所有者を www-data に変更する
  • composer install
    • composer.lock の内容でパッケージをインストール
node/npmのインストール詳細

Laravel でアプリケーションを作成する場合, 使用するパッケージによっては Node および npm を使用するため Docker 環境内に導入しておく. 導入に際してDockerでphpコンテナとかにnpmをインストールするときのメモを参考にさせていただきました.

Docker コンテナ上に構築される Debian のパッケージマネージャでは Node のバージョンが古かったり, npm が同梱されていなかったり等あるらしいので NodeSource Node.js Binary Distributionsからインストールする.

リンク先の記載内容を元に任意の Node をインストールする.
今回は Node 本体としては Debian 向けのバージョンは 18 を選択(curl -fsSL https://deb.nodesource.com/setup_18.x | bash -).

Node インストール後, 任意のバージョンの npm を取得するため npm でバージョン 8.12.1 の npm をインストール(npm install npm@8.12.1 --global).

# node と npm をインストール
RUN apt-get install -y gnupg
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -\
 && apt-get install -y nodejs\
 && npm install npm@8.12.1 --global

Apache のコンフィグファイルの作成

Apache サーバのコンフィグファイル プロジェクトトップ/docker/apache/config/000-default.conf を以下の通り作成する.

<VirtualHost *:80>
        ServerAdmin webmaster@localhost
        DocumentRoot /var/www/html/public
        ErrorLog ${APACHE_LOG_DIR}/error.log
        CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
  • VirtualHost
  • ServerAdmin
    • サーバがクライアントに送るエラーメッセージに含めるアドレス
    • プライベートに使用するウェブサイトのため適当に設定
  • ErrorLog
    • エラーログファイルの指定
    • APACHE_LOG_DIR環境変数はコンテナの /etc/apache2/envvars に定義されている
  • CustomLog
    • クライアントのアクセスログを記録するファイルとフォーマットを指定する
    • 第2引数の combinedLogFormat ディレクティブで名前つけされたフォーマット
      • combinedapache2.conf に以下の通り定義されている
      • LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined
    • 本ディレクティブは mod_log_config モジュールの機能

php.ini の作成

PHP の設定ファイル プロジェクトトップ/docker/apache/php.ini を以下の通り作成. とりあえず, タイムゾーンと言語に関する設定だけ.

[Date]
date.timezone = "Asia/Tokyo"

[mbstring]
mbstring.language = "Japanese"

db コンテナの Dockerfile の作成

プロジェクトトップ/docker/db/ に以下の db コンテナ用の Dockerfile を作成する.

FROM mysql:8.0.30

# docker-entrypoint-initdb.d にある SQL ファイルがコンテナ起動時に実行される
COPY ./docker/db/initdb.d /docker-entrypoint-initdb.d
  • FROM mysql:8.0.30
    • MySQL イメージの 8.0.30 タグを使用する
  • COPY ./docker/db/initdb.d /docker-entrypoint-initdb.d
    • docker/db/initdb.d フォルダをコンテナの /docker-entrypoint-initdb.d にコピーする
    • /docker-entrypoint-initdb.d フォルダにある SQL ファイルがコンテナ起動時に実行される

プロジェクトトップ/docker/db/initdb.d/ に以下の db コンテナ起動時に実行される master.sql を格納する.

キャラクタ設定をして master スキーマを作成している.

SET CHARACTER_SET_CLIENT = utf8;
SET CHARACTER_SET_CONNECTION = utf8;

CREATE DATABASE `master`;

以上で ApacheMySQL のコンテナ作成のための準備は完了.

Laravel プロジェクトの作成

それでは Laravel プロジェクトを作成する.

今回は Docker 環境内で Laravel を動かすため, Docker 側から Laravel プロジェクトをインストールする.

プロジェクト直下で docker-compose up -d --build を実行しコンテナを立ち上げる. その際 Dockerfile 内の RUN chown www-data storage/ -R によってまだ作成していない Laravel プロジェクト内の storage ディレクトリにアクセスしようとしエラーとなるので一旦 docker/apache/Dockerfile の以下部分の記述はコメントアウトしておく.

#RUN chown www-data storage/ -R \
# && composer install

Docker コンテナが問題なく立ち上がったら docker-compose exec apache bashapache コンテナ内に入る.

/var/www/ に移動し以下コマンドを実行し html ディレクトリに Laravel プロジェクトを作成する.

$ composer create-project laravel/laravel html

問題なく作成されたら exit しコンテナから出る.

その後, コメントアウトした Dockerfile の記述を元に戻し, 一旦 docker-compose をダウン(docker-compose down --rmi all --volumes --remove-orphans), 再度アップ(docker-compose up -d --build) し再構築する.

この状態でブラウザから http://localhost:80 にアクセスし, Laravel のトップページが表示されれば問題なし.

Laravel から DB にアクセスする準備

とりあえず, db コンテナと連携できるかの確認のため捨てテーブルを作る. テーブル生成用のマイグレーションファイルを作成する. 以下のコマンドを実行.

$ php artisan make:migration create_hoges_table

database/migrations/yyyy_mm_dd_xxxx_create_hoges_table.php が生成される.

確認用なのでとりあえずデフォルトのままでOK.

Eloqent モデルを作成する. 以下のコマンドを実行.

$ php artisan make:model Hoge

webapp/app/Models/Hoge.php が作成される.

次にコントローラを以下のコマンドで作成.

$ php artisan make:controller HogeDir/HogeController --invokable

webapp/app/Http/Controllers/HogeDir/HogeController.php が作成される.

とりあえず動作確認のため, hoges テーブルからレコードを取得し先頭要素の id をビューに渡すだけの処理を実装.

<?php

namespace App\Http\Controllers\HogeDir;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Hoge;

class HogeController extends Controller
{
    /**
     * Handle the incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function __invoke(Request $request)
    {
        $ls = Hoge::all();
        return view('hoge.index', ['hoge' => $ls[0]->id]);
    }
}

webapp/resources/views/hoge/index.blade.php を作成する. これも確認のためだけなので $hoge を表示する簡易な表示のみ.

<!doctype html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0,
          maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>アプリタイトル</title>
</head>

<body>
    <h1>アプリボディ</h1>
    <p>{{ $hoge }}</p>
</body>

</html>

.env の変更

.env は DB に関する設定だけ以下の通り変更する. DB_HOST にはデータベースサーバの IP を設定するが, Docker のネットワーク内であれば コンテナ名で名前解決されるため, db で OK. ただし, ホストからはコンテナ名では IP の名前解決はできないため ホストで動かした Laravel からは DB にはアクセスできない. docker-compose.yaml で別途コンテナに IP アドレスを 付与してやり, そちらを DB_HOST に設定すればホストで動かした Laravel からでも DB にアクセスできる気がするが, 基本的に apache コンテナで動かすつもりなので今の所やらない.

...

DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=master
DB_USERNAME=root
DB_PASSWORD=secret

...

Web サーバ(Apache)の確認

ここまでで準備が整ったのでそれぞれのコンテナの動作確認をする.

まずはコンテナを生成するためプロジェクトトップで以下のコマンドを実行. -d でデーモン起動(detachの略らしいけど).

$ docker-compose up -d

ブラウザで http://localhost:80/ にアクセスし Laravel のページが表示されれば問題なく apache コンテナが起動していることを確認できる.

DB(MySQL)の確認

Laravel から DB にアクセスできるか確認する.

そのまえに, DB アクセスのための下準備が完了していないためそちらを終わらせる.

まず, master スキーマが生成されていることを確認する. db コンテナに入る.

$ docker-compose exec db bash

以下のコマンドを実行. docker-compose.yaml に設定している MySQL のパスワードを入力する.

# mysql -u root -p
# パスワードを入力

show databases;master スキーマが生成されていることを確認する.

+--------------------+
| Database           |
+--------------------+
| information_schema |
| master             |
| mysql              |
| ......             |
| ......             |

exit;mysql を終了, さらに exit しコンテナからも出る.

次に apache コンテナに入る.

$ docker-compose exec apache bash

まずは apache コンテナで MySQL 用のドライバがインストールされているか確認する. 以下のコマンドを実行し pdo_mysql が確認できれば OK.

$ php -m | grep mysql
mysqlnd
pdo_mysql

Laravel プロジェクトをマウントしたディレクトリ(/var/www/html)に移動する(おそらくコンテナに入った時点でそのディレクトリのはず).

$ pwd
/var/www/html

マイグレーションを実行し hoges テーブルを作成する.

$ php artisan migrate

exitapache コンテナを出る. 再度, db コンテナに入る.

$ docker-compose exec db bash

hoges テーブルが作成されていることを確認. 表示用のダミーデータを適当に追加する.

$ mysql -u root -p
パスワード入力

mysql> use master;

mysql> show tables;
+--------------------+
| Tables_in_master   |
+--------------------+
| ......             |
| hoges              |
| ......             |
| ......             |
| ......             |


mysql> insert into hoges (id) values (1);

mysql> select * from hoges;
+----+------------+------------+
| id | created_at | updated_at |
+----+------------+------------+
| 1  | NULL       | NULL       |
+----+------------+------------+

db コンテナから抜ける.

ブラウザから http://localhost:80/hoge にアクセスしテーブルに追加したレコードの id が表示されていることを確認できれば 問題なく apache コンテナ上の Laravel から db コンテナの MySQL にアクセスできている.

以上で Docker + Apache + Laravel での最低限の環境が完成.

その他

  • Docker コンテナの停止と削除は以下のコマンドで実施
    • $ docker-compose down --rmi all --volumes --remove-orphans
      • --rmi all
        • 使用したイメージも全て削除する
      • --volumes
        • volume を全削除
      • --remove-orphans
        • Compose ファイルで定義されていないコンテナも削除する
  • apache コンテナ内での php artisan migrate はホストから docker exec コマンドでも OK
    • $ docker exec apache php artisan migrate
  • データベースへのアクセスをコマンドでやるとめんどくさいので GUI アプリを使用すると吉
    • 自分は Sequel Ace を使用
    • localhost の ポートフォワードしているポートから使用できる

出典

作成にあたり以下の記事を参考にさせていただきました.

Laravel : 画像のアップロードと表示

Laravel : 画像のアップロードと表示

環境

  • macOS Big Sur
    • 11.6.5
  • Docker
    • 20.10.12
  • Laravel
    • 9.21.5
  • PHP
    • 8.1.8
  • Composer
    • 2.3.10
  • MySQL
    • 8.0.29

参考

画像のアップロード

画像をアップロードし, DB に保存する.
画像は別のストレージに保存し, DB には画像のファイル名のみを保存する.

画像用テーブルを作成する

$ sail artisan make:migration createImagesTable

スキーマ設定に画像のファイルパスを文字列で保存する name を追加する.

20XX_XX_XX_XXXXX_create_images_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.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('images', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->timestamps();
        });
    }

    ...省略

};

マイグレーションを実行する

マイグレーションを実行し DB にテーブルを作成する.

$ sail artisan migrate

画像用の Eloquent モデルを作成する

画像用の Eloquent モデルを生成. 中身はデフォルトのままで OK.

$sail artisan make:model Image

シンボリックリンクを作成する

画像は storage ディレクトリに保存される. storage ディレクトリは外部からアクセスできないため storage/app/publicpublic から参照できるようシンボリックリンクを作成する.

$ sail artisan storage:link

画像用のコントローラを作成

GET のコントローラ

$ sail artisan make:controller --invokable Image/IndexController

アップロード用 POST のコントローラ

$ sail artisan make:controller --invokable Image/UploadController

ルーティングの追加

<?php

...省略

Route::get('/', function () {
    return view('welcome');
});


Route::get('/image', \App\Http\Controllers\Image\IndexController::class)->name('image.index');
Route::post('/image/upload', \App\Http\Controllers\Image\UploadController::class)->name('image.upload');

...省略

GET コントローラの編集

とりあえず Blade の表示だけ.

app/Http/Controllers/Image/IndexController.php

<?php

namespace App\Http\Controllers\Image;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class IndexController extends Controller
{
    /**
     * Handle the incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function __invoke(Request $request)
    {

        return view('image.index');
    }
}

Blade の編集

フォームを作成する.
画像アップロードには enctype="multipart/form-date" が必要.

resources/views/image/index.blade.php

<x-layout title="TOP | 画像サンプル">
    <x-layout.single>
        <h2 class="text-center text-blue-500 text-4xl font-bold mt-8 mb-8">
            画像サンプル
        </h2>
    </x-layout.single>
    <div>
        <form action="{{ route('image.upload') }}" method="post" enctype="multipart/form-data">
            @csrf
            <input type="file" name="image">
            <input type="submit" value="画像アップ">
        </form>
    </div>
</x-layout>

リクエストの作成(バリデーション・リクエスト取得)

バリデーションとリクエストの取得のためリクエストを作成する.

$sail artisan make:request Image/CreateRequest.php

CreateRequest.php が生成されるので編集する.
authorize はログインを要求するか否かの設定. システムに合わせて適宜編集.
rules にバリデーションルールを追加. nameimage の要素に対して 「必須」「画像」「拡張子」「サイズ」のルールを追加.

加えて画像取得用のメソッド image を追加. ファイルの取得は $this->file() で行う.

app/Http/Requests/Image/CreateRequest.php

<?php

namespace App\Http\Requests\Image;

use Illuminate\Foundation\Http\FormRequest;

class CreateRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules()
    {
        return [
            //
            'image' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048'
        ];
    }

    public function image()
    {
        return $this->file('image');
    }
}

画像保存処理作成

app/Services/ に任意のサービスクラスを作成する.
今回は ImageService.php で作成.

ストレージへの保存と Model を使用した DB へのファイル名保存処理を実装する.
DB に保存するファイル名はハッシュ.

app/Services/ImageService.php

<?php

namespace App\Services;

use App\Models\Image;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;

class ImageService
{

    public function saveImage($image)
    {
        Storage::putFile('public/images', $image);
        $imageModel = new Image();
        $imageModel->name = $image->hashName();
        $imageModel->save();
    }
}

POST コントローラの編集

POST 用のコントローラに画像のリクエスト取得処理と保存処理を実装.

app/Http/Controllers/Image/UploadController.php

<?php

namespace App\Http\Controllers\Image;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\ImageService;
use App\Http\Requests\Image\CreateRequest;

class UploadController extends Controller
{
    /**
     * Handle the incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function __invoke(CreateRequest $request, ImageService $imageService)
    {
        //
        $image = $request->image();
        $imageService->saveImage($image);
        return redirect()->route('image.index');
    }
}

以上でストレージと DB にアップロードした画像が保存される.

画像の表示

アップした画像を表示する.

DB からファイル名取得

ファイル名を取得する処理を追加(Image::all()).

app/Services/ImageService.php

<?php

namespace App\Services;

use App\Models\Image;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;

class ImageService
{

    public function saveImage($image)
    {
        Storage::putFile('public/images', $image);
        $imageModel = new Image();
        $imageModel->name = $image->hashName();
        $imageModel->save();
    }

    public function getImages()
    {
        return Image::all();
    }
}

GET 用のコントローラに画像取得処理追加

GET 用のコントローラに DB から画像のファイル名を取得する処理を追加.
取得した画像のファイル名を Blade に渡す.

app/Http/Controllers/Image/ImageController.php

<?php

namespace App\Http\Controllers\Image;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\Imageservice;

class IndexController extends Controller
{
    /**
     * Handle the incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function __invoke(Request $request, Imageservice $imageService)
    {
        //
        $images = $imageService->getImages();

        return view('image.index')->with('images', $images);
    }
}

Blade に画像表示処理追加

resources/views/image/index.blade.php

<style>
    .image {
        width: 120px;
        height: 120px;
    }
</style>

<x-layout title="TOP | 画像サンプル">
    <x-layout.single>
        <h2 class="text-center text-blue-500 text-4xl font-bold mt-8 mb-8">
            画像サンプル
        </h2>
    </x-layout.single>
    <div>
        <form action="{{ route('image.upload') }}" method="post" enctype="multipart/form-data">
            @csrf
            <input type="file" name="image">
            <input type="submit" value="画像アップ">
        </form>
    </div>
    <div>
        <ul>
            @foreach ($images as $image)
                <li>
                    <div class="image">
                        <img src="{{ asset('storage/images/' . $image->name) }}">
                    </div>
                </li>
            @endforeach
        </ul>
    </div>
</x-layout>

以上で http://localhost/image にアクセスするとアップロードした画像が表示される.

画像の削除

画像の消去は以下.

use App\Models\Image;
use Illuminate\Support\Facades\Storage;


...省略

// 対象画像のモデルを取得
$image = Image::where('id', $imageId)->firstOrFail();

$filePath = 'public/images' . $image->name;
// ファイルをストレージから削除
if (Storage::exists($filePath)) {
    Storage::delete($filePath);
}

// DB から対象の画像を削除
$image->delete();