Laravelトラップ（ゲームブック風）

はじめまして、NACKと申します。

「毎月７日は罠（わな＝０７）の日」……と言い張ってみます。
※記念日認定されているわけではありませんので、ご注意ください。

というわけで、Laravelの開発中に、実際に罠にはまった話を、昔の[ゲームブック風](https://ja.wikipedia.org/wiki/%E3%82%B2%E3%83%BC%E3%83%A0%E3%83%96%E3%83%83%E3%82%AF)に書いてみます。
楽しんでいただければ幸いです。「では、幸運を祈っています（[1へ](#1)進む）」

「そういうお遊びは興味無いから！」という方は、直接 [罠の解説](#罠の解説)まで飛んでください。
実際にはまった罠の話が列挙されています。


---
# Laravelトラップ（ゲームブック風）
----

### 1
君は小さく、四角い、明るい部屋にいる。

扉は全部で２箇所ある。１つは東の壁、もう１つは西の壁にある。
東の扉には「Eloquentの旅」、西の扉には「envの旅」と書かれている。

東の扉を開けるなら[11へ](#11)進め。
西の扉を開けるなら[9へ](#9)進め。

----

### 2
冒険を楽しみ、さらに挑戦してみようと思うなら[1へ](#1)進む。
もう満足したか、全ての冒険を達成したなら、Laravelの開発に挑戦すべきだろう。
楽しんでくれてありがとう。

----

### 3
値は正しく取得できた。
目の前に、次の部屋への扉が現れる。
扉を開けて、[13へ](#13)進め。

----

### 4
更新日時は無事更新できた。
目の前に、次の部屋への扉が現れる。
扉を開けて、[10へ](#10)進め。

----

### 5
値は取得できなかった（[14へ](#14)）。
※[解説](#env値の呼出し箇所とキャッシュ)

----

### 6
データは無事更新できた（[2へ](#2)）。

----

### 7
値は正しく取得できた（[2へ](#2)）。

----

### 8
更新日時は更新されなかった（[14へ](#14)）。
※[解説](#saveメソッドとupdateメソッドの違い)

----

### 9
commandファイル内で、envに設定された値を取得したい。
君はconfigファイル経由で取得してもいいし（[15へ](#15)）、
commandファイルで直接取得してもいい（[3へ](#3)）。

```php5:configファイル経由で取得する
$app_name = \Config::get('app.name');  // config/app.php に return ['name' => env('APP_NAME', 'Laravel')];という定義済
```

```php5:commandファイルで直接取得する
$app_name = env('APP_NAME', 'Laravel');
```

----

### 10
Eloquentを使い、テーブルの全レコードのデータを更新したい。
君はそのままupdateしてもいいし（[12へ](#12)）、全件該当する条件を指定してからupdateしてもいい（[6へ](#6)）。
もしくは、queryメソッドを呼び出してからupdateするのも自由だ（[17へ](#17)）。

```php5:そのままupdateする
App\Flight::update(['piyo' => 1]);
```

```php5:全件該当する条件を指定してからupdateする
App\Flight::whereRaw('1=1')->update(['piyo' => 1]);
```

```php5:queryメソッドを呼び出してからupdateする
App\Flight::query()->update(['piyo' => 1]);
```

----

### 11
更新日時を更新したい。
ただし、今回は「更新日時以外の更新対象項目の値が、変更前後で変わらないこともある」状況だ。

君はsave()を使ってもいいし（[8へ](#8)）、update()を使ってもいい（[4へ](#4)）。
もしくは、touch()を使うのも自由だ（[16へ](#16)）

```php5:save()を使う
$flight = App\Flight::find(1);
$flight->name = 'ほげ'; // 元のnameも"ほげ"
$flight->save();
```

```php5:update()を使う
App\Flight::where('id', 1)
          ->update(['name' => 'ほげ']); // 元のnameも"ほげ"
```

```php5:touch()を使う
$flight = App\Flight::find(1);
$flight->name = 'ほげ'; // 元のnameも"ほげ"
$flight->touch();
```

----

### 12
データは更新されなかった（[14へ](#14)）。
※[解説](#一括更新時のupdateメソッド)

----

### 13
commandファイル内で、envに設定された値を取得したい。
（ただし、このアプリは `php artisan config:cache` を使用している）。
君はconfigファイル経由で取得してもいいし（[7へ](#7)）、
commandファイルで直接取得してもいい（[5へ](#5)）。

```php5:configファイル経由で取得する
$app_name = \Config::get('app.name');  // config/app.php に return ['name' => env('APP_NAME', 'Laravel')];という定義済
```

```php5:commandファイルで直接取得する
$app_name = env('APP_NAME', 'Laravel');
```

----

### 14
君は失敗した。だが、くじけることはない。君はいつだって再挑戦できるのだ。

----

### 15
値は正しく取得できた。
目の前に、次の部屋への扉が現れる。
扉を開けて、[13へ](#13)進め。

----

### 16
更新日時は無事更新できた。
目の前に、次の部屋への扉が現れる。
扉を開けて、[10へ](#10)進め。

※special thanks: @mikkame 

----
### 17
データは無事更新できた（[2へ](#2)）。

※special thanks: @mikkame 

----


# 罠の解説
## Eloquent / QueryBuilder

### saveメソッドとupdateメソッドの違い
「（更新日時以外の）値が、元の値から変更されているかどうか」で挙動が異なります。

+ 値が変更されている場合のみ更新するのがsaveメソッド
+ 値の変更状況に関わらず更新するのがupdateメソッド
です。


参考： [Eloquentのメソッド saveとupdateは処理が異なる](https://qiita.com/gomaaa/items/91e5cbd319279a2db6ec)

そのため、**（「変更しようとしたが、結果的に同じ値だった」場合を含め）値が変更されなかった場合、saveメソッドでは更新日時が変更されません**。
どうしてもsaveメソッドと同じような実装をしつつ、時間も確実に更新したい場合は、saveメソッドの"替わり"にtouchメソッドを使いましょう。データ部分は（更新が必要であれば）更新した上で、時間は確実に更新されます（special thanks: @mikkame ）。ただ、touchメソッドだと、ネーミング的に「データが更新される感が薄い」のが悩ましいところです……。




なお、こちら「データが更新されていないのだから、更新日時も更新しなくて良いのでは？」と思われる方もいらっしゃると思います。
正しい見解ではあるのですが、データの差分取込バッチを作った時、この仕様のために問題が発生しました……。

> 発生した問題：
> 親子テーブルを対象とする差分取込バッチで
> 「親テーブルの更新日時が変わっていたら取込対象とする。
> 　そのため、子テーブルだけデータ更新したい場合でも、親テーブルの更新日時は更新しておく」
> という作りにしたつもりだった。
> が、各テーブルの更新処理をsaveメソッドで行っていたため、親テーブルのデータ更新が無い場合に、更新日時が更新されず、取込対象から漏れた。

### 一括更新時のupdateメソッド

百聞は一見に如かず……ということで、下記のサンプルコードをご覧ください（関連コードのみ抜粋）。

```php
// クエリログ出力準備
\DB::enableQueryLog();
        
// 条件指定無しで更新
App\Flight::update(['piyo' => 1]);

// クエリログ＆実行速度出力
var_dump(\DB::getQueryLog());

/*
出力結果：
array(0) {
}
→SQLの実行ログが無い！
*/
```

```php
// クエリログ出力準備
\DB::enableQueryLog();
        
// 全件当てはまる条件付きで更新
App\Flight::whereRaw('1 = 1')->update(['piyo' => 1]);

// クエリログ＆実行速度出力
var_dump(\DB::getQueryLog());

/*
出力結果：
array(1) {
  [0] =>
  array(3) {
    'query' =>
    string(xx) "update `flights` set `piyo` = ?, `updated_at` = ? where 1 = 1"
    'bindings' =>
    array(2) {
      [0] =>
      int(1)
      [1] =>
      string(19) "2018-12-07 12:34:56"
    }
    'time' =>
    double(439.91)
  }
}
→SQLの実行ログが出てきた！
*/
```

というわけで、**条件指定無しでupdateした時は、SQLが実行されません**。

原因は、「条件が指定されているかどうか」ではなく、「Eloquent（Model）クラスから直接呼び出すupdateメソッド」と「whereメソッド使用後に呼び出すupdateメソッド」の実装が異なっているからです（当然といえば当然なのですが……）。

```php:Illuminate\Database\Eloquent\Modelクラス
// whereメソッド（whereRawメソッド等も含む）を使用せず、Eloquentクラスから直接updateメソッドを呼び出すと、ここに来る。
    /**
     * Update the model in the database.
     *
     * @param  array  $attributes
     * @param  array  $options
     * @return bool
     */
    public function update(array $attributes = [], array $options = [])
    {
        if (! $this->exists) { 
            return false;
        }

        return $this->fill($attributes)->save($options);
    }
```

```php:Illuminate\Database\Eloquent\Builderクラス
// whereメソッド（whereRawメソッド等も含む）使用後にupdateメソッドを呼び出すと、ここに来る。
    /**
     * Update a record in the database.
     *
     * @param  array  $values
     * @return int
     */
    public function update(array $values)
    {
        return $this->toBase()->update($this->addUpdatedAtColumn($values));
    }
```

なお、私が修正した時は `Illuminate\Database\Eloquent\Builder` クラスのupdateメソッドを呼び出す」ために `whereRaw` メソッドを使ってしまいましたが、**全件更新時の正攻法は「 `query` メソッドを使う」です**（special thanks: @mikkame ）。

```php5:全件更新の正攻法
App\Flight::query()->update(['piyo' => 1]);
```


## Environment
### env値の呼出し箇所とキャッシュ
special thanks: @cflat0528 

基本的に、.envファイルに設定した値は、どこからでも取得できます（ControllerやModel、Commandからも取得可能です）。

```php:App\Console\Commands\HogeCommandクラス（自作のコマンドクラス）
<?php

namespace App\Console\Commands;
use Illuminate\Console\Command;

class HogeCommand extends Command
{
    protected $signature = 'hoge:fuga';

    public function handle()
    {
        // envファイルからAPP_NAMEという変数の値が取得できればその値を、なければ"Laravel"という文字列を出力する
        echo env('APP_NAME', 'Laravel') . "\n"; 
    }
}
```

ただし、 `php artisan config:cache` と組み合わせて使う場合は注意が必要です。
`php artisan config:cache` の実行前後で、上記のコマンドの実行結果を比較してみましょう。

```console
$ php artisan hoge:fuga    # ↓envの値が取得できている
hoge-batch
$ php artisan config:cache #configの値をキャッシュすると……
Configuration cache cleared!
Configuration cached successfully!
$ php artisan hoge:fuga    # ↓出力結果が変わってしまった！
Laravel     
$
```

これは、「envファイル読み込み処理の仕様」です。

`php artisan config:cache` を実行した時、`APP_NAME`という変数が`config/app.php`で以下のように設定されている場合、

```php:config/app.php
<?php

'name' => env('APP_NAME', 'Laravel'),
```

この値を `envファイル内に定義した定数値` に置き換えた「configのキャッシュファイル」が生成されます。

```php:bootstrap/cache/config.php
<?php

'name' => 'hoge-batch' // .envで指定したAPP_NAME
```



ここまでは良いのですが、**一度 `php artisan config:cache` をした後は、アプリの起動時にenvファイルが読み込まれなくなります**。

参考：
https://github.com/laravel/framework/blob/197a7c3b86d24b8698c61107263b68cb737d51c8/src/Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php#L12-L31

```php:Illuminate\Foundation\Bootstrap\LoadEnvironmentVariablesクラス
    /**
     * Bootstrap the given application.
     *
     * @param  \Illuminate\Contracts\Foundation\Application  $app
     * @return void
     */
    public function bootstrap(Application $app)
    {
        if ($app->configurationIsCached()) {
            return; // ←configキャッシュがある場合、ここでリターンしてしまうため、後続のenvファイル読み込みが行われない
        }
        $this->checkForSpecificEnvironmentFile($app);
        try {
            (new Dotenv($app->environmentPath(), $app->environmentFile()))->load();
        } catch (InvalidPathException $e) {
            //
        }
    }
```

そのため、`php artisan config:cache` 実行後は、configファイル以外に書かれているenvの値は（キャッシュファイルに保存されておらず、envファイルの読み込みも行われないので）取得できなくなってしまうのです。


# （ゲームブック部分の）参考文献

+ [ケン・セント・アンドレ(1989)『デストラップ―T&Tソロ・アドベンチャー』社会思想社.](https://www.amazon.co.jp/%E3%83%87%E3%82%B9%E3%83%88%E3%83%A9%E3%83%83%E3%83%97%E2%80%95T-T%E3%82%BD%E3%83%AD%E3%83%BB%E3%82%A2%E3%83%89%E3%83%99%E3%83%B3%E3%83%81%E3%83%A3%E3%83%BC-%E7%8F%BE%E4%BB%A3%E6%95%99%E9%A4%8A%E6%96%87%E5%BA%AB-%E3%82%B1%E3%83%B3%E3%83%BB%E3%82%BB%E3%83%B3%E3%83%88-%E3%82%A2%E3%83%B3%E3%83%89%E3%83%AC/dp/4390112457)