Open22

laravel周り全般tips

okita kamegorookita kamegoro

enumについて

laravelとMySQLでENUMを扱う際に、laravel-enumを使う.

laravel-enumではphp artisan make:UserTypeとすることで、ENUMの定義ファイルを作成できる。

<?php

namespace App\Enums;

use BenSampo\Enum\Enum;

final class UserType extends Enum
{
    const Administrator = 0;
    const Moderator = 1;
    const Subscriber = 2;
    const SuperAdministrator = 3;
}

ここで問題になるのが、const Administrator = 0

MySQLではsql_modeがStrictの時にENUM型で整数値0を許容しないというのがあるらしい。

確認するにはmysqlコマンドでログインしてSELECT @@sql_mode;と打つと確認できる。

mysql> SELECT @@sql_mode;
+-----------------------------------------------------------------------------------------------------------------------+
| @@sql_mode                                                                                                            |
+-----------------------------------------------------------------------------------------------------------------------+
| ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION |
+-----------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

ここでSTRICT_TRANS_TABLESとあると、厳格モード。

この時に整数値0が許容されない。
したがって、ENUMを設定する場合は、以下のように 1からスタートするようにするべきなのではないのかな。

<?php

namespace App\Enums;

use BenSampo\Enum\Enum;

final class UserType extends Enum
{
    const Administrator = 1;
    const Moderator = 2;
    const Subscriber = 3;
    const SuperAdministrator = 4;
}
okita kamegorookita kamegoro

slug などを用いたルートモデルバインディングについて

基本的にルートは以下のようにして行う。

web.php
<?php

Route::get('/post/{id}', [PostController::class, 'show'])->name('post.show');

ここで、モデルバインディングといった機能があり、idに紐付けてモデルに関連するデータをController側で受け取るがことができる。

さらにデフォルトではこのバインディングする際のキーが idとなっているが、slugなどに変更することが可能である。

web.php
<?php

Route::get('/post/{post:slug}', [PostController::class, 'show'])->name('post.show');

これの利点は、(....以下説明ちゃんと書く)

そして問題になるのが、resoueceに対してid以外のキーを指定する場合である。
この場合は以下にすると大丈夫

web.php
<?php
Route::resource('post', PostController::class)->parameters([
    'post' => 'post:slug'
]);

以上。

okita kamegorookita kamegoro

Laravel と Vueの組み合わせ(部分的導入)で注意すべき点

例えば以下のようにbladeで記述して、app.jsでvueをマウントするとする

index.blade.php
<div id="app">
    <h1>タイトル</h1>
    <vue-component :text="stringAttr" boolProps="false" />
  
   <section>
        <!-- 省略 -->
   </section>
</div>
app.js
import { createApp } from 'vue'
import App from './App'
if (document.getElementById('app')) {
    createApp({
        components: {
            app: App,
        },
    }).mount('#app')
}

vueの記法的には Component を <vue-component /> と書いても良いのでそうすると、
その下に続く section が表示されなくなる。

この場合、<vue-component></vue-component> と閉じることで表示されるようになる

okita kamegorookita kamegoro

Laraveでどうdebugするか

Laravel、特にモノリスで構築するLaravelではフロント、バックエンド両方の開発をすることがあり、バックエンドについてもMVCだけでなくcommandやtest, queueなど様々なシーンの開発を行う。

このため、開発のシーンによってデバッグしやすい方法も変わり、これを把握しているかどうかは開発スピードに大きな影響を及ぼすだろう。

デバッグの種類を列挙し、どの開発シーンで生きるかについて説明できればと思う。

echo

print

var_dump

フロント

dd

フロント

eval(\Psy\sh());

全般的に有効
しかし最近のlaravelではphp artisan serveはこのブレイクポイントを自動的に閉じてしまうため
リクエストに応じた処理をするならこれがいい

log

全般、特にqueue(job, redis)などは、ターミナルでも見えにくいのでここを参照するといい
tail -f でログを垂れ流してみてるのも便利。Macユーザーならコンソールアプリケーションで確認することもできる

