【Laravel】FormファサードとflashInputの併用に注意!チェックボックスの初期値がうまく反映されない時の対処法

はじめまして!

バックエンドエンジニアのコバックです。

バックエンド
エンジニア

コバック

ロジカルスタジオ歴はそこそこ長いのですが、

実は表舞台に顔を出すのはこれが初めてだったりします。

バックエンド
エンジニア

コバック

今回は僕が実際に案件で出会った、一見不可解な現象についてお話したいと思います。

この記事で救われる可能性のある方

以下のすべてに該当する方は是非ご一読ください。

  • laravelcollectiveのFormファサードを使用して開発している
  • システム内で何かしらの値をflashInputしている
  • DBから取得した値が期待通りにチェックボックスに反映されない

環境情報

  • PHP 8.1
  • Laravel 9.1
  • laravelcollective/html 6.4

※他、本記事とは無関係の為割愛

発生した現象

特定の画面(以下、画面Aと呼称)からチェックボックスのある画面(以下、画面Bと呼称)へ遷移した時のみ、画面Bのチェックボックス初期値がすべてfalseになるという現象です。

NGパターン:画面Aから遷移した場合

OKパターン:他画面から遷移した場合

もちろんチェックボックスの値はデータベースに保存されており、取得処理も問題ありません。

画面A以外の画面から遷移した場合や、画面Bでリロードした場合はOKパターンの通りに初期値がセットされています。

原因

結論からお話すると、そもそも画面Aを表示する際にControllerで行っていた以下の処理が原因でした。

session()->flashInput($food->toArray());

本記事を「flashInputしたセッションを使用している方向け」としていたのは、これが理由です。

補足:そもそもなぜモデルから取得した値をflashInputしているのか

モデルから取得した値をflashInputしておくことで、blade側の表記を若干簡略化することができます。

ブログ記事_03

弊社マスコット?

ロージー

そもそもflashInputって何しゅこ?という方は
弊社メンバーが過去に執筆したこちらの記事
チェックしゅこ!

通常、データベースの値を初期値として表示する場合、
モデルから取得したどの値を表示するか、blade側で書いてあげないといけません。

{{ Form::input('text', 'name', $food->name, ['id' => 'name']) }}

しかし、モデルから取得した値をController側でflashInputしてあげることで
セッションにセットしたname属性(第2引数)と同名のkeyを持つvalue値を、自動でセットしてくれるようになります。

つまり、以下のように第3引数をnullに統一することが可能になります。

{{ Form::input('text', 'name', null, ['id' => 'name']) }}

name属性は指定する必要があるため完全な共通化は難しいですが、
このように統一できる箇所が増えれば、それだけ実装・修正のコストを抑えることができます。

flashInputが与えていた影響

では、なぜflashInputが今回の事象の原因となっていたのでしょうか?

それを知るためには、まずFormファサードで生成したチェックボックスが
どのようなロジックで初期値のチェックを行っているかを知る必要があります。

チェック状態を判定・返却しているメソッドがこちら。

// vendor/laravelcollective/html/src/FormBuilder.php

protected function getCheckboxCheckedState($name, $value, $checked)
{
    $request = $this->request($name);
    if (isset($this->session) && ! $this->oldInputIsEmpty() && is_null($this->old($name)) && !$request) {
        return false;
    }

    if ($this->missingOldAndModel($name) && is_null($request)) {
        return $checked;
    }

    $posted = $this->getValueAttribute($name, $checked);

    if (is_array($posted)) {
        return in_array($value, $posted);
    } elseif ($posted instanceof Collection) {
        return $posted->contains('id', $value);
    } else {
        return (bool) $posted;
    }
}

今回注目したいのはこの部分。

if (isset($this->session) && ! $this->oldInputIsEmpty() && is_null($this->old($name)) && !$request) {
    return false;
}

設定されている4つの条件をすべて満たした場合、問答無用でfalseが返却され、初期値は「チェックなし」となります。

今回はすべてのチェックボックスがこの判定にひっかかり、falseを返していました。

