🏍️

Laravel 開発を快適にする PHP Doc (vscode での検証)

2021/10/04に公開約12,900字2件のコメント

Laravel 職人の皆さん、PHP Doc 書いてますか?

「静的解析バリバリやってるぜ!」 という方は是非ブラウザバック願います。

この記事は、Laravel での開発をちょっと快適にする、PHP Doc の書き方をご紹介します。

先日開催されたPHPカンファレンス2021 の、
とあるセッションに触発されて走り書きした雑な記事ですが、
少しでも誰かのお役に立てば。

実験環境は以下のとおりです。

VisualStudio Code 1.60.2
PHP Intelephense 1.7.1

Laravel のバージョンは、たぶん関係ありません。

とりあえずコード書こうぜ

の前に事前準備

サンプルとして、こんなテーブルがあるとします。

宗教上の理由でテーブル名に m_ とかついてたり、
プライマリーキーが xxx_id とかなってますが、悪しからず。

これに対応するモデルは、だいたいこんな感じだと思います。

<?php
namespace App\Models;

use App\Models\Shared\BaseModel;
use App\Models\Shared\Who;
use Illuminate\Database\Eloquent\SoftDeletes;

/**
 * 書籍マスタ
 */
class Book extends BaseModel
{
    use SoftDeletes, Who;

    protected $table = "m_books";
    protected $primaryKey = 'book_id';

    protected $hidden = [];
    protected $appends = [];
    protected $dates = [
        'published_date',
        'created_at',
        'updated_at',
        'deleted_at',
    ];
    protected $casts = [
        'book_id' => 'integer',
        'author_id' => 'integer',
        'published_date' => 'date:Y-m-d',
        'created_at' => 'date:Y-m-d H:i:s',
        'created_by' => 'integer',
        'updated_at' => 'date:Y-m-d H:i:s',
        'updated_by' => 'integer',
        'deleted_at' => 'date:Y-m-d H:i:s',
        'deleted_by' => 'integer',
    ];

    public function author()
    {
        return $this->belongsTo(Author::class, 'author_id', 'author_id');
    }
}
<?php
namespace App\Models;

use App\Models\Shared\BaseModel;
use App\Models\Shared\Who;
use Illuminate\Database\Eloquent\SoftDeletes;

/**
 * 著者マスタ
 */
class Author extends BaseModel
{
    use SoftDeletes, Who;

    protected $table = "m_authors";
    protected $primaryKey = 'author_id';

    protected $hidden = [];
    protected $appends = [];
    protected $dates = [
        'birthday',
        'created_at',
        'updated_at',
        'deleted_at',
    ];
    protected $casts = [
        'author_id' => 'integer',
        'sex' => 'integer',
        'birthday' => 'date:Y-m-d',
        'created_at' => 'date:Y-m-d H:i:s',
        'created_by' => 'integer',
        'updated_at' => 'date:Y-m-d H:i:s',
        'updated_by' => 'integer',
        'deleted_at' => 'date:Y-m-d H:i:s',
        'deleted_by' => 'integer',
    ];

    public function books()
    {
        return $this->hasMany(Book::class, 'author_id', 'author_id');
    }

    public function getAgeAttribute()
    {
        return $this->birthday->diffInYears(today());
    }
}

これまた諸般の事情により BaseModel とか Who トレイトとかくっついてますが、お気になさらず。

今度こそコード書くぞ!

Book モデルをインスタンス化して、

・・・

はい。

「"book_title"出てこいや!!!」

・・・ってなってませんか?

もしくはそんな不便さにもすっかり慣れて、
毎回クライアントソフトやテーブル定義書からコピペしたりしてませんか?

いやいや、本来、クラスのプロパティやメソッドは、コード補完で出てくれるはずなのです。

出てきてくれないのは、 book_id や book_title などのプロパティが、
Book クラスに実在するプロパティではなく、
マジックメソッドを通じて実現されている仮想のプロパティだからです。

つまり、vscode ちゃん(というより intelephense )は、
明示的に定義されている $table や $primaryKey の存在は知っていますが、
その背後に(DBのカラムとして)存在している $book_id や $book_title は知らないのです。

おまじない

Book モデルの先頭に

