【Laravel】 ルートパラメータとモデルを紐付けて楽にモデルを取得する

ルートパラメータに入れた値をプライマリーキーの値としてレコードを取得したい、といったことはよくあります。その場合、毎回Model::find($id)を書く必要はありません。

ルートパラメータの代わりにモデルを引数に入れる

今までは、次のように書いていました。

class HelloController extends Controller
{
    public function getNote(Request $request, $id)
    {
        Log::debug($request->all());
        $note = Note::find($id);
        // ...
        return $note;
    }
}

しかし、いちいちfindする必要はありません。Controllerの引数にモデルのクラスの型を指定するだけ動かすことができます。

今まで通りルート側にルートパラメータを入れたら、対応するコントローラの仮引数に該当モデルの型を付与することで、対応するモデルを取得できます。

こうすると、Laravelはルートパラメータに入れた値を使って、プライマリーキーで絞り込んで該当する1件を取得してくれます

Route::get('/notes/{note}', [HelloController::class, 'getNote']);
class HelloController extends Controller
{
    // Note $note のように型をつける
    // 自動的に Note::find($note) され、その結果が引数に入る
    public function getNote(Note $note)
    {
        return $note;
    }
}

これを暗黙の結合と呼びます。

カラムを指定

暗黙の結合をしている場合に、どのカラムで取得するかを指定できます。{note:title}のように、:の後にカラム名を付けます。

Route::get('/notes/{note:title}', [HelloController::class, 'getNote']);
// titleカラムで絞り込んだデータが$noteに入る
public function getNote(Note $note)
{
    return $note;
}

このカラムを指定するものをカスタムキーと呼びます。

複数件該当する場合、最初の1件だけが取得されます。

暗黙の結合の場合だけなので、Route::modelで指定している場合はこの手法は使えません。次の節にあるRoute::bindを利用してください。

明確に結合する

ルートパラメータとモデルをどう対応させるかを明確に指定することもできます。次の3つの作業を行います。

  • ルートパラメータ名とモデルの紐付け
  • ルートに、上で紐付けたときの名前でルートパラメータを設定
  • コントローラのメソッドの引数に、ルートパラメータのときと同様に設定

まずはapp/Http/Providers/RouteServiceProvider.phpにあるboot()で、ルートパラメータの名前とモデルの紐付けを行います。

app/Http/Providers/RouteServiceProvider.php
class RouteServiceProvider extends ServiceProvider
{
    public function boot()
    {
        // ...
        // ルートパラメータの {note} に入れた値とNoteモデルを紐付ける
        Route::model('note', Note::class);
    }
}

次に、上で決めた名前と同じ名前のルートパラメータを含めてルートを作ります。

app/routes/web.php
Route::get('/notes/{note}', [HelloController::class, 'getNote'])

最後に、コントローラ側でそれを受け取ると、URLに入れた値の代わりに、その値をプライマリーキーの値として検索した結果として取得された値が入ります。

use App\Models\Note;

class HelloController extends Controller
{
    // 仮引数の型をモデルにする
    // https://.../notes/100 でアクセスすると、$noteにidが100であるNoteのレコードが入る
    public function getNote(Note $note)
    {
        Log::debug($request->all());
        Log::debug($note);
        // ...
        return $note;
    }
}

手前のモデルで絞り込む

例えば、特定のグループに所属しているもののうち、特定のタイトルで存在するものを絞り込みたい (つまりグループでスコープを作りたい) など、事前に何かで絞り込み、その中から更に絞り込むという場合があると思います。

その場合は、基本的にルートパラメータを並べるだけで実現できます。

重要な点として、先に絞り込む側のモデルには、絞り込まれる側のモデルへのリレーションを定義しなければなりません。

// groupで絞り込む → 該当group_idを持つnoteだけを取得する
Route::get('/groups/{group}/notes/{note:title}', [HelloController::class, 'getNote']);
class Group extends Model
{
    protected $table = 'groups';

    /**
     * このGroupに属する全てのNoteを取得
     *
     * 結合で絞り込むために必須。 ->get() 等は付けてはならない
     */
    function notes() {
        return $this->hasMany(Note::class);
    }
}
class HelloController extends Controller
{
    // 今回は暗黙の結合が効くように型を付与
    public function getNote(Group $group, Note $note)
    {
        return $note;
    }
}

モデル側の定義を忘れている場合、Call to undefined method App\Models\Group::notes()のような表示が出ます。

モデル側で定義しているものの、return $this->hasMany(Note::class)->get();のように->get()などを付けてしまっている場合、Method Illuminate\Database\Eloquent\Collection::getRelated does not exist.となります。

カスタムキーを使用しない場合でも、ルート側に->scopeBindings()を付けるとスコープを有効にできます。

Route::get('/groups/{group}/notes/{note}', [HelloController::class, 'getNote'])
    ->scopeBindings();

この場合に->scopeBindings()を付けなかった場合、絞り込みは行われません。それぞれ独立して普通に取得されます。

Route::scopeBindings()->group(/*...*/)のようにすれば、グループ単位で指定できます。

結合をカスタマイズ

デフォルトでは、プライマリーキー(id)を使用して結合が行われます。それ以外を使用したい場合はカスタマイズ可能です。

Route::bindを使う

デフォルトのものを使用する場合はRoute::modelでしたが、こちらではRoute::bindを用います。

Route::bind('note', function ($value) {
    $note = Note::find($value);
    return $note;
});

第2引数に入れる関数の引数は、ルートパラメータに入ってきた値です。ここで戻り値として返した値がコントローラの引数に入ります。

ここで、返す値がモデルである必要はありません。普通の値でも、配列でもなんでも良いです。

Route::bind('note', function ($value) {
    return 'user-' . $value;
});

モデルでresolveRouteBindingをオーバーライドする

Route::modelを使用してモデルが取得される際は、そのモデルのresolveRouteBindingが呼ばれます。これをオーバーライドして挙動を変えるというものです。

Providerのboot()側では、従来どおりRoute::modelを使用します。

class Note extends Model
{
    public function resolveRouteBinding($value, $field = null)
    {
        return $this->where('group_id', $value)->firstOrFail();
    }
}

また、/groups/{group}/notes/{note:title}のようにスコープで絞り込む例では、絞り込む側 (この例ではgroup) でresolveChildRouteBindingをオーバーライドします。

class Group extends Model
{
    protected $table = 'groups';

    /**
     * このGroupに属する全てのNoteを取得
     */
    function notes() {
        return $this->hasMany(Note::class);
    }

    /**
     * 値と結合するモデルの取得
     *
     * @param  string  $childType
     * @param  mixed  $value
     * @param  string|null  $field
     * @return \Illuminate\Database\Eloquent\Model|null
     */
    public function resolveChildRouteBinding($childType, $value, $field)
    {
        Log::debug($childType); // note
        Log::debug($value); // 1

        // デフォルトでは、色々絞り込みが行われて最終的なNoteのモデル1件を返す
        return parent::resolveChildRouteBinding($childType, $value, $field);
    }
}

モデルが見つからない場合の挙動

モデルが見つからない場合、デフォルトでは404になります。これをカスタマイズしたい場合、ルートを定義するときにmissingメソッドを使用します。

Route::get('/notes/{note}', [HelloController::class, 'getNote'])
    ->missing(function (Request $request) {
        return Redirect::route('err');
    });

また、ルーティングをコントローラと紐付ける時と同様に、クラスとメソッド名を使用して紐付けることもできます。ただし、メソッドはstaticである必要があります。

Route::get('/notes/{note}', [HelloController::class, 'getNote'])
    ->missing([HelloController::class, 'missingNote']);
class HelloController extends Controller
{
    // 必ずstaticにする
    static public function missingNote(Request $request)
    {
        return Redirect::route('err');
    }
}

Enumと結合

これまでの例ではモデルと結合させていましたが、Enumとも結合できます。

使い方に変わりはなく、モデルの代わりにEnumを指定するだけです。もしEnumに含まれない値がルートパラメータに入ってきた場合、missing扱いになります。

PHP8.1以上が必要です。

App/Enumsディレクトリに作成することを推奨します。

App/Enums/ContentType.php
namespace App\Enums;

enum ContentType: string
{
    case Html = 'html';
    case Text = 'text';
}
route
Route::get('/groups/{group}/content-type/{type}', [HelloController::class, 'getHoge']);
controller
use App\Enums\ContentType;

class HelloController extends Controller
{
    // 暗黙の結合。 Enumで定義した'html'と'text'以外は404扱いになる
    public function getHoge(Group $group, ContentType $type)
    {
        Log::debug($group);
        Log::debug($type->value);
        // ...
        return /* ... */;
    }
}

まとめ

結合を用いることで、いちいちModel::findしたり、whereして絞り込んだりといった行為を減らすことが可能です。これにより、コントローラのコードの肥大化を防ぐことができます。

手動で書くのではなく、Laravelの機能をどんどん使っていきましょう!

laravel combine thumb

役に立ったらシェアしよう!