Laravel で Rails の抽象 ActiveModel みたいなやつが欲しくなった

# TL;DR

車輪の再発明してしまったのでソースコードの墓場にする記事

# 内容

データベースを使わないけど， `app/Services` 配下に置くような外部 API サービスのレスポンスエンティティを Eloquent ライクに使いたいニーズがあった。

最初 Eloquent Model を継承することも考えたが，意味のないメソッドがごちゃごちゃ入ってくるのが鬱陶しいので

- `HasAttributes`
- `HidesAttributes`
- `GuardsAttributes`

の3つのトレイトをベースに，足りないものや動作修正の必要があるものを適宜ケアして，最小限の記述で作ってみたのでここにぶん投げます。IDE でチェックしながらメソッド埋めやったのでエラーとかは起こらないはず…

```php
<?php

namespace App\AbstractEntities;

use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Database\Eloquent\Concerns\GuardsAttributes;
use Illuminate\Database\Eloquent\Concerns\HasAttributes;
use Illuminate\Database\Eloquent\Concerns\HidesAttributes;
use Illuminate\Database\Eloquent\MassAssignmentException;
use Illuminate\Support\Collection;
use LogicException;

abstract class Entity implements Arrayable
{
    use HasAttributes, HidesAttributes, GuardsAttributes;

    public const CREATED_AT = 'created_at';
    public const UPDATED_AT = 'updated_at';

    /**
     * @var bool
     */
    public $timestamps = true;

    /**
     * @var bool
     */
    public $incrementing = true;

    /**
     * @var string
     */
    protected $primaryKey = 'id';

    /**
     * @var string
     */
    protected $keyType = 'int';

    /**
     * @var array
     */
    protected $relations = [];

    /**
     * Create an existing Entity instance.
     *
     * @param array $attributes
     * @return static
     */
    public static function hydrate(array $attributes = [])
    {
        return (new static())->forceFill($attributes)->syncOriginal();
    }

    /**
     * Create existing Entity instances.
     *
     * @param array $attributesArray
     * @return \Illuminate\Support\Collection|static[]
     */
    public static function hydrateMany(array $attributesArray = []): Collection
    {
        $collection = new Collection();
        foreach ($attributesArray as $key => $attributes) {
            $collection[$key] = static::hydrate($attributes);
        }
        return $collection;
    }

    /**
     * Create a new Entity instance.
     *
     * @param array $attributes
     */
    public function __construct(array $attributes = [])
    {
        $this->fill($attributes);
    }

    /**
     * Apply updates to an existing Entity instance.
     *
     * @param array $attributes
     * @return $this
     */
    public function apply(array $attributes)
    {
        return $this->forceFill($attributes)->syncOriginal();
    }

    /**
     * Fill the model with an array of attributes.
     *
     * @param array $attributes
     * @throws \Illuminate\Database\Eloquent\MassAssignmentException
     * @return $this
     */
    public function fill(array $attributes)
    {
        $totallyGuarded = $this->totallyGuarded();

        foreach ($this->fillableFromArray($attributes) as $key => $value) {
            if ($this->isFillable($key)) {
                $this->setAttribute($key, $value);
            } elseif ($totallyGuarded) {
                throw new MassAssignmentException(sprintf(
                    'Add [%s] to fillable property to allow mass assignment on [%s].',
                    $key,
                    get_class($this)
                ));
            }
        }

        return $this;
    }

    /**
     * Fill the model with an array of attributes. Force mass assignment.
     *
     * @param array $attributes
     * @return $this
     */
    public function forceFill(array $attributes)
    {
        return static::unguarded(function () use ($attributes) {
            return $this->fill($attributes);
        });
    }

    /**
     * Determine if the given relation is loaded.
     *
     * @param string $key
     * @return bool
     */
    public function relationLoaded(string $key): bool
    {
        return array_key_exists($key, $this->relations);
    }

    /**
     * Set the given relationship on the model.
     *
     * @param string $key
     * @param mixed $value
     * @return $this
     */
    public function setRelation(string $key, $value)
    {
        $this->relations[$key] = $value;

        return $this;
    }

    /**
     * Determine if the model uses timestamps.
     *
     * @return bool
     */
    public function usesTimestamps(): bool
    {
        return $this->timestamps;
    }

    /**
     * Get the value indicating whether the IDs are incrementing.
     *
     * @return bool
     */
    public function getIncrementing(): bool
    {
        return $this->incrementing;
    }

    /**
     * Get the primary key for the model.
     *
     * @return string
     */
    public function getKeyName(): string
    {
        return $this->primaryKey;
    }

    /**
     * Get the auto-incrementing key type.
     *
     * @return string
     */
    public function getKeyType(): string
    {
        return $this->keyType;
    }

    /**
     * Get the format for database stored dates.
     *
     * @return string
     */
    public function getDateFormat(): string
    {
        return $this->dateFormat ?? 'Y-m-d H:i:s';
    }

    /**
     * Get the database connection for the model.
     */
    public function getConnection(): void
    {
        throw new LogicException(static::class . ' is not an Eloquent Model; Database Connection is not available.');
    }

    /**
     * Dynamically retrieve attributes on the model.
     *
     * @param string $key
     * @return mixed
     */
    public function __get(string $key)
    {
        return $this->getAttribute($key);
    }

    /**
     * Dynamically set attributes on the model.
     *
     * @param string $key
     * @param mixed $value
     */
    public function __set(string $key, $value): void
    {
        $this->setAttribute($key, $value);
    }

    /**
     * Determine if an attribute or relation exists on the model.
     *
     * @param string $key
     * @return bool
     */
    public function __isset(string $key): bool
    {
        return $this->getAttribute($key) !== null;
    }

    /**
     * Unset an attribute on the model.
     *
     * @param string $key
     */
    public function __unset(string $key): void
    {
        unset($this->attributes[$key]);
    }

    /**
     * Convert the model instance to an array.
     *
     * @return array
     */
    public function toArray(): array
    {
        return array_replace($this->attributesToArray(), $this->relationsToArray());
    }
}
```

## 機能制約

- データベース絡む系は全部無し
  - リレーションは手動で `setRelation()` するのみ
- APIレスポンスとしてそのまま返すことも無いと思うのでインタフェースは `Jsonable` とかは実装せず `Arrayable` のみ
  - `ArrayAccess` も基本使わないので無し
- **`existing()`** で外部 API からの取得結果レスポンスからのインスタンス生成を想定
- **`apply()`** で外部 API からの更新結果レスポンスをインスタンスに反映することを想定

# 既存のライブラリ

- [jenssegers/model: This model provides an eloquent-like base class that can be used to build custom models in Laravel and other frameworks](https://github.com/jenssegers/model)