【Laravel】ルート情報の記述順によって発生するエラーと正規表現 制約を使った対策

## はじめに

初めてQiitaに記事を投稿します。
ご指摘いただけますと幸いです。

## 1. 概要

ルートパラメータを使用する場合、記述の順番によっては
DBに存在しないデータを処理しようとして、エラーが発生する可能性があります。

今回は、簡単なエラー発生例を記したあと、原因と対策について説明します。

<font color="SlateGray">※本記事は初学者向けに詳しく解説しています。</font>

## 2. 環境

- PHP：7.4
- Laravel：6.20

## 3. エラー発生の例

下記前提を踏まえた上で、エラーコードのサンプルを示します。

### 3.1 前提

- ログインユーザーが記事を投稿できるsnsアプリを想定。
- 分かりやすいように最低限のメソッド、ルート情報のみを実装。
- 処理に対するルート情報のURI仕様は下記とする。

| 処理|HTTP Method |URI | Action |Middleware|
|:-:|:-:|:-:|:-:|:-:|
| 記事詳細画面を表示  |GET| articles/{記事のid}  |show   |web, guest|
|  記事投稿画面を表示 | GET|articles/create  | create  |web, auth|

- ルートパラメータは教材でよくある"id"を用いる。
- viewは正しく実装されているものとして、今回は記載を省略する。

### 3.2 エラーが発生するコード

下記のコードにて、ログインした状態で記事投稿画面を表示させようと`articles/create` にアクセスするとエラーが発生します。

```php:routes/web.php
Auth::routes();

Route::get('articles/{id}', 'ArticlesController@show')->name('articles.show');

Route::group(['middleware' => 'auth'], function () {
    Route::get('articles/create', 'ArticlesController@create')->name('articles.create');
});
```

※groupで括っているのは、他の処理を書いていた名残りです。

```php:app/Http/Controllers/ArticlesController.php
<?php

namespace App\Http\Controllers;

use App\Article;

class ArticlesController extends Controller
{
    public function create()
    {
        return view('articles.create');
    }

    public function show($id)
    {
        $article = Article::find($id);
        return view('articles.show', compact('article'));
    }
}
```

### 3.3 エラー内容

今回の例ではエラー内容は下記となります。

```:エラー内容
ErrorException
Trying to get property 'title' of non-object (View: /var/www/html/resources/views/articles/show.blade.php)
```

上記は、「オブジェクトにはtitleというプロパティはない」というエラーになっています。
( `articles/show.blade.php`では、`$article`モデルオブジェクトから`title`プロパティを取得する記述をしています。 )

したがって、下記のいずれかが考えられます。

- `$article`モデルオブジェクトにtitleというプロパティが存在しない。
- `$article`モデルオブジェクト、`title`というプロパティは存在しているが、アクセス修飾子がprivateまたはprotectedで設定されており、アクセスできない。
- `$article`モデルオブジェクトがそもそも存在しない。