/**
 * 書籍マスタ
 *
 * @property integer $book_id
 * @property string $book_title
 * @property integer $author_id
 * @property \Illuminate\Support\Carbon $published_date
 * @property \Illuminate\Support\Carbon $created_at
 * @property integer $created_by
 * @property \Illuminate\Support\Carbon $updated_at
 * @property integer $updated_by
 * @property \Illuminate\Support\Carbon $deleted_at
 * @property integer $deleted_by
 */
class Book extends BaseModel
{

これをごちゃごちゃっと追加してやるだけで、

出た・・・!!!

それだけではありません。

たとえば $published_date であれば、
ちゃんと Carbon であることまで理解してくれているので、

Carbon のメソッドまで補完が利いてくれます。

これ何?

/* */ で囲まれてるんだからコメント・・・ではなくて、
PHP Doc といい、書式があれこれとルール化されています。

詳しいことは 「PHPDoc」 でググってください。
入り組んだ歴史的事情とかも知りたい方は、さらに「PSR-5」とか「PSR-19」とかでググってください。

文法的にはコメントなので、PHPの動作には影響しません。

が、PHP Storm などの IDE は、
コード上のタイプヒンティング等だけでなく、
これらの記載(アノテーション)も加味してコードを補完したり、
エラーチェックをしたりしてくれます。

vscode の場合、PHP Intelephense という拡張機能を有効化することで、
このあたりを強力にサポートしてくれます。

つまり、

 /**
 * @property \Illuminate\Support\Carbon $published_date
 */

と書いておくことで、 vscode ちゃんに、
「このクラスには published_date ってプロパティもあるよ! 型は Carbon だよ!」
と教えてあげているわけです。

アクセサも書く

一方、Author モデルには、
アクセサ getAgeAttribute() が定義されています。

これにより、

$author->age

で、計算された年齢が返るわけですが、

このままだと、やはり、補完が利いてくれません。

(vscode ちゃん 「そんなローカルルール知らんもん・・・」)

そこで、

/**
 * 著者マスタ
 *
 * @property integer $author_id
 * @property string $author_name
 * @property integer $sex
 * @property \Illuminate\Support\Carbon $birthday
 * @property \Illuminate\Support\Carbon $created_at
 * @property integer $created_by
 * @property \Illuminate\Support\Carbon $updated_at
 * @property integer $updated_by
 * @property \Illuminate\Support\Carbon $deleted_at
 * @property integer $deleted_by
 *
 * @property integer $age <- これ
 */
class Author extends BaseModel
{

アクセサも @property で定義してあげると、

無事出てきました。

ちゃんと int であることまで伝わっています。

リレーションも認識させる

続いてリレーションです。

Book -> Author の belongsTo リレーションからいきましょう。

一見すると勝手に補完が利いているように見えますが、

これは、プロパティ author ではなく、
リレーションを定義している author() メソッド。
このまま ENTER を押すと、 author() となってしまいます。

違うそうじゃない。

というわけで、

<?php

namespace App\Models;

use App\Models\Shared\BaseModel;
use App\Models\Shared\Who;
use Illuminate\Database\Eloquent\SoftDeletes;

/**
 * 書籍マスタ
 *
 * @property integer $book_id
 * @property string $book_title
 * @property integer $author_id
 * @property \Illuminate\Support\Carbon $published_date
 * @property \Illuminate\Support\Carbon $created_at
 * @property integer $created_by
 * @property \Illuminate\Support\Carbon $updated_at
 * @property integer $updated_by
 * @property \Illuminate\Support\Carbon $deleted_at
 * @property integer $deleted_by
 *
 * @property Author $author <- これ
 */
class Book extends BaseModel
{

これを追加すると、

author の補完が効くのはもちろん、
$book->author が Author モデルのオブジェクトであることを認識してくれるので、

ここまでいけます。

ラスボス、hasMany

さて、他方の Author モデルには、 Book に対する hasMany リレーションがあります。

ご存知の通り hasMany リレーションは、コレクションを返しますので、