tinker

プレイグラウンド的に実行するのにちょうどいい。
tinker内部でも様々なコマンドが用意されており、奥深い

laravel-debugbar

お馴染み。何かと便利。特にセッション周りやDBのクエリをさくっと確認したい場合はこれを使う。
リダイレクトに弱い。
deubgbar の config をいじるだけでさらに便利になるのでちゃんとカスタマイズしよう。
この辺の解説記事が全然ない気がする
こんな感じで、ログインしているユーザー情報とかロケーションの設定、conifg, laravelのバージョンなども出力できるって知ってた?知らんかったでしょ

laravel clockwork

APIのデバッグをする時に有効。軽く触ったが、結構良い。

xdebug

PHP使いなら一度は聞いたことがあるとおもう。あまり使わない

https://qiita.com/ucan-lab/items/29614d0f3ded1d3a94fb

okita kamegorookita kamegoro

Query builderの戻り値に関して

save, update などの戻り値についてまとまっている

メソッド 返り値(型) 返り値(例) コメント
save boolean true, false 成功の結果
create object {id:1,name: テスト} 新規作成したレコード
insert boolean true, false 成功の結果
update integer 0, 1, 10など 影響を与えたレコード数
delete integer 0, 1, 10など 影響を与えたレコード数
first object {id:1,name:テスト} レコード1件 / 無ければ 5.1→NULL, 5.3→{}

https://qiita.com/HorikawaTokiya/items/679b5d3b1cfe1c3b2f71

okita kamegorookita kamegoro

BladeからVueにbooleanをpropsする際のTips

bladeに部分的にVueを採用しているパターンにおいて、booleanをpropsに渡そうとすると以下のようになる。

index.blade.php
<div class="wrapper">
    <boolean-props-form 
        :is-truthy="{{ $isTruthry }}"
    ></boolean-props-form>
</div>

[Vue warn]: Template compilation error: v-bind is missing expression.

laravelからgetで吐き出されるhtmlを見ると、:is-truthy=""となり空になってしまう

https://stackoverflow.com/questions/63109076/how-to-pass-boolean-values-from-blade-to-vue-component-laravel-7

index.blade.php
 <php?
 <div class="wrapper">
    <boolean-props-form 
-        :is-truthy="{{ $isTruthry }}"
+        :is-truthy="{{ json_encode($isTruthry) }}"
    ></boolean-props-form>
 </div>
okita kamegorookita kamegoro

ネストした連想配列を一括でCollectionに変換する方法
こんな感じでネストされた配列があったとする。

