LaravelのModelクラスのcreated_atのようなDateTime型をCarbonとして扱う

Laravelには日時を扱う際に便利なCarbonライブラリが組み込まれており、

現在ではPHP界隈でメジャーになってきたかなとひしひしと感じる今日この頃。

同様にLaravelのModelはデフォルトでcreated_atとupdated_atをサポートしており、

DBにデータを反映させる際に自動的に面倒を見てくれますよね。

…で、例えばあるレコードの日時型のカラムを扱う際に、その都度Caron::parse($this->created_at)なんてしていると、非効率でいなくなりたくなります。

 

自分の同じような経験をしたことが人へ、少しでも手助けとなりますように。

 

《想定読者》

  • Laravelを使っている/今後使いたい人で、先人の失敗談に興味のある人。
  • LaravelでModelとCarbon使っているときに、少しイケてないコードを書いちゃってるなと思っている人。
  • PHPのクラスにマジックメソッドという素晴らしいものがあることを知らない人。
  • Laravelを構成するライブラリのソースコードを読む習慣のない人。(読みましょう)

 

《背景》

冒頭でも書きましたが、LaravelのModelクラスで例えばMySQLのDateTime型でデータを取り出したときは、デフォルトのままでは以下のようになります。

$ php artisan tinker
Psy Shell v0.8.17 (PHP 7.2.9-1+ubuntu18.04.1+deb.sury.org+1 — cli) by Justin Hileman
>>> $sampleModel = SampleModel::find(1)
[!] Aliasing 'SampleModel' to 'App\SampleModel' for this Tinker session.
=> App\SampleModel {#886
  id: 1,
  created_at: "2018-11-20 15:24:53",
  updated_at: "2018-11-20 15:24:53"
}
>>> $sampleModel->created_at
=> "2018-11-29 10:40:00"

 

このように

>>> $sampleModel->created_at
=> "2018-11-29 10:40:00"

となっていることからcreated_atは文字列型の扱いです。(DateTimeですらない)

もしcreated_atをCarbonとして利用したい場合は、

\Carbon\Carbon::parse($sampleModel->created_at)

としなくてはいけません。

 

基本的には上記の方法で問題ないのですが、コントローラを1度コールされた際に、

\Carbon\Carbon::parse($sampleModel->created_at);

\Carbon\Carbon::parse($sampleModel->created_at);

\Carbon\Carbon::parse($sampleModel->created_at);

と何度も呼び出すのは非効率だし、

使いまわすために

$createdAt = \Carbon\Carbon::parse($sampleModel->created_at);

$createdAt;

$createdAt;

とやるのもいけてない気がする。。。 

 

「そうだ、Modelクラス側でDateTimeカラムは自動的にCarbon型でとれるようにしよう!」というのが今回の内容です。

 

《方法》

Modelクラスの以下のマジックメソッドにテコ入れを行う。

  • __construct
  • __get
  • __set

 

《マジックメソッドとは?》

それくらい公式のドキュメントをご覧ください。

http://php.net/manual/ja/language.oop5.magic.php

 

《実際のコード》

今回はトレイトとして実装します。

<?php
namespace App;

use Carbon\Carbon;

trait AutoCarbonField
{
protected $carbonFields = [];
protected $carbonValues = [];

public function __construct(array $attributes = [])
{
parent::__construct($attributes);

if ($this->timestamps) {
$this->carbonFields[] = static::CREATED_AT;
$this->carbonFields[] = static::UPDATED_AT;
}
}

/**
* getter magic method.
*
* @param string $key
* @return Carbon|mixed|null
* @throws \Exception
*/
public function __get($key)
{
if (array_search($key, $this->carbonFields) !== false) {
$attribute = $this->getAttribute($key);
if (isset($attribute)) {
if (array_key_exists($key, $this->carbonValues)) {
return $this->carbonValues[$key];
}
try {
return $this->carbonValues[$key] = Carbon::parse($attribute);
} catch (\Exception $e) {
throw new \Exception(sprintf("Invalid Carbon field. [class: %s, property: %s, value: %s]", static::class, $key, $attribute), null, $e);
}
}
return null;
}

return parent::__get($key);
}

/**
* setter magic method.
*
* @param string $key
* @param mixed $value
* @throws \Exception
*/
public function __set($key, $value)
{
if (array_search($key, $this->carbonFields) !== false && array_key_exists($key, $this->carbonValues)) {
unset($this->carbonValues[$key]);
parent::__set($key, $value);
$this->__get($key);
} else {
parent::__set($key, $value);
}
}
}

