PHP
laravel

Handling input parameters in Laravel

この記事について

ウェブアプリケーションにおける入力パラメータをどう扱うか、というテーマで、下記の勉強会で LT しまして、その資料です。

Laravel/Vue.js勉強会#5 ~CORE~ - connpass


概要

話したこと

本記事での「入力パラメータ」とは、HTTPリクエストとしての入力パラメータと関数の引数としての入力パラメータを指しています。

  • 1. 基本(Laravel における連想配列データの取り扱い方法について)
  • 2. 実践(HTTPリクエストの扱いと関数パラメータの扱いについて)

環境

  • PHP 7.1.12
  • Laravel 5.6.24

1. 基本

まずは、Laravel で使える、連想配列を操作するための関数やクラスのバリエーションをおさらいしておきます。

主な関数やクラスは以下の通りです。

  • collect(), data_*(), array_*()
  • Collection
  • Arr
  • Fluent

1.1. dot notation

ネストした配列のキーを、 . で繋いで要素にダイレクトにアクセスできるようにする記法です。

$data = ['products' => ['desk' => ['price' => 100]]];
$price = array_get($data, 'products.desk.price');
// 100
// 以下と等価
// Arr::get($data, 'products.desk.price')

問い

とあるPOSTを受けるエンドポイントに対する以下のような入力パラメータがあったとき、

{
  "data": {
    "hero": {
      "name": "R2-D2",
    }
  }
}

以下の (1) - (3) のうち、 戻り値が R2-D2 にならないのはどれでしょう?

// SomeController
public function __invoke(Request $request)
{
    $input = $request->json('data');
// 'hero' => [
//    'name' => "R2-D2",
// ]
    return [
        // (1) using Collection
        collect($input)->get('hero.name'),
        // (2) using Arr
        array_get($input, 'hero.name'),
        // (3) using helper function
        data_get($input, 'hero.name'),
    ];
}

答え

(1)

Collection::get() は dot notation 使えないんです、なぜかは分からないけど。


1.2. null coalescing

nullable なデータが null だった場合に、与えられた別の値を返す仕組みです。

$data = ['products' => ['desk' => ['price' => 100]]];
data_get($data, 'products.desk.type', 'Unknown')
// Unknown
// 以下と等価
// $data['products']['desk']['price'] ?? 'Unknown'

問い

以下のようなモデルクラスと永続化されたデータがあったとき、

class User extends Model
{
    public function profile(): HasOne
    {
        return $this->hasOne(Profile::class)
    }
}

/**
 * @property string favorite_character
 */
class Profile extends Model
{
}

users

id name
1 Luke Skywalker
2 Han Solo

profiles

id user_id favorite_character
1 1 C-3PO

以下の (1) - (3) のうち、戻り値が R2-D2 にならないのはどれでしょう?

$user = User::findOrFail(2);
// (1) ?? operator
$user->profile->favorite_charactor ?? 'R2-D2'
// (2) optional + ?? operator
optional($user->profile)->favorite_character ?? 'R2-D2';
$user = User::findOrFail(1);
// (3) data_get
data_get($user, 'profile.favorite_character', 'R2-D2');

答え

(3)

data_get() はオブジェクトのプロパティチェーンにも対応しているので、(3) は C-3PO が返ります。


2. 実践


2.1. Request

アプリケーションの外部からやってくるパラメータは Request オブジェクトに内包されて Controller に渡されます。

Request に渡される主な入力パラメータ

  • ヘッダ
  • URL パラメータ
  • POST/GET パラメータ
  • ファイル

ここでは主に POST/GET パラメータを見ていきます。


FormRequest の継承関係

  • class FormRequest extends Request バリデーション関連の振る舞いを追加
  • class Request extends Symfony\Component\HttpFoundation\Request 主にルーティング/関連の振る舞いを追加

Request クラスの入力パラメータ関連メソッド群

(実体は Illuminate\Http\Concerns\InteractsWithInput トレイト)

// キーの指定ができる
array all(array $keys = [])
// 指定したキーのパラメータのみ
array only(array $keys)
// 指定したキーのパラメータを除外
array except(array $keys)
// 指定したキーの値があるかどうか
bool has(mixed $key)
// POSTパラメータを取得
mixed post(mixed $key = null, mixed $default = null)
// GETパラメータを取得
mixed query(mixed $key = null, mixed $default = null)
// POST か GET かは問わない
mixed input(mixed $key = null, mixed $default = null)
// JSON ペイロードから取得
mixed json(string $key, mixed $default = null)

// offsetGet
$request['key']

// __get
$request->key

問い

以下の (1) - (3) のうち、 戻り値が R2-D2 にならないのはどれでしょう?

// SomeController
public function __invoke(Request $request)
{
//  $request->json()
// 'hero' => [
//    'name' => "R2-D2",
// ]
    return [
        // (1) get
        $request->get('hero.name'),
        // (2) input
        $request->input('hero.name'),
        // (3) json
        $request->json('hero.name'),
    ];
}

答え

(1)

Request::get() は Symfony Request のメソッドで、 dot notation に対応していません、 input()/query() を使いましょう。


(おまけ) DTO 的な使い方

参考) Laravel の FormRequest を Data Transfer Object 的に使ってみた - Qiita

Request から、UseCase へのパラメータをつくる

// SomeController
function __invoke(SomeRequest $request, SomeUseCase $useCase)
{
    $someModel = $request->someModel();
    $response = $useCase($someModel);

    return $response;
}