 /**
 * @property \Illuminate\Database\Eloquent\Collection $books
 */

こいつを追加してやると、

補完が利くのはもちろん、

コレクションのメソッドまでちゃんと補完してくれます。

しかし、これを foreach で回すと・・・

book_title が出てきません。

なぜなら vscode ちゃんは $books がコレクションであることは知っていますが、
その中身たる $book が何者かを知らないのです。

$book にマウスオーバーしてみても、 mixed (=何か) としか出てきません。

そこで、もうひと手間

/**
 * @property \Illuminate\Database\Eloquent\Collection<Book> $books
 */

このように記載してあげることで、「Book の Collection だよ!」と明示することができます。

補完もばっちり。

ちゃんと vscode ちゃんが、
$book を Book モデルのオブジェクトだと認識していることがわかります。

いろいろ補足

コード補完だけじゃない

1番手っ取り早くありがたみが伝わりそうな、コード補完を中心に書きましたが、
真骨頂はたぶんエラーチェックです。

vscode ちゃんが、コードに登場する各変数の型を正しく認識できるようになることで、
タイプヒンティングで指定されているのと違う型の変数をメソッドの引数に渡していたり、
そのクラスに存在しないメソッドを呼び出したりしたときに、
的確にエラーを表示してくれます。

実行前にエラーに気づけると、開発効率が上がり、不具合も未然に防ぐことができますよね。

この視点で考えると、本記事で紹介した程度ではヌルい、
という 過激派 先進的な流派も存在します。

「PHP 静的解析」とかでググると、きっと沼にハマれます。

もちろんモデルだけじゃない

宗派により Service とか UseCase とか Repository とか様々ですが、
Controller から呼び出してデータを返したり更新したりするクラス、ありますよね。

たとえばこういうやつ。

class listLatestBooks()
{
    function __invoke(Author $author) : Collection
    {
        return $author->books->filter(function ($book) {
            return $book->published_date->gt(today()->subYear());
	});
    }
}

PHP純正のタイプヒンティングだと、 Collection が返る、
ということろまでしか明記できませんが、

PHP Doc を使うと、

class listLatestBooks()
{
    /**
     * 特定著者の新着書籍を返す
     *
     * @param \App\Models\Author $author
     * @return \Illuminate\Database\Eloquent\Collection<\App\Models\Book>
     */
    function __invoke(Author $author) : Collection
    {
        return $author->books->filter(function ($book) {
            return $book->published_date->gt(today()->subYear());
	});
    }
}

「Book モデルの Collection を返すよ!」 というところまで明示することができます。

呼び出し元で foreach で回したとき、
その中身まで型を把握してもらえるので、とても便利で安全です。

もちろん配列も

本記事では Laraveler に馴染みのある Collection を中心にご紹介しましたが、
配列の場合でも同様に定義できます。

int の配列

/**
 * @property array<int> $ints1
 * @property int[] $ints2
 */

配列の場合は単に xxx[] とも書けるので、すっきりしますね。

キーが int で、値が string な配列

/**
 * @property array<int, string> $assoc
 */

クラスオブジェクト (例 : Carbon) の配列

/**
 * @property array<\Illuminate\Support\Carbon> $carbons1
 * @property \Illuminate\Support\Carbon[] $carbons2
 */

一応、

use \Illuminate\Support\Carbon;

を書いておけば、単に

/**
 * @property Carbon[] $carbons1
 * @property array<Carbon> $carbons2
 */

と書けるのですが、アノテーションのためだけに use 書くのはどうなんでしょう。。。

※ちなみにモデルの @property の例で単に Author 等と書けているのは、
 同一ネームスペースのクラスだからです。

自由な連想配列

例えば、

$user = [
    'id' => 1,
    'name' => 'roku',
    'birthday' => Carbon::parse('1987-01-29'),
];

みたいな配列も、

/**
 * @property array{id:int, name:string, birthday:Carbon} $user
 */

のように表現できるようです。

個人的にはこんな配列をメソッドの引数や戻り値、ましてやプロパティにすべきではないと思います。
専用のクラスを定義すべきでしょう。

全テーブル全カラム @property を書くのがめんどくせぇ

Laravel IDE Helper Generator ( barryvdh/laravel-ide-helper ) という便利なライブラリがあります。

composer でインストールして

composer require --dev barryvdh/laravel-ide-helper

以下のコマンドを実行すると、

php artisan ide-helper:model

全てのモデルに、自動的にアノテーションをつけてくれます。

先の Author モデルはこんな感じです。

/**
 * 著者マスタ
 *
 * @property int $author_id
 * @property string $author_name
 * @property int $sex
 * @property mixed $birthday
 * @property mixed $created_at
 * @property int $created_by
 * @property mixed $updated_at
 * @property int $updated_by
 * @property mixed|null $deleted_at
 * @property int|null $deleted_by
 * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\Book[] $books
 * @property-read int|null $books_count
 * @property-read mixed $age
 * @method static \App\Models\Shared\CustomBuilder|Author newModelQuery()
 * @method static \App\Models\Shared\CustomBuilder|Author newQuery()
 * @method static \Illuminate\Database\Query\Builder|Author onlyTrashed()
 * @method static \App\Models\Shared\CustomBuilder|Author query()
 * @method static \App\Models\Shared\CustomBuilder|Author whereAuthorId($value)
 * @method static \App\Models\Shared\CustomBuilder|Author whereAuthorName($value)
 * @method static \App\Models\Shared\CustomBuilder|Author whereBirthday($value)
 * @method static \App\Models\Shared\CustomBuilder|Author whereCreatedAt($value)
 * @method static \App\Models\Shared\CustomBuilder|Author whereCreatedBy($value)
 * @method static \App\Models\Shared\CustomBuilder|Author whereDeletedAt($value)
 * @method static \App\Models\Shared\CustomBuilder|Author whereDeletedBy($value)
 * @method static \App\Models\Shared\CustomBuilder|Author whereSex($value)
 * @method static \App\Models\Shared\CustomBuilder|Author whereUpdatedAt($value)
 * @method static \App\Models\Shared\CustomBuilder|Author whereUpdatedBy($value)
 * @method static \Illuminate\Database\Query\Builder|Author withTrashed()
 * @method static \Illuminate\Database\Query\Builder|Author withoutTrashed()
 * @mixin \Eloquent
 */

より丁寧に、アクセサやリレーション等は、読み取り専用であることを示す、
@property-read になっていますね。

ファイルに物理的に追加されるので、自動上書きが怖い方は、

php artisan ide-helper:model --nowrite

とすると、アノテーション部分の内容のみが、ひとつのテキストファイルにまとめて書き出してくれます。

上の例では $age が mixed となっていますが、あらかじめアクセサに

