Laravlのルートとコントローラーのパラメーター名が異なっているときの動作

2021-03-11
7 min read

Laravel のフレームワークには興味深い実装がいくつかあります。その 1 つとして「ルートとコントローラのパラメータ名が異なっていても正常に動作する」というものがあります。 どのようなものなのかを解説します。そして、PHP8 以降では「Unknown named parameter」エラーになることをお伝えします。

ルートとコントローラでパラメータ名が異なる場合とは

route ファイルでルートパラメータとして name を要求するルートを設定します。

//routes/web.php
Route::get('/{name}', 'BasicController@show');

対応するコントローラクラスの仮引数を$name ではなく$shimei として定義します。

class BasicController extends Controller
{
    public function show($shimei) {

        return $shimei;

    }
}

このようにルートパラーメータと対応するメソッドの仮引数が異なっていても動作に問題がないことはよく知られていることだと思います。

パラメータが2つ以上あった場合

2 つ以上のルートパラメータがあった場合をみてみましょう。

//routes/web.php
Route::get('/{name}/{age}', 'BasicController@show');
class BasicController extends Controller
{
    public function show($age,$name) {

        return $age

    }
}

以下のアクセスをした場合、ブラウザには、haruka が表示されます。

http://localhost/haruka/20

どのような処理がされているかを framwork のソースにダイブして見ていきます。

まずは、コントローラのメソッドがどこで呼ばれるのかを探し出し、その後にパラメータの指定方法をみていきます。

ルートで指定したcontorollerはどこで呼び出されるのか?

Laravel の onion architecture を突き進み、ソースリーディングをしていくと vendor/laravel/framework/src/Illuminate/Routing/Route.phpdispatchメソッドにたどり着きます。

/**
  * Dispatch a request to a given controller and method.
  *
  * @param  \Illuminate\Routing\Route  $route
  * @param  mixed  $controller
  * @param  string  $method
  * @return mixed
  */
public function dispatch(Route $route, $controller, $method)
{
    //コントローラーのメソッド呼び出し時の引数を整える処理
    $parameters = $this->resolveClassMethodDependencies(
        $route->parametersWithoutNulls(), $controller, $method
    );

    //コントローラーのメソッド呼び出し
    if (method_exists($controller, 'callAction')) {
        return $controller->callAction($method, $parameters);
    }

    return $controller->{$method}(...array_values($parameters));
}

このdispatchメソッド内にてコントローラを呼び出しています。

method_exists($controller, 'callAction') で、callAction の有無を判断しています。 自作のcontrollerは、aritisan コマンドでテンプレを作成した時点で、abstract class Controller extend しています。 そして、abstract class ControllercallActionが定義されています。よって、callActionが呼ばれます。

/**
  * Execute an action on the controller.
  *
  * @param  string  $method
  * @param  array  $parameters
  * @return \Symfony\Component\HttpFoundation\Response
  */
public function callAction($method, $parameters)
{
    return call_user_func_array([$this, $method], $parameters);
}

abstract class ControllercallAction では、call_user_func_arrayを呼んでいます。

call_user_func_arrayの第 2 引数の$parameters は、メソッドを呼びときの引数になります。 arrayということで、複数の引数を指定できます。

配列は、連想配列だけでなくインデックス配列でも問題ありません。配列の並び順に、引数が割り当てられます。 連想配列の key は無視される仕様です。

call_user_func_arrayの仕様のためルートとコントローラのパラメータ名が異なっていても正常に動作することがわかります。

仮引数(parameter)と引数(argument)はどのように処理されているのか?

前述の dispatch メソッドの resolveClassMethodDependencies にて、メソッドの仮引数 (parameter)に応じて引数(argument)をよしなにしているようです。

resolveMethodDependenciesでの処理

ネームスペース、Illuminate\Routing にある trait RouteDependencyResolverTrait で処理されています。

/**
  * Resolve the given method's type-hinted dependencies.
  *
  * @param  array  $parameters
  * @param  \ReflectionFunctionAbstract  $reflector
  * @return array
  */
public function resolveMethodDependencies(array $parameters, ReflectionFunctionAbstract $reflector)
{
    $instanceCount = 0;

    $values = array_values($parameters);// 引数リスト

    $skippableValue = new \stdClass;

    //該当メソッドの仮引数リストを取得して順番に処理していく
    foreach ($reflector->getParameters() as $key => $parameter) {
        //以下では仮引数にクラスが指定されていればサービス・プロバイダーからインスタンスを取得して引数リストの該当位置にインスタンスを加えています。
        $instance = $this->transformDependency($parameter, $parameters, $skippableValue);
        if ($instance !== $skippableValue) {
            $instanceCount++;

            $this->spliceIntoParameters($parameters, $key, $instance);
        // 以下は引数の指定がなければ、メソッドの定義にデフォルト値が設定されているかを確認。デフォルト値があれば設定します。
        } elseif (! isset($values[$key - $instanceCount]) &&
                  $parameter->isDefaultValueAvailable()) {
            $this->spliceIntoParameters($parameters, $key, $parameter->getDefaultValue());
        }
    }

    return $parameters;
}