このトレイトをuseして、

<?php
namespace App;

use Illuminate\Database\Eloquent\Model;

class SampleModel extends Model
{
use AutoCarbonField;
}

その後、SampleModel->created_atがCarbonになっているか確認

>>> $sampleModel = \App\SampleModel::find(1)
=> App\SampleModel {#887
  id: 1,
  created_at: "2018-11-20 15:18:25",
  updated_at: "2018-11-20 15:25:22"
}
>>> $sampleModel->created_at
=> Carbon\Carbon @1542694705 {#875
  date: 2018-11-20 15:18:25.0 Asia/Tokyo (+09:00),
}

>>> get_class($sampleModel->created_at)
=> "Carbon\Carbon"
>>> $sampleModel->created_at->format('Y/m/d H:i:s')
=> "2018/11/20 15:18:25"
>>>

これで煩わしいCarbon::parseから解放されました。

 

以上です。

今回も閲覧いただきありがとうございました。

Redisのデータベース番号を".env"に持たせた際にハマった件について(Laravel)

これからLaravelが依存関係で利用している"vlucas/phpdotenv"でハマります。

自分の同じようハマった人へ、少しでも手助けとなりますように。

 

《想定読者》

  • Laravelを使っている/今後使いたい人で、先人の失敗談に興味のある人。
  • LaravelでRedisを利用する際、DB番号0が無いと怒られたことのある人。
  • Laravelを構成するライブラリのソースコードを読む習慣のない人。(読みましょう)

 

《背景》

いきなり外部のリンクで恐縮ですが、

Redis 5.5 Laravel

これを参考にしながらRedisへの接続を設定していた。

その中で以下の箇所がありますが、

'redis' => [

 

'client' => 'predis',

 

'default' => [

'host' => env('REDIS_HOST', 'localhost'),

'password' => env('REDIS_PASSWORD', null),

'port' => env('REDIS_PORT', 6379),

'database' => 0,

],

 

],

"database"も.envに持たせた方が扱いやすいこともあり、以下のように内容を書き換えた。

'redis' => [

 

'client' => 'predis',

 

'default' => [

'host' => env('REDIS_HOST'),

'password' => env('REDIS_PASSWORD'),

'port' => env('REDIS_PORT'),

'database' => env('REDIS_DB'),

],

 

],

 合わせて.envに次の定義を行った。

REDIS_HOST=localhost

REDIS_PASSWORD=hoge

REDIS_PORT=6379

REDIS_DB=0

Redisへの接続を試行すると失敗した。

f:id:jfujimura13:20180416142210p:plain

エラー画面

RedisからそんなDBはないと怒られてしまっているのだが、0番DBはRedis上に存在している。

 

《究明》

.envの書き方を変えつつ、artisan tinker で env('REDIS_DB') を実行してみた。

  1. 「REDIS_DB=0」の場合
    >>> ""
    結果:  空
  2. 「REDIS_DB=1」の場合
    >>> "1"
    結果: 文字列の1
  3. 「REDIS_DB="1"」の場合
    >>> "1"
    結果: 文字列の1
  4. 「REDIS_DB="0"」の場合
    >>> "0"
    結果: 文字列の0

もしかして、数字の0で定義をすると、空文字に変換されているのでは…。

実際に"0"で定義することで、Redisへの接続時に発生していた問題が解決した。

 

《原因》

.envの書き方に問題があるらしい。

…というよりも、Laravelが利用している"vlucas/phpdotenv"ライブラリの問題ともいえる。

(少しバージョンが異なるが)実際のソースコードは以下。

github.com

 同じ事象に遭遇し、issueを上げている人がいました。

github.com

見たところLaravelが依存関係と使用しているdotenvライブラリのバージョンが古いという理由で、issueは未解決のままクローズされてました。

 

原因の箇所はvlucas/phpdotenv/src/Dotenv.phpの180行目付近です。