$array = [
     "token" => "b9938784-85bf-4dc6-af1b-75336d204e28",
     "selling_plan_group" => [
       "token" => "3c65fb70-b64c-4903-ab87-accf9c277205",
       "selling_plans" => [
         [
           "token" => "70ee18ee-a7c5-4a5e-b1a8-0d3e3acb5885",
         ],
         [
           "token" => "1a3e293c-34bf-4d55-922a-e698c8313205",
         ],
     ]
]

これをCollectionに変換したい場合、

$collection = collect($array);

上辺ではcollectionに変換されたように見える。
しかし、ネストした値を取り出そうとすると、ネストされた値はすべて配列として取得される。
メソッドチェーンで値を取得したい場合は、これは少し厄介。

従って、下記のように一旦 jsonに変換したのちに取得するといい。

$courseCollection = collect(json_decode(json_encode($course['course_variants'])));
okita kamegorookita kamegoro

ide-helper, laravel-ide-helper について

morphTo リレーションの phpdoc の書き方について

morphTo リレーションを行なっているクラスで ide-helper を走らせると以下のような問題にぶち当たる
https://github.com/barryvdh/laravel-ide-helper/issues/502

静的には morph が返すモデルが確定していないため、その上位の Model が割り当てられたんだと思う(要出典)。
通常、mono, poly なリレーションに関わらず、リレーションは返却されるモデルは固定されているはず。

なので、この場合は愚直に出力された phpdoc をユニオンで記述して上書きしても良いと思っている。

**** 中略
// * @property-read \App\Models\Post|\App\Modesl\Video $commentable
**** 中略
public function commentable()
{
    return $this->morphTo();
}

追記:よく読んだら、この人と同じやり方かも
https://github.com/barryvdh/laravel-ide-helper/issues/502#issuecomment-450384184

okita kamegorookita kamegoro

認証周りの話の理解

ログイン後のリダイレクト方法について

ログインしたら通常、ログインページへリダイレクトされる前のページに戻りたいのが世の常いうものである。
ところが、調べてみると redirectTo などをいじることでなんとかする記事が多い。
違う。そうじゃない。正しくはこうだ。

LoginController.php
public function login(Request $request): Response|RedirectResponse
    {
        // ログイン処理....中略
        return redirect()->intended($this->redirectTo); // intended メソッドを活用する
    }

また、会員登録フロー(register -> メール認証)などを行なった際に、最後に 「元のページに戻る」などをbladeで記述したい場合はこうなる。

<a href="{{ session()->pull('url.intended', '/') }}">元のページに戻る</a>

やっていることは、 redirect()->intended($this->redirectTo)intendedメソッドと同様である。
(メソッドのコードを読めば、 session()->pull()が呼び出されているのがわかる。)

より動的に元いたページに戻るには

redirect()->intended() で元いたページに戻るように変更を加えた。
このメソッドの呼び出して、sessionに保存されている url.intendedを読み取り、存在すればそのページに飛ばしている。
そもそも、url.intendedはいつセットされるのか?これは以下の記事を読むとわかりやすい。
url-intendedはいつどこでセットされたのか?

url.intendedは基本的に authのミドルウェアが設定されているルーティングで、ログインしていない場合に設定(session set)される。

例えば別のページからnavバー経由でログインページに訪れた場合、url.intendedはセットされない。
この場合、url.intendedが設定されていないため、ログイン後にLoginControllerredirectToに設定されたページに飛ばされてしまう。

これを回避するために、ログインページを表示するメソッドで以下の処理を追加すれば良い

LoginController.php
    public function showLoginForm(): View
    {
        if (url()->previous()) {
            $this->redirector->setIntendedUrl(url()->previous());
        }

        return view('chefrepi.auth.login');
    }

これでどのページからログインページにアクセスしても元いたページに戻すことが可能になる

LoginController の利用時の注意点

LoginControllerの AuthenticatesUsers に事前にログイン処理で必要な処理が事前に定義されている。
これらを使い回すのは非常に便利であるが、注意しないといけない部分もある。
例えば、複数タイプのアカウント (User, Admin など) を管理する場合、ロジック内で guard を一意に指定したいが AuthenticatesUsers では一意に指定できていないということだ。
この中で定義されている guard メソッドで Auth::guard() を返しており guard の指定を行なっていない。
このため各種guardを指定したい場合は、LoginController上でtraitを上書きするように実装する。

    /**
     * Get the guard to be used during authentication.
     *
     * @return \Illuminate\Contracts\Auth\StatefulGuard
     */
    protected function guard()
    {
        return Auth::guard('admin');
    }
okita kamegorookita kamegoro

既にある配列をもとに、配列の並び替えを行う方法

こういうユーザー情報が入った配列があったとする

$users = [
    [
        'age' => 10,
        'name' => '太郎',
    ],
    [
        'age' => 25,
        'name' => '五郎',
    ],
    [
        'age' => 30,
        'name' => '三郎',
    ],
];

これを単純な年齢のソートや文字コードによるソートなどを行わず、
事前に用意された「五郎、太郎、三郎」の順番を並び替えたい時、usortarray_searchを使う。

$users = [
    [
        'age' => 10,
        'name' => '太郎',
    ],
    [
        'age' => 25,
        'name' => '五郎',
    ],
    [
        'age' => 30,
        'name' => '三郎',
    ],
];

$sort = ['五郎', '太郎', '三郎'];

usort($users, function ($a, $b) use ($sort) {
    return array_search($a['name'], $sort) - array_search($b['name'], $sort);
});

// => [
//      [
//        "age" => 25,
//        "name" => "五郎",
//      ],
//      [
//        "age" => 10,
//        "name" => "太郎",
//      ],
//      [
//        "age" => 30,
//        "name" => "三郎",
//      ],
//    ]
okita kamegorookita kamegoro

時間経過を伴うテストを行いたい時

有効期限付きの処理など、ある時刻以降での状態をチェックしたい場合がある。
Laravelで標準的に利用する日時処理系のパッケージ Carbon では、アプリケーションのロード時に時刻を改変する機能がある。

app/Providers/AppServiceProvider.php

<?php

namespace App\Providers;

use Carbon\Carbon;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    // 省略

    public function boot()
    {
        // 15日後に移動「したものとして」実行
        $target_dt = today()->addDays(15);
        Carbon::setTestNow($target_dt);
       // Laravel上のデフォルトをCarbonからCarbonImmutableに変更している場合は、
       // CarbonImmutable::setTestNow($target_dt);
    }
}
okita kamegorookita kamegoro

多対多リレーションの紐付けに関して

$post = Post::first();
// 投稿に対してユーザーのいいね(user id が1,2,3)
$post->userLikes()->sync([1,2,3]);

laravelの多対多リレーションは上記のように syncを使うと簡単に行える。
ただしこの syncメソッドは与えられた配列のみを多対多リレーションするので、DBには存在するが、引数に渡した配列に存在しないuser id は削除されてしまう。

$post = Post::first();
// 投稿に対してユーザーのいいね(user id が1,2,3)
$post->userLikes()->sync([1,2,3]);
$post->userLikes()->sync([4]);

上記では、user_idが4のみがDBに保存される形となる。

これを回避するために attachを使う方法がある

$post->userLikes()->attach(1);
$post->userLikes()->attach(2);
$post->userLikes()->attach(3);
$post->userLikes()->attach(4);

これで強制的に外されることはない。

ただし、ここで問題なのが以下のようなもの

$post->userLikes()->attach(4);
$post->userLikes()->attach(4); // SQL の重複エラーが発生

このようにattachでは重複があるとエラーになる。
このような状況の場合は、 syncの第二引数に falseを設定するか、 syncWithoutDetachingメソッドを用いる

ちなみにsyncWithoutDetachingsyncの第二引数にfalseを設定したショートハンドである

okita kamegorookita kamegoro

ポリシーの利用について

Laravelの認可については、ポリシーとゲートの2種類が存在する。
主に使い分ける基準は「対象が特定のモデルに対するアクションかどうか」だと考えて良いと思っていて、
基本的にモデルに対する話は、ポリシーで対処すると良いと考える。

ポリシーの認可では、各種メソッドでboolの戻り値を用いて、権限があるかどうかの判定を行うが、これのみだと認可ができない場合が複数パターンあるときに表現力に乏しい。

このためExceptionを活用する。

namespace App\Exceptions;

use Exception;

class NotPremiumMemberException extends Exception
{
    protected $message = 'プレミアム会員ではないため購入できません';
}

class ProductNotAvailableException extends Exception
{
    protected $message = 'この商品は現在利用できません';
}
public function view(User $user, Product $product)
{
    if (!$user->is_premium_member) {
        throw new NotPremiumMemberException;
    }

    if (!$product->is_available) {
        throw new ProductNotAvailableException;
    }

    return true;
}
public function show(Product $product)
{
    try {
        $this->authorize('view', $product);
    } catch (\App\Exceptions\NotPremiumMemberException $e) {
        return back()->with('error', $e->getMessage());
    } catch (\App\Exceptions\ProductNotAvailableException $e) {
        return back()->with('error', $e->getMessage());
    }

    // ...
}
okita kamegorookita kamegoro

認証関連