[エラー"Trying to get property of non-object"を解決したい](https://teratail.com/questions/76134)


また、createの処理を実行したつもりが、
なぜかshowアクションから表示する`show.blade.php`のファイルでエラーを指摘されています。

## 4. エラーの原因

### 4.1 原因

<font color="Red">エラーの原因は、`web.php`のshowアクションを実行するURIのルートパラメータに`create`を渡してしまっていることです。</font>

これにより、showアクションにDBに存在しないパラメータが渡され、処理されます(nullが返ってくる)。
したがってblade側では`$article`オブジェクトがそもそも存在していないため、エラーが発生します。

[Laravelのルーティング優先度でつまづいた話](https://qiita.com/dhiki1234/items/dcb2d2bc17d11e1f4565)

### 4.2 詳細解説

#### 4.2.1 ルートパラメータについて 

まず、前提知識としてルートパラメータについて説明します。
今回showアクションのルートパラメータは`{id}`としています。
記事のidを受け取るので分かりやすく`{id}`にしていますが、下記の規則に従えば`{x}`でも`{article}`でもなんでも良いです。

>[Laravel 6.x ルーティング：ルートパラメーター](https://readouble.com/laravel/6.x/ja/routing.html)
>ルートパラメータは、いつも{}括弧で囲み、アルファベット文字で構成してください。
>ルートパラメータには、ハイフン（-）を使えません。


ちなみに今回の場合、パラメータを`{article}`とし、コントローラー側のアクションの引数を`(Article $article)`とすると、
ルートパラメータで指定したIDを持つ、モデルインスタンスが注入されて便利です。

>[Laravel 6.x ルーティング：モデル結合ルート](https://readouble.com/laravel/6.x/ja/routing.html)
>ルートかコントローラアクションへモデルIDが指定される場合、IDに対応するそのモデルを取得するため、大抵の場合クエリします。
>Laravelのモデル結合はルートへ直接、そのモデルインスタンスを自動的に注入する便利な手法を提供しています。
>つまり、ユーザーのIDが渡される代わりに、指定されたIDに一致するUserモデルインスタンスが渡されます

#### 4.2.2 ルートパラメータが受け付ける値

ルートパラメータですが、`{id}`にしたからといって数字のみ受け付けるのではなく、文字列も受け付けます。
リファレンスによるとデフォルトでは、`/`以外は受け付けるそうです。

>[Laravel 6.x ルーティング：スラッシュのエンコード](https://readouble.com/laravel/6.x/ja/routing.html)
>Laravelのルーティングコンポーネントは、/を除くすべての文字を許可しています。

試しにパラメータに`/`を入れて実行しましたが、コントローラーへ行き着く前に404エラーレスポンスを返しました。

#### 4.2.3  エラー発生の流れ

以上を踏まえて、エラー発生の原因について詳しく説明します。

web.phpに記載されたルート情報の処理は上から順番に実行されます。
showアクションのルート情報はcreateアクションのルート情報よりも先に記述されています。

つまり、
<img width="900" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1723886/fc1e9c85-2bc3-d9ac-338e-02dc3bd0327f.png">

となって、viewメソッドにより、nullがbladeに渡されエラーが発生します。

リソースルートでCRUDを一括で設定すれば、その中では順番を考慮しなくても良いですが、
今回のようにshowは認証しない、createは認証するなど分ける場合は、ルートパラメータや記述順に注意する必要があります。

:::note warn
<strong>処理によって異なるエラー内容</strong>

1.今回の例では、showアクション内で<code>find</code>メソッドを使用したことで、DBにデータがない場合の返り値が<code>null</code>となりましたが、コントローラーの処理を下記のようにしている場合は、<code>null</code>を返さずに「HTTP 404エラー」が発生します。

　   ・<code>findOrFail</code>でモデルインスタンスを取得する処理を実装。
　   ・モデル結合ルートを使用している場合。

2.アクションでint型でidをタイプヒンティング(型宣言)した場合は、「int型を入力してください」 といったエラーが発生します。
:::

[Laravelのfind()とfindOrFail()の違い、使い分け方。初心者もよく分かる！](https://nebikatsu.com/8589.html/)

>[Laravel 6.x ルーティング：モデル結合ルート](https://readouble.com/laravel/6.x/ja/routing.html)
>一致するモデルインスタンスがデータベースへ存在しない場合、404 HTTPレスポンスが自動的に生成されます。

[PHPの型宣言（タイプヒンティング）の話](https://kani-kee-toto.com/php-type-hinting/)

## 5. 対策

そもそもURI設計を適切にしていれば起きないのかも知れませんが、
起きてしまった場合の対策を3つ記載します。

### 5.1 ルートの記述順を変える。

今回の例で一番簡単な方法はルートの順番を変更することです。

下記のようにshowとcreateのルートを入れ替えることで、createが先に実行されるためエラーは発生しません。

```php:web.php
Auth::routes();

Route::group(['middleware' => 'auth'], function () {
    Route::get('articles/create', 'ArticlesController@create')->name('articles.create');
});

Route::get('articles/{id}', 'ArticlesController@show')->name('articles.show');
```

個人的に思うデメリットとしては、下記が挙げられます。

- 事前にURI設計をしてない場合、ルート情報が多くなるとややこしくなる。
- ルートを追加した際に今後も同じエラーが発生する恐れがある。

### 5.2 web.phpに正規表現を使用

whereメソッドを使い、引数に正規表現を記載すると、パラメータを制限することができます。
ここでいう制限とはエラーを返さずに次の処理を実行することです。

ルート全体で当てはまるパラメータがなければ、404エラーを返します。


idの制約はリファレンスに記載されている`'id', [0-9]+`を用いています。
これは、idのパラメータは、「数字0~9で1文字以上」であることを意味します。

```php:routes/web.php
Auth::routes();

Route::get('articles/{id}', 'ArticlesController@show')->name('articles.show')->where('id', '[0-9]+'); //whereメソッドを追記

Route::group(['middleware' => 'auth'], function () {
    Route::get('articles/create', 'ArticlesController@create')->name('articles.create');
});
```
[Laravel 6.x ルーティング：正規表現制約](https://readouble.com/laravel/6.x/ja/routing.html)
[【5分でまるっと理解】PHP正規表現の使い方まとめ](https://eng-entrance.com/php-regularex)

また、idは0が入ることはなく、1以上なので、そういった正規表現を行う場合は下記の表現ができるみたいです。
（上記の正規表現との挙動の違いは、0が入力された時、ルーティングの段階で404エラーを返すか、コントローラー以降の処理でエラーを返すかの違いになります。）

```php:数字1~9で1文字以上の正規表現
('id', '\+?[1-9][0-9]*');
```

[正の整数のことをききたいです](https://teratail.com/questions/77394)

### 5.3 グローバル制約

正規表現は指定したルートパラメータ全てに適用することもできます。
下記のように`bootメソッド`内で`patternメソッド`を用います。

第1引数で指定したパラメータと同一のルートパラメータを、第2引数の制約で制限することができます。

```php:app/Providers/RouteServiceProvider.php
    /**
     * Define your route model bindings, pattern filters, etc.
     *
     * @return void
     */
    public function boot()
    {
        Route::pattern('id', '[0-9]+'); //ルート制約を追加

        parent::boot();
    }
```

[Laravel 6.x ルーティング：グローバル制約](https://readouble.com/laravel/6.x/ja/routing.html)

## 6. まとめ

今回は、実際に僕もハマったことのあるルートのエラーについての解説でした。

ルートの制約は実務ベースで使われるか不明ですが、
そういったところも含めてご指摘いただけますと幸いです。