条件を詳しく見ていきましょう。

  • isset($this->session)
  • セッションがセットされていればtrue。

    少なくともtoken等あるはずなので、trueになります。

  • ! $this->oldInputIsEmpty()
  • ネタバレですが今回の犯人。後述します。

  • is_null($this->old($name))
  • セッションの_old_inputにname属性のkeyがなければtrue。

    実はこの判定も無関係ではないのですが、本筋から若干逸れるため割愛します。

    基本的にtrueになると思っていていただいて結構です。

  • !$request
  • name属性のkeyがリクエストに存在しなければtrue。

    $requestには$this->request($name)がセットされています。

    画面Bへの遷移時点では存在しないため、trueとなります。

上記の通り、画面A→画面Bへの遷移時には! $this->oldInputIsEmpty()以外の条件はtrueを返すんですね。

つまり! $this->oldInputIsEmpty()がtrueを返す場合、チェックボックスの初期値はfalseになります。

// vendor/laravelcollective/html/src/FormBuilder.php

public function oldInputIsEmpty()
{
    return (isset($this->session) && count((array) $this->session->getOldInput()) === 0);
}

こちらがoldInputIsEmpty()の処理になります。

前者の判定条件であるisset($this->session)は、前述した4つの判定条件の1つとまったく同じため、trueになります。

後者の判定条件の話をする前に、今一度本事象およびflashInputの仕様について振り返りましょう。

  • 今回問題となった現象は、画面A→画面Bへの遷移時に発生していた。
  • 画面Aの表示時には、viewに出力する情報をセットするため、
    モデルから取得した値をflashInputでセッションに保存していた。
  • flashInputでセットした値は、次のリクエスト時まで残り続ける。

つまり、画面Bの表示時には、画面AでflashInputしたセッションが残っているということです。

getOldInput()はflashInputの値を取得するため、この残ったセッションが取得対象に。

当然、count((array) $this->session->getOldInput()) === 0は不成立となりますね。

その結果、oldInputIsEmpty()自体もfalseを返すようになるため、! $this->oldInputIsEmpty()はtrueとなります。

改めて、チェックボックスの初期値を判定している条件文を確認しましょう。

if (isset($this->session) && ! $this->oldInputIsEmpty() && is_null($this->old($name)) && !$request) {
    return false;
}

isset($this->session)is_null($this->old($name))!$requestの3つは前述の通り、trueを返します。

そして、画面A→画面Bへの遷移時は! $this->oldInputIsEmpty()もtrueを返すことがわかりました。

そのため、チェックボックスの初期値がすべてfalseになっていたというわけです。

対応策

今回の事象は、flashInputした値が直後の別画面遷移時にも残ってしまっていたことが原因でした。

対応策はいくつかありますが、今回は
「他画面への遷移の際、flashInputを空更新するミドルウェアを作成」する方針で対応したいと思います。

artisanコマンドでミドルウェアを作成し、handle()内に以下を記述します。

public function handle(Request $request, $next)
{
    // 遷移元URL
    $previousUrl = $request->header('referer');

    // 遷移先URL
    $currentUrl = url()->current();

    if ($previousUrl !== $currentUrl) {
        // 遷移先と遷移後のURLが異なっていれば、flashInputの値を空更新
        session()->flashInput([]);
    }

    return $next($request);
}

今回はこのミドルウェアの動作範囲を指定したいため、Kernelの$routeMiddlewareに追記して・・・

// app/Http/Kernel.php

protected $routeMiddleware = [
    /**
    * 省略
     */
    'sample_middleware' => \App\Http\Middleware\[ミドルウェア名]::class,
];

ログイン後の管理画面のみで動作するよう、routeで指定します。

// routes/admin.php

Route::group(['middleware' => ['auth', 'sample_middleware']], function () {
    // ログイン後のrouting
}

この状態で画面A→画面Bに遷移してみると・・・

問題なく表示されることが確認できました!

おわりに

今までもFormファサードを使用して開発してきましたが、今回の仕様は初めて知りました・・・

本記事が同じ現象で悩む方の助けになれば幸いです。

 

ロジカルスタジオではエンジニアを募集しています。

Laravel等のバックエンド技術を用いて開発をしたい!という方は、是非採用サイトからご応募ください!