Multi Auth (認証を複数モデルに付与する実装)

Laravel では1つのプロジェクト内で認証(ログインやユーザーごとの制限権限付与)可能なモデルは1つにすることが推奨されているが、現実のアプリケーションにおいてはそれは結構難しく、ほとんどの場合で対応が必要になる。

Multi Authでログアウトすると、全アカウントからログアウトされる問題について

基本的に Laravel は Multi Auth をあまり推奨はしていない
ログアウト時にセッションを削除するため、ログアウト時には全アカウントからログアウトされてしまう。
これはログアウトの処理で invalidate() を無効化することで回避可能ではある。

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

セッション固定攻撃を防ぐために、必ずログインログアウト時にはセッションを更新するべき。

結論としては、Laravel自体が個別でログアウトする機能をサポートしていないので、現状はできないと考えて良い。
やり方があるとすれば、各ガードごとに異なるセッションを用いるようにすることなのだと思う

Multi Auth の際に複数の認証可能なモデルごとにエラーページ(404.blade.php)を用意する方法

結論

app/Exceptions/Handler.phpgetHttpExceptionView メソッドに表示するエラーページを指定する

詳しく

デフォルトの app/Exceptions/Handler.php の実装は以下の通りかと思う。
ここで、 Handler クラスは Illuminate\Foundation\Exceptions\Handler に依存しているので、ここを眺めているとエラーの処理を見ることができる。

app/Exceptions/Handler.php
<?php

namespace App\Exceptions;

use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;

class Handler extends ExceptionHandler
{
    /**
     * The list of the inputs that are never flashed to the session on validation exceptions.
     *
     * @var array<int, string>
     */
    protected $dontFlash = [
        'current_password',
        'password',
        'password_confirmation',
    ];

    /**
     * Register the exception handling callbacks for the application.
     */
    public function register(): void
    {
        $this->reportable(function (Throwable $e) {
            //
        });
    }
}

Illuminate\Foundation\Exceptions\Handler に対応するファイルは、 vendor 以下で次のように見ることができるが、その中でエラー時に表示するページを以下のメソッドで定義しているようである。

vendor/laravel/framework/src/Illuminate/Foundation/Exceptions/Handler.php

    /**
     * Render the given HttpException.
     *
     * @param  \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface  $e
     * @return \Symfony\Component\HttpFoundation\Response
     */
    protected function renderHttpException(HttpExceptionInterface $e)
    {
        $this->registerErrorViewPaths();

        if ($view = $this->getHttpExceptionView($e)) {
            return response()->view($view, [
                'errors' => new ViewErrorBag,
                'exception' => $e,
            ], $e->getStatusCode(), $e->getHeaders());
        }

        return $this->convertExceptionToResponse($e);
    }

したがって、ここを上書きするような実装を、app/Exceptions/Handler.php にて行えば良い。

app/Exceptions/Handler.php
<?php

namespace App\Exceptions;

use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;

class Handler extends ExceptionHandler
{
    /**
     * The list of the inputs that are never flashed to the session on validation exceptions.
     *
     * @var array<int, string>
     */
    protected $dontFlash = [
        'current_password',
        'password',
        'password_confirmation',
    ];

    /**
     * Register the exception handling callbacks for the application.
     */
    public function register(): void
    {
        $this->reportable(function (Throwable $e) {
            //
        });
    }

    /**
     * Override default method.
     * Get the view used to render HTTP exceptions.
     */
    protected function getHttpExceptionView(HttpExceptionInterface $e): ?string
    {
-        $view = 'errors::'.$e->getStatusCode();
+        // getHttpExceptionView を記述しつつ、
+        // エラーが発生したルーティングで表示するページを雑に切り分ける(PHP8.0.0以降はmatch 文で制御してもいいかも)
+        $view = request()->is('admin/*')
+            ? 'errors::admin.' . $e->getStatusCode()
+            : 'errors::' . $e->getStatusCode();

        if (view()->exists($view)) {
            return $view;
        }

        $view = substr($view, 0, -2) . 'xx';

        if (view()->exists($view)) {
            return $view;
        }

        return null;
    }
}