transformDependencyにて、モデルバインディングされているものを除いたクラスのインスタンスを取得しています。 モデル系のものは、ここより前の処理にて引数を元にインスタンスを取得しています。

/**
* Attempt to transform the given parameter into a class instance.
*
* @param  \ReflectionParameter  $parameter
* @param  array  $parameters
* @param  object  $skippableValue
* @return mixed
*/
protected function transformDependency(ReflectionParameter $parameter, $parameters, $skippableValue)
{
  //引数のクラス名を取得
  $className = Reflector::getParameterClassName($parameter);

  // If the parameter has a type-hinted class, we will check to see if it is already in
  // the list of parameters. If it is we will just skip it as it is probably a model
  // binding and we do not want to mess with those; otherwise, we resolve it here.
  if ($className && ! $this->alreadyInParameters($className, $parameters)) {
      return $parameter->isDefaultValueAvailable() ? null : $this->container->make($className);
  }

  return $skippableValue;
}

つまるところ、$this->container->make($className) の部分で、メソッドインジェクションが行われています。

下記で、引数リストを加工しています。

protected function spliceIntoParameters(array &$parameters, $offset, $value)
{
    array_splice(
        $parameters, $offset, 0, [$value]
    );
}

array_spliceは、第 3 引数が 0 の場合は offset で指定された位置に第 4 引数の値を挿入します。

たとえば、下記の場合、

$array = ["age" => "18","name" => "john"];
array_splice( $array, 1, 0, "country"=>"England" );
print_r($array);

["age" => "18",country"=>"England","name" => "john"] となります。

実際、以下のルートがあり、

//routes/web.php
Route::get('/{name/{name}', 'BasicController@show');

BasicController の show メソッドが以下のシグネチャの場合、

public function show($age,Requet $req,$name) {

    return $age

}

下記の値でリクエストが送られると、

http://localhost/18/john

最終的に、show メソッドに渡される引数は以下のようになります。

[“age” => “18”,0 =>requestのインスタンス ,“name” => “john”]

array_splice(
    $parameters, $offset, 0, [$value]
);

最後の [$value] が曲者ですが、配列を表すので、 0=>request のインスタンスになります。

名前付き引数(Named Arguments)があればこんなに面倒なことをしなくてよいのにと思うのですが、PHP のバージョン8 未満では、名前付き引数はありません。 よって、呼び出し時にルートパラメータとメソッドのパラメータの並び順を一致させる必要があります。それを行っているのがこの一連の処理になります。

PHP8でのcall_user_func_arrayの挙動

PHP8 から、名前付き引数(Named Arguments)が導入されました。 これにより、call_user_func_array の挙動が少し変わりました。

PHP 7 までは、連想配列のキーは無視されていましたが、PHP8 からはキーが引数名として解釈されます。

function test(\Stdclass $class,$age,$name){
    
    print $age;
}

$a = [1 => new \Stdclass,"nenrei" => "18","name" => "john"];

call_user_func_array("test",$a);

上記は、関数の仮引数の定義では、$age となっており、引数では nenrei となっています。 実行するとエラーになります。

PHP Fatal error:  Uncaught Error: Unknown named parameter になります。

さらに、連想配列とインデックス配列が混在して、下記のようにインデックス配列が途中で使用されている場合。

function test($age,\Stdclass $class,$name){
    
    print $age;
}

$a = ["age" => "18",1 =>new \Stdclass,"name" => "john"];

実行すると、

PHP Fatal error:  Uncaught Error: Cannot use positional argument after named argument 

名前付き引数の後には、順番指定の引数は設定できないというエラーになります。

順番指定の引数を先頭にして引数を指定すると、

function test($age,\Stdclass $class,$name){
    
    print $age;
}

$a = [1 =>new \Stdclass,"age" => "18","name" => "john"];

今度は、$age のオーバーライトということでエラーになります。

PHP Fatal error:  Uncaught Error: Named parameter $age overwrites previous argument

結局、動作させるためには、下記のように仮引数の定義と引数の順番は、インデックス配列を先に指定して連想配列を後に指定するようにします。


function test(\Stdclass $class,$age,$name){
    
    print $age;
}

$a = [0 =>new \Stdclass,"age" => "18","name" => "john"];

call_user_func_array("test",$a);

PHP8でのルートパラメータとコントローラの仮引数の定義に注意

ルートの処理では、ルートパラメータで設定されたものは連想配列となり、それ以外ではインデックス配列になります。 前述での call_user_func_array の挙動から以下の点について注意が必要となってきます。

  1. ルートパラメータの名前と対応するメソッドの仮引数の名前は一致させること
  2. メソッドインジェクションを意図した仮引数は他の仮引数より先に記載すること

以上、PHP8 での Laravel を用いた開発では、ルートパラメータとメソッド引数の設定には気を付けましょう。特に、アップグレード時には注意が必要です。