Laravel Eloquent でハマらないために知っておきたいこと

## はじめに

はじめまして、[オープンロジ](https://openlogi.com/corp/)のエンジニアで @ttaka と申します。
この記事は [OPENLOGI Advent Calendar 2017](https://qiita.com/advent-calendar/2017/openlogi) の8日目になります。

さて、弊社サービスのサーバーサイド開発には、 PHP のフレームワークである Laravel が使われています。今回はその Laravel の ORM である Eloquent を使う際に、ちょっとハマりそうなポイントをいくつかご紹介したいと思います。

## ハマりポイント

### 論理削除トレイトはテーブルの結合先では動作しない

論理削除自体には賛否ありますが、 Eloquent には標準でその機能が提供されています。

https://laravel.com/docs/5.5/eloquent#soft-deleting

例えば、次のような論理削除を使用している `User` と `Profile` クラスがあるとします。

```php
class User extends Model
{
    use SoftDeletes;

    public function profile()
    {
        return $this->hasOne(Profile::class);
    }
}
```

```php
class Profile extends Model
{
    use SoftDeletes;
}
```

各モデルで `find` や `where` などを利用すると、論理削除されたレコードを除外する条件が自動的にクエリーへ追加されます。

```php
User::where('name', 'hoge')
    ->toSql();
```

```sql
select * from `users`
 where `name` = ?
   and `users`.`deleted_at` is null
```

また、定義したリレーションを使って `Profile` を取得した場合や、 `with` を使った Eager Loading の場合も、論理削除されたレコードは自動的に除外されます。

```php
$user = User::find(1);
$profile = $user->profile; // 論理削除されてると null が返る
```

```php
$user = User::with('profile')->find(1);
$profile = $user->profile; // 論理削除されてると null が返る
```

では、次のように `Profile` が存在する `User` のみ取得しようと `join` を指定した場合、結合先の条件はどうなるでしょうか？

```php
User::join('profiles', 'users.id', '=', 'profiles.user_id')
    ->toSql();
```

```sql
select * from `users`
 inner join `profiles` on `users`.`id` = `profiles`.`user_id`
 where `users`.`deleted_at` is null
```

このように、結合先の `profiles` に対しては論理削除の条件が付加されません。そのため、手動で `profiles.deleted_at is null` の条件を加える必要があります。

### クエリービルダーによる削除ではオブザーバーが動作しない

Eloquent には Observer を設定することで、作成や削除といったイベント時に特定の処理をさせる機能があります。

https://laravel.com/docs/5.5/eloquent#observers

ただ、クエリービルダーによる `delete` などでは、そのイベントが発火しません。

```php
$user->items()->delete(); // 発火しない
```

そのため、ちょっと面倒ではありますが、各 Eloquent インスタンスの `delete` メソッドを呼んであげる必要があります。

```php
$user->items->each(function ($item) {
    $item->delete(); // 発火する
});
```

### テーブルを結合すると同名のカラムは上書きされる

`join` 先のテーブルに同じ名称のカラムがあると、主テーブルの値が上書きされてしまうため、必ず `select` を一緒に指定するようにしましょう。

```php
User::join('profiles', 'users.id', '=', 'profiles.user_id')
    ->select('users.*') // 必ず指定しよう！
    ->get();
```

### 予約語のカラム名を使う際は取得時に注意

`hidden` などのように Eloquent 側で利用しているカラム名を使うと、そのクラスのメソッド内から `$this->hidden` ではアクセスできません。その際は `$this->getAttribute('hidden')` のようにアクセスする必要があります。

### 条件に OR を使う際は必ずグループ化する

一見すると問題なさそうな次のクエリーですが……

```php
User::where('name', 'hoge')
    ->orWhere('is_admin', true)
    ->get();
```

論理削除を使ってると、自動的に条件が追加されて正しく動作しなくなります。そのため、必ずグルーピングしてまとめる必要があります。

```php
User::where(function ($query) {
    $query->where('name', 'hoge')
        ->orWhere('is_admin', true);
})->get();
```

論理削除などを使ってなくても、後から条件を追加することはありますし、常にグループ化しておきたいですね。

### chunk を使う際は必ず orderBy を指定

レコードが大量にあっても、分割して取得できる便利な `chunk` メソッドがあります。

```php
Users::query()->chunk(100, function ($users) {
    foreach ($users as $user) {
        // 悪い例
    }
});
```

このメソッドですが、内部的には `SELECT * FROM users LIMIT ?,?` の結果を順に返してるだけですので、次のように `orderBy` を指定しないと、正しく動作しないことがあります。

```php
Users::query()->orderBy('id')->chunk(100, function ($users) {
    foreach ($users as $user) {
        // 良い例
    }
});
```

SQL では `ORDER` を付け忘れないのに、ソースコードになると見た目の問題かつい忘れがちなので、気をつけたいですね。

ちなみに Laravel のドキュメントでも、ちゃんと `orderBy` を指定した例になってます。

https://laravel.com/docs/5.5/queries#chunking-results

### 属性キャストを指定しないとそのままの値

例えば、マイグレーションで `is_admin` カラムを次のように指定し、レコードには `0/1` の値が格納されているものとします。

```php
Schema::table('users', function (Blueprint $table) {
    $table->boolean('is_admin')->default(false);
});
```

このまま `$user->is_admin` にアクセスしても、残念ながらそのまま `0/1` の値が返ってきます……。

そんな時は、属性キャストを指定することで、その型に自動変換されます。

```php
class User extends Model
{
    protected $casts = [
        'is_admin' => 'boolean',
    ];
}
```

これで曖昧比較が嫌いな方も、安心して `$user->is_admin === true` で判定できますね。

## おわりに

以上、知ってれば何ということはないものですが、他のフレームワークなどと動きが違っていたり、ついやってしまいがちなことをいくつかまとめてみました。

アドベントカレンダーどころか Qiita に書くのもはじめてのことなので、何か至らないところがありましたら、ご指摘くださると助かります。

それでは、みなさんが Eloquent を使って何かハマったときに、解決の一助になれば幸いです。