バグか仕様かはわかりませんが、以下の挙動となります。

 protected static function sanitiseVariableValue($value)
{
    $value = trim($value);  // ここで$value = "0" と評価される。
    if (!$value) {  // この行で false と評価される。
        return '';  // 最終的に "0" ではなく "" に代わりreturnされる。
    }
    if (strpbrk($value[0], '"\'') !== false) { // value starts with a quote
    ...

同ライブラリの最新版についてはどうなっているかを確認したところ…。

全く同じコードのままでした。

vlucas/phpdotenvライブラリを利用する場合、.envファイルの内容によっては意図しない動作となることがあるため、十分にご注意を…。

 

《対策》

  • env関数で.envの内容を読み込む際は、可能であればデフォルト値を設定する。
  • .env を利用する際は、〇〇=0という書き方はしないようにする。
  • 困ったらLaravelや依存ライブラリのソースコードを読むようにする。

以上です。

今回も閲覧いただきありがとうございました。

Phpstorm + LaravelのEloquent Modelで爆速補完したい

仕事柄PHPフレームワークLaravelを普段から触ってます。

取引先に卸す関係上、バージョンは常にLTS。*1

 

《想定読者》

  • Phpstorm等のPHP開発環境を利用している人

 (Phpstorm以外を無視した内容のため適時自分の環境に置換してください。)

  • 開発環境のコード補完効率を上げ、開発速度を上げたい人
  • LaravelのEloquent ORMで取得したインスタンスの補完が聞かなくてムズムズする人

 

《背景》

以下のようなコードを書いていた。

$team = \App\Team::find($team_id);

if( $team->isMember(auth()->user()) ) {

    // do something

}

 私は血液型がA型なので、"find"メソッドに警告が出ていることが気になった。

$team = \App\Team::find($team_id); // ←これ

if( $team->isMember(auth()->user()) ) {

    // do something

}

警告が出る原因は、Teamクラスにstatic宣言されたfindが存在しないことが理由だった。

LaravelのEloquent Modelを利用者であれば常識的なことだが、

static宣言されていないがこのコードは問題なく動作する。

警告の理由は、Eloquent Modelのマジックメソッド(__callStatic)が、以下のような実装になっているためだ。

public static function __callStatic($method, $parameters)

{

    return (new static)->$method(...$parameters);

 つまり、static宣言されていないメソッドが呼び出された場合、自動的にインスタンスを生成してメンバメソッドを呼び出している。(無理やりstaticメソッドとして呼び出す。)

 

このような実装の場合、Phpstormはfindメソッドを補完してくれないため、「あれ?そのstaticメソッドある?」的な注意を促すのである。

また、findメソッドを見つけることのできないPhpstormは、findメソッドの戻り値の型も当然把握していない。

そのため、以下の箇所まで入力しても\App\Teamクラスのインスタンスメソッドが補完されない。

$team = \App\Team::find($team_id);

if( $team->

 爆速でプログラミングを行う上で、これは由々しき事態である。(しかもPhpstormをつかっているのに…。)

 

どうにかして、Phpstorm側にfindメソッドをstaticに呼び出せると錯覚させることはできないか試行錯誤を行った。

 

《解決方法》

Eloquent Model の継承クラス(モデル)に、補完用のTraitを付与する方法で解決した。

実際のTraitは以下の通りである。

<?php

namespace App;

use Illuminate\Database\Eloquent\Collection;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;

/**
* Trait ModelSupportHelper
*
* @package App
*
* @method static $this find($id, $columns = ['*'])
* @method static $this where($column, $operator = null, $value = null, $boolean = 'and')
* @method static $this whereColumn($first, $operator = null, $second = null, $boolean = 'and')
* @method static $this whereIn($column, $values, $boolean = 'and', $not = false)
* @method static $this whereNotIn($column, $values, $boolean = 'and')
* @method static $this orderBy($column, $direction = 'asc')
* @method static $this orderByDesc($column)
* @method static Collection get($columns = ['*'])
* @method static $this first($columns = ['*'])
* @method static LengthAwarePaginator paginate($perPage = 15, $columns = ['*'], $pageName = 'page', $page = null)
*/
trait ModelSupportHelper
{
}

上記のTraitをモデルでuseする。

Traitのphpdocとして、保持しているメソッドを記述しています。

当然ですが、phpdocはコメントのためこのTraitをuseするクラスの挙動には影響はありません。

phpdocのコツとしては、以下のように戻り値をstaticメソッドで、戻り値を$thisにすること。

* @method static $this find($id, $columns = ['*'])

すると、冒頭のコードが以下のようにfindメソッドの警告がなくなる。

$team = \App\Team::find($team_id);

if( $team->isMember(auth()->user()) ) {

    // do something

}

 さらに、以下まで入力した段階で補完候補にisMemberメソッド(インスタンスメソッド)が出てくるようになる。

$team = \App\Team::find($team_id);

if( $team->

これでモデルクラスを利用したコードの補完が効くようになり、快適なコーディングが可能になる。

 

《最後に》

中身が空っぽのTraitをuseする副作用について、知見をお持ちの方がいらっしゃればご教授ください。

*1:執筆時点(2018年3月30日)のLTSバージョン5.5を元にしてます