TODO
errors:: の記法について、解説
match 文を使った分岐について解説
リクエストのパスで分岐するのではなく、namespace で分岐する方法を解説

20230816 追記

request()->routeIs() を用いて、ルーティングのネームスペースで分岐を行いたい場合、404のようなエラーでは動作しない。request()->routeIs() の実装は、 https://github.com/laravel/framework/blob/9.x/src/Illuminate/Http/Request.php#L224 にある通りだが、Kernel.php でグローバルに登録されている Middleware レベルで発生するエラーについては、 request()->route() に関する解決が行われていないため、 request()->routeIs() で実行されている $this->route()null になる。これにより判定が常にfalse で返ってくる。
これに対応するために致し方なく、request()->server->get('***') などを使う手もある。
request()->server では symfonyのServerBagを読んでおり、これに紐づくデータが取得できる。

okita kamegorookita kamegoro

Auth::user() とかは、必ずクラスで管理するようにした方が良い。

こんな感じで管理してれば、型をつけることができる。
以前はRepositoryに記載していたが、Repositoryではセッションの操作は責務範囲外なので分離したいところ。

<?php

declare(strict_types=1);

namespace App\Services\Laravel\Auth;

use Illuminate\Auth\AuthenticationException;
use Illuminate\Contracts\Auth\Authenticatable;
use App\Models\Customer\Customer;

/**
 * @mixin \Illuminate\Auth\AuthManager
 * @mixin \Illuminate\Auth\SessionGuard
 */
final class AuthCustomer extends AbstractAuth
{
    public static function user(): ?Customer
    {
        return static::guard('customer')->user();
    }

    public static function userOrFail(): Customer
    {
        $user = static::guard('customer')->user();

        if (is_null($user)) {
            throw new AuthenticationException('user failed login');
        }

        return $user;
    }

    /**
     * @param array<mixed> $credentials
     * @param bool $remember
     *
     * @return bool
     */
    public static function attempt(array $credentials = [], $remember = false): bool
    {
        return static::guard('customer')->attempt($credentials, $remember);
    }

    /**
     * @param bool $remember
     *
     * @return void
     */
    public static function login(Authenticatable $user, $remember = false): void
    {
        static::guard('customer')->login($user, $remember);
    }
}

okita kamegorookita kamegoro

サブドメインルーティングについて

Laravelではサブドメインルーティンが行える。
これは以下のように定義すればOK

Route::domain('sub.domain.com')
            ->middleware('web')
            ->namespace($this->namespace)
            ->group(base_path('routes/web/app.php'));

サブドメインルーティングをする場合、
sub.domain.com を登録するが、このとき、元々存在するメインのドメイン domain.com についても Route::domain('domain.com')として登録しなければならない。

ドメインを設定していない場合、相対バスで解決されてしまい
domain.com/pages のパスが sub.domain.com/pages でもアクセスできてしまう問題が発生する。

okita kamegorookita kamegoro

vscode extension [laravel goto view] について

  • vite ディレクティブに対応してほしい
  • URL::temporarySignedRoute など、URLクラスでネームスペースで指定するものに対応してほしい
okita kamegorookita kamegoro

DBカラム定義について

Laravelではmigrationでテーブル定義を行う際にタイムスタンプを以下のようにすることが一般的

$table->timestamps()

php artisan make:migration でもデフォでこれが入っているが、なんとnullableで登録されるのである。
created_at, updated_atを登録するからにはnullableになるようなケースは考えにくいのだが、nullableで指定されるのは非常に謎。

nullableにするなら必要ないのでは。。。。
自分はこのようにしている。

$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();

stubで定義すると便利

<?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('{{ table }}', function (Blueprint $table) {
            $table->id();

            // $table->timestamps(); は nullable となるため利用しない。
            $table->timestamp('created_at')->useCurrent();
            $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('{{ table }}');
    }
};