    public function getAgeAttribute() : int

きちんとタイプヒンティングを書いておけば、 int で書いてくれます。

さいごに

元々私自身は、 @property はモデルにだけつけたりつけなかったり。

@param や @return はタイプヒンティングから自動生成する(そして引数を変えた時に直し忘れる)程度だったのですが。

一方で、 array や Collection としか書けないことにもどかしさを感じていました。

これ全く型の保証になってねーじゃん、と。

別の言語では、そもそも型宣言に int[] とか書ける(あるいは書かなきゃいけない)わけです。

でもPHPではこれができない。

いっそ BookCollection クラスとか、AuthorCollection クラスとか、
さらには IntList クラスとか StringList クラスとか量産してやろうか、
などと思案していました。

phpcon2021 のセッションにて、上記のような配列の型記法(ジェネリクス型)が、
PHPDoc では使える、IDEがサポートしてくれるということを知り、

「これ Laravel の Collection でも使えるんじゃね?」 と思い至り、
改めて PHPDoc の重要さを感じました。

今流行りの(?)静的解析も、もっと勉強していこうと思います。

Discussion

連想配列の型定義に関しては <> ではなく、 array{id:int, name:string, birthday:Carbon} のように {} を使った記法が一般的かと思われます!

https://phpstan.org/writing-php-code/phpdoc-types#array-shapes

また「全テーブル全カラム @property を書くのがめんどくせぇ」については laravel-ide-helper というもので自動でPHPDocを追記できますので共有させていただきます!

コメントありがとうございます!

array-shapes 記法書き損じてました・・・修正します。

laravel-ide-helper もペチコンで聴いたのに、すっかり忘れてました・・・
検証してから差し替えます!

ログインするとコメントできます