// SomeRequest
function someModel(): SomeModel
{
    $data = $this->json('data');
    // 入力パラメータをモデル生成のためのパラメータに変換し、
    // モデルを生成して返す
    return new SomeModel($data);
}

2.2 関数パラメータ

依存関係が増えてくると、メソッドの引数が多くなってくることがあります。

Laravel ではインスタンス生成時、リフレクションを利用して自動的に、依存オブジェクトをパラメータに渡すことができますので、これを用いて、 クラスが 依存するオブジェクトを DI で差し込むことで、メソッド単位の依存を減らすことができます。


<?php

namespace App\UseCases;

class SomeUseCase extends UseCase
{
    private $someService;

    public function __construct(SomeService $someService)
    {
        $this->someService = $someService;
    }
}

// client
// 以下は通常フレームワークが面倒を見る
$useCase = new SomeUseCase(app(SomeService::class));

問い

以下の 1 - 5 のうち、コンストラクタに DI できないのはどれでしょう?

  1. Controller
  2. FormRequest
  3. Model
  4. EventListener
  5. Command

答え

(3)

Model のコンストラクタは __construct(array $attributes = []) のみで、DI の余地はありません。

逆に、他のクラスはぜんぶコンストラクタで DI できるんですよね、便利。


(おまけ)入力パラメータのリレー

Request → Controller → Model → Response

HTTPリクエストパラメータを Request から取得し、Controller を経由して Model へ渡し、最終的に Response へ渡す、という一連の流れの中で、それぞれにインピーダンスミスマッチが存在しうるので、それを受け渡し側がやるのか、受け取り側でやるのか、という問題があります。


例えば、

// SomeController
public function __invoke(Request $request, SomeModel $someModel)
{
    $result = $someModel->doSomething($request->all());
    return (new SomeResponse())->json($result->toArray());
}
// Model
public function doSomething(array $parameters)
{
    // ...
}
// Response
public function json(array $parameters)
{
    // ...
}

これらのパラメータは Model の振る舞いや Response の構造に最適化されたものだろうか? :thinking:


実例で考えてみます。

パターンは主に2つかな、と思ってまして、

  1. 受け渡し側が受け取り側に配慮した構造に変換する
  2. 受け渡し側は材料を提供し、受け取り側でよしなに変換する

1. モデルの検索

// Controller
public function __invoke(Request $request)
{
    $models = SomeModel::where('price', '<=', $request->input('price'))
        ->where('genre', $request->input('genre'))
        ->get();
}

// Controller
public function __invoke(SearchRequest $request)
{
    $models = SomeModel::search($request->buildQuery(SomeModel::query()))
        ->get();
}
// Request
public function buildQuery(Builder $query): Builder
{
    return $query->where('price', '<=', $this->input('price'))
    // ...
    ;
}

Builder オブジェクトを返すようにしました。


2. モデルの更新

// Controller
public function __invoke(Request $request, SomeModel $model)
{
    $model->some_datetime = $request->input('date') . ' ' . $request->input('time');
    $model->save();
}

// Controller
public function __invoke(UpdateRequest $request, SomeModel $model)
{
    $model->some_datetime = $request->someDatetime();
    $model->save();
}
// Request
public function someDatetime(): Carbon
{
    return new Carbon($request->input('date') . ' ' . $request->input('time'));
}

Model のアトリビュートに適した形にあらかじめ変換して渡してやるようにしました。


3. 複数のモデルにまたがる操作

// Controller
public function __invoke(Request $request, SomeModel $someModel)
{
    $parametersForSomeModel = [];
    // build parameters from $request...
    // ...
    $someModel->doSomething($parametersForSomeModel);

    $attributesForAnotherModel = [];
    // build attributes from $someModel...
    // ...
    AnotherModel::create($attributesForAnotherModel);

    return $someModel;
}

// Controller
public function __invoke(SomeRequest $request, SomeModel $someModel)
{
    $someModel->doSomething($request->parametersForSomeModel());
    AnotherModel::createFromSomeModel($someModel);

    return $someModel;
}
// Request
public function parametersForSomeModel()
{
    $parameters = [];
    // build parameters from $request...
    // ...
    return $parameters;
}
// AnotherModel
public function createFromSomeModel(SomeModel $some)
{
    // build attributes from $someModel...
    // ...
}

今回はデータ変換をモデル(AnotherModel)側でやってみました。

余談ですが、PHP には関数のオーバーロードがないので、メソッド名が長くなりがちですが、こればっかりはどうにもなりませんね…


これらのケース、みなさんなら、受け渡し側、受け取り側、どちらで変換処理を行いますか?


上記3つのケース、1 と 2 はビュー(UI)の都合で変わるので、ビューと隣り合っている FormRequest で変換してやるのが理に適ってるかな、と思いました。

一方、3 のケースは、AnotherModel の初期化は AnotherModel の構造に依存しているので、自身でハンドリングするのがいいのかな、と思いました。

そうやって、受け渡し側、受け取り側、どちらの構造に依存しているかを判断軸にすると、データ変換をどちらが担当した方がいいのか、見えてくるのかぁな、と思います。


後半は Laravel と関係ない話題になりましたが、 issetarray_key_exists などのチェック関数を使わずに、上手に入力パラメータを扱うことができるようになりますので、 CollectionArr など(をラップしたヘルパー関数とか)を使ってみてください。

他にも、入力パラメータの取り扱いにおいて、こんな工夫をしているよ!という方がいたら、ぜひ教えていただけると助かります :bow: