Laravel: spatieでラジオボタンが選択されなくなったら
Laravelのフォームビルダー(*1)は、これまで何度か変わってきました。
- 初期の頃(~laravel4?)はLaravel内に存在した
- Laravel5からはLaravel本体から削除され、別パッケージのlaravelcollective/htmlとして分離された
- laravelcollective/htmlも2023年秋頃にサポート終了を迎えた
- 後継としてspatie/laravel-htmlがあるが、laravelcollective/htmlとは全く互換性はない
このような感じで、ころころ変わるのと、spatie/laravel-htmlはlaravelcollective/htmlと互換性がないので、フォームビルダーは使わず自分でフォーム処理を書いている人もいるかもしれません。
パッケージの更新後にラジオボタンが選択されなくなった
ここから本題ですが、お客様が自社のシステムのパッケージ類を更新したところ、データ更新ページのフォームのラジオボタンが正しく選択されなくなったとのことでお問い合わせをいただきました。
お客様のシステムのフォームのイメージとしては以下のように、bladeテンプレート内でspatieを使ってフォームを出力するものです。modelForm()メソッドでモデル(以下の例では$category)を設定して、フォームの初期値としてモデルのデータを表示しようとしています。作り的にはオーソドックスなものです。
spatieを使ったフォームの記述例
{{ html()->modelForm($category, 'PUT', route('xxxxx', $category->id))->open() }} ←モデル($category)を設定して初期値として表示する ... <label>{{ html()->radio('enable', null, '1') }}公開</label> <label>{{ html()->radio('enable', null, '0') }}非公開</label> ←ラジオボタンのvalueは0/1ではなく'0'/'1'のようにstring型であることに注意
ですが以下のように、レコード編集用のページを開いてもラジオボタンだけ現在の状態が選択されていない状態になってしまったようです。

結論から言うと、これは、spatie/laravel-html 3.11 → 3.12でラジオボタンの選択処理がloose comparison(==)からstrict comparison(===)に変更されたためです。
まず、前提知識として、LaravelのModel(Eloquent)はカラム型がintのような整数型だと、対応するプロパティ値をModelから取得した場合は変数型もintになります。 例えば何らかのフラグ(今回の例ではenableフラグ)をBOOLEAN(TINYINT(1))型カラムで定義していた場合、 $catecory->enableプロパティから取得する値の型もintになります。
このため、既存のデータをフォームに表示しようとしてmodelForm()でフォームにModelを設定した場合、loose comparisonしていた時は0 == '0' ⇨ true, 1 == '1' ⇨ trueとなり適切なものが選択されていたのが、strict comparisonになるとは0 === '0' ⇨ false, 1 === '1' ⇨ falseになるのでいずれのボタンも選択されなくなってしまうわけです。
それなら対策としては、フォーム要素に設定する値($value引数)を0,1のように数値にすればいいのでは?と考えるかもしれませんが、入力エラー時にold()で旧入力値を取得する値はstringになるので(*2)、今度は入力エラーでフォームを再表示する際にラジオボタンが選択されなくなります。
間違った対策
<label>{{ html()->radio('enable', null, 1) }}公開</label> <label>{{ html()->radio('enable', null, 0) }}非公開</label> ←ラジオボタンのvalueはint型で指定する
フォームへの入力値がint(0/1)になったりstring('0'/'1')になったりするのが問題なのですが、従来はloose comparisonでうまいこと動作していたのが、strict comparisonになって動作しなくなったわけです。 同様の仕様変更によりラジオボタンをうまく扱えなくなったことはlaravelcollective/htmlの時にもありました。 後継のspatieではradioボタンでの扱いやすさを考えて意図的にloose comparisonをしていたのかと思ったのですが、結局修正されたということはそうではなかったようです。
対策方法としては、
$catecory->enable = (string) $catecory->enable
のように毎回キャストしてやったりという方法もあるのですが、変更箇所が多くなったりあまりスマートではないので、今回は従来のようにloose comparisonに戻すやり方を紹介しようと思います。
loose comparisonに戻すといっても、パッケージのバージョンを戻すのでは意味がないので、\Spatie\Html\Htmlクラスを継承して該当部位を書き換える形で修正します。
\Spatie\Html\Htmlのカスタマイズ
今回問題になったのは、\Spatie\Html\Htmlクラスのradio()メソッドです。 このHtmlクラスを継承してradio()メソッドをoverrideします。
app/Services/SpatieHtmlEx.php
<?php
namespace App\Services;
use Illuminate\Support\Str;
use Spatie\Html\Html;
class SpatieHtmlEx extends Html
{
/**
* @param string|null $name
* @param bool $checked
* @param string|null $value
*
* @return \Spatie\Html\Elements\Input
*/
public function radio($name = null, $checked = null, $value = null)
{
// spatie 3.13でchecked判定がstrictly compareになったので従来どおりの
// loosely compareに戻す処理。
return $this->input('radio', $name, $value)
->attributeIf($name, 'id', $value === null ? $name : ($name.'_'.Str::slug($value)))
->attributeIf(! is_null($value), 'value', $value)
->attributeIf((! is_null($value) && $this->old($name) == $value) || $checked, 'checked');
}
}
overrideしたradio()メソッドではloose comparisonに戻しています(青色箇所)。
そしてサービスコンテナにこのクラスを登録しておきます。
サービスコンテナへの登録
namespace App\Providers; use App\Services\SpatieHtmlEx; use Illuminate\Support\ServiceProvider; use Spatie\Html\Html; class AppServiceProvider extends ServiceProvider { ... public function register() { // spatieのHtmlクラスを拡張したものを登録する。 $this->app->singleton(Html::class, SpatieHtmlEx::class); }
これで、
{{ html()->radio('enable', null, '1') }}
のようにフォーム要素を出力する際、SpatieHtmlExが使われるようになります。
ユニットテストも作成しておきましょう。
tests/Feature/Services/SpatieHtmlExTest.php
<?php namespace Tests\Feature\Services; // use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class SpatieHtmlExTest extends TestCase { public function testRadio(): void { // string html()->model(['enable' => '0']); $input = html()->radio('enable', null, '0'); $this->assertTrue(str_contains($input, 'value="0"')); $this->assertTrue(str_contains($input, 'checked')); html()->model(['enable' => '1']); $input = html()->radio('enable', null, '1'); $this->assertTrue(str_contains($input, 'value="1"')); $this->assertTrue(str_contains($input, 'checked')); // int html()->model(['enable' => 0]); $input = html()->radio('enable', null, '0'); $this->assertTrue(str_contains($input, 'value="0"')); $this->assertTrue(str_contains($input, 'checked')); html()->model(['enable' => 1]); $input = html()->radio('enable', null, '1'); $this->assertTrue(str_contains($input, 'value="1"')); $this->assertTrue(str_contains($input, 'checked')); // noinput html()->model(['enable' => '']); $input = html()->radio('enable', null, '0'); $this->assertTrue(str_contains($input, 'value="0"')); $this->assertFalse(str_contains($input, 'checked')); // checkedがつかないこと html()->model(['enable' => null]); $input = html()->radio('enable', null, '0'); $this->assertTrue(str_contains($input, 'value="0"')); $this->assertFalse(str_contains($input, 'checked')); // checkedがつかないこと } }
これで、ラジオボタンが正しく選択されるようになります。
ちなみに初期表示時や未入力状態でエラーになってフォーム再表示した場合は、入力値はnullや空文字('')になりますが、これらは'0'とloose comparisonしてもtrueにはならないので(*3)誤って選択状態になることはありません。
loose comparisonが気持ち悪くて嫌という人は、入力値のis_numeric()がtrueの場合は、stringなりintにキャストしてstrict comparisonする形でもいいでしょう。 100とかの数データも$_GET, $_POSTに設定される値の型はstringになるので、フォームで扱うデータの型はstringに統一して処理するのが個人的には好みです。
Spatieに拡張に関する参考ページ:
https://spatie.be/docs/laravel-html/v3/general-usage/extending
(*1) フォームのHTML出力をサポートしてくれるクラス/関数。
(*2) フォームからPOSTされた値は$_POSTではstring型になるので、old()で取得できる値もstringになる。
(*3) https://www.php.net/manual/ja/types.comparisons.php
投稿日:2025/05/13 12:55