10
11

More than 1 year has passed since last update.

【Laravel】「配列やコレクションの中身に何が入っているか分からない...」を解決する

Last updated at Posted at 2022-10-28

はじめに

PHP(Laravel)ではGoやTypeScript等とは違い配列やコレクションの「中身の構造や型」を定義する術がありません。

こんな現象に見舞われたことはないでしょうか?

Purchase.php
class Purchase
{
    /**
     * @param  array  $data
     */
    public function get(array $data) // ← ?
    {
        // $dataを使って何かをする処理
    }
}

$data...?」

これは$dataの命名が抽象的すぎて何を表してるのか分からない、という話ではなく、arrayと型定義されてはいるもののその中身がどうなっているのか(どういう構造をしているのか)全くわからない、という問題です。

仮に引数の名前を分かりやすく、さらにアノテーションを詳しくしてみることにします。

Purchase.php
class Purchase
{
    /**
     * @param  array<int>  $orders
     */
    public function get(array $orders)
    {
        // $ordersを使って何かをする処理
    }
}

なるほどこれなら「$ordersという配列の中に数値が入っているということは、注文のIDかなにかが複数入っているのか」という風に理解できるかと思います。

ただこのarray<int>はあくまで注釈であって、「この配列の中身は全て数値である」ということを保証するものではないです。

つまり数値ではない可能性も全然あって、呼び出し元を確認してどんな情報が飛んできているのか確認し、さらに中身をデバッグして本当に数値だけなのか、といったことを検証する必要があります。

また配列の中身が数値だけではなく商品の情報やメタ情報なんかを含んだ重厚な構造をしているおそれもあり、

Purchase.php
class Purchase
{
    /**
     * @param array $orders = [
     *      'id'         => (int) id.
     *      'date'       => (string) date.
     *      'quantity'   => (int) quantity.
     *      'amount'     => (int) amount.
     *      'shipStatus' => (int) ship status.
     *      'note'       => (string) note.
     *      'item'       => [
     *          'id' => (int) id.
     *      ],
     *      'data'       => (array) meta info.
     *      ...
     *    ]
     */
    public function get(array $order)
    {
        // $dataを用いて何かをする処理
    }
}

しかもこれを$ordersを引数に指定するすべてのメソッドに書くのか...?という問題や、もし書いたとしても修正があった場合に何箇所も修正することになる、という問題が発生します。

そんな問題をこのライブラリを使ってデータをオブジェクトに変換することで解決していきます。

  • spatie/laravel-data

前提

php --version
> PHP 8.1.11

php artisan --version
> Laravel Framework 9.27.0

composer show -i
> spatie/laravel-data 2.0.13

目次

配列をオブジェクトに変換して構造や型を定義する

使い方は簡単で、\Spatie\LaravelData\Dataを継承してプロパティを定義するだけです。
たとえば上の$ordersだとこんな風になります。

OrderData.php
use Spatie\LaravelData\Data;

class OrderData extends Data
{
    public function __construct(
        public int $id,
    ) {
    }
}

fromメソッドで元の値からオブジェクトを生成します。

MyController.php
use App\Data\OrderData;
use App\Http\Controllers\Controller;

class MyController extends Controller
{
    public function index()
    {
        $orderData = OrderData::from([
            'id' => 1,
        ]);

        dd($orderData->id); // 1
    }
}

toArray()メソッドで元の配列に変換もできるので配列として扱いたい場合も問題なしです。

MyController.php
use App\Data\OrderData;
use App\Http\Controllers\Controller;

class MyController extends Controller
{
    public function index()
    {
        $orderData = OrderData::from([
            'id' => 1,
        ]);

        dd($orderData->toArray()); // ['id' => 1]
    }
}

元のコードを修正するとこうなります。

Purchase.php
use App\Data\OrderData;

class Purchase
{
    /**
     * @param  OrderData  $orderData
     */
    public function get(OrderData $data) // array → OrderData
    {
        // $dataを使って何かをする処理
    }
}

引数の定義を確認するだけでどんな構造でどんな型の値が入っているのか一目で分かりますね。(オブジェクトとして定義されているため、値の有無と型は保証されています。)

OrderData.php
use Spatie\LaravelData\Data;

class OrderData extends Data
{
    public function __construct(
        public int $id,
    ) {
    }
}

php8.1以降であればreadonlyキーワードを付けることで不変性も担保できます。

OrderData.php
use Spatie\LaravelData\Data;

class OrderData extends Data
{
    public function __construct(
        readonly public int $id,
    ) {
    }
}
MyController.php
use App\Data\OrderData;
use App\Http\Controllers\Controller;

class MyController extends Controller
{
    public function index()
    {
        $orderData = OrderData::from([
            'id' => 1,
        ]);

        $orderData->id = 2; // Error: Cannot modify readonly property
    }
}

ユースケース

laravel-dataの嬉しいところはプロパティにバリデーションルールを付与できる点です。

OrderData.php
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\Validation\Rule;

class OrderData extends Data
{
    public function __construct(
        #[Rule('required|integer')]
        readonly public int $id,
        #[Rule('required|in:shipped,pending')]
        readonly public string $ship_status,
        #[Rule('nullable|string|max:250')]
        readonly public string $note,
        #[Rule('required|array')]
        readonly public array $itemes,
    ) {
    }
}

上記は属性(php8.0以降)を使用してバリデーションルールを適用しています。

Laravelのフォームリクエストと同じようにrules()メソッドでバリデーションルールを記述することもできます。

OrderData.php
use Spatie\LaravelData\Data;

class OrderData extends Data
{
    public function __construct(
        readonly public int $id,
        readonly public int $ship_status,
        readonly public string $note,
        readonly public array $items,
    ) {
    }

    public static function rules(): array // 静的メソッドなことに注意
    {
        return [
            'id' => 'required|integer',
            'ship_status' => 'required|in:shipped,pending',
            'note' => 'nullable|string|max:250',
            'items' => 'required|array',
            'items.*' => 'required|integer'
        ];
    }
}

配列のバリデーションについては中身についても考えたい場合はこの書き方がやりやすいです。

リクエストデータのバリデーション

ユースケースとしてリクエストデータのバリデーションを考えます。

コントローラーのメソッドでリクエストデータをバリデーションする場合、フォームリクエストクラスを作成してタイプヒントした後、その値を後続の処理へそのまま渡す、というような状況があると思います。

OrderRequest.php
use Illuminate\Foundation\Http\FormRequest;

class OrderRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules()
    {
        return [
            'id' => 'required|integer',
            'ship_status' => 'required|in:shipped,pending',
            'note' => 'nullable|string|max:250',
            'items' => 'required|array',
            'items.*' => 'required|integer'
        ];
    }
}

MyController.php
use App\Actions\PurchaseAction;
use App\Http\Controllers\Controller;
use App\Http\Requests\OrderRequest;

class MyController extends Controller
{
    public function __construct(
        private PurchaseAction $purchaseAction,
    ) {
    }

    public function index(OrderRequest $request)
    {
        // リクエストの値取得
        $orders = $request->all();

        // 後続の処理へその値を渡す
        $this->purchaseAction->execute($orders);
    }
}
PurchaseAction.php
class PurchaseAction
{
    /**
     * @param  array  $orders
     */
    public function execute(array $orders) // ← 今回問題としている事象が発生
    {
        // $dataを使った何かの処理
    }
}

このOrderRequestOrderDataに差し替えます。
サービスコンテナによって依存注入をしてくれているのでそのままタイプヒントできます。

MyController.php
use App\Actions\PurchaseAction;
use App\Data\OrderData;
use App\Http\Controllers\Controller;

class MyController extends Controller
{
    public function __construct(
        private PurchaseAction $purchaseAction,
    ) {
    }

    public function index(OrderData $data) // OrderDataに差し替え
    {
        // リクエストの値取得の処理は必要なし

        // 後続の処理へそのまま渡す
        $this->purchaseAction->execute($data);
    }
}

PurchaseAction.php
use App\Data\OrderData;

class PurchaseAction
{
    /**
     * @param  OrderData  $data
     */
    public function execute(OrderData $data) // ← 引数で受け取る値が分かりやすい!
    {
        // $dataを使った何かの処理
    }
}

こんな風に「バリデーション」と「値の信用性の保証」の2つを同時に達成することができます!

ちなみに話は変わりますが、JSONのキーについてスネークケースである場合があるかと思います。
そんなときは下のように指定することでキャメルケースに変換して扱うことができるようになります。

OrderData.php
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;

#[MapInputName(SnakeCaseMapper::class)]
class OrderData extends Data
{
    public function __construct(
        public int $id,
        public string $shipStatus, // ship_status → shipStatus
        public string $note,
        public array $items,
    ) {
    }

    public static function rules(): array
    {
        return [
            'id' => 'required|int',
            'ship_status' => 'required|in:shipped,pending',
            'note' => 'nullable|string|max:250',
            'items' => 'required|array',
            'items.*' => 'required|integer'
        ];
    }
}
MyController.php
use App\Data\OrderData;
use App\Http\Controllers\Controller;

class MyController extends Controller
{
    public function index(OrderData $data)
    {
        // キャメルケースでOK
        dd($data->shipStatus) // shipped
    }
}

また、そのままreturnすると自動でJSONに変換してくれたりもします。

MyController.php
use App\Data\OrderData;
use App\Http\Controllers\Controller;

class MyController extends Controller
{
    public function index(OrderData $data)
    {
        return $data;
    }
}
レスポンス
{
    "id": 1,
    "shipStatus": "shipped",
    "note": "test",
    "items": [
        1
    ]
}

という感じでバリデーションについて見てきましたが、
他にもデータをコレクションのように扱うことができたり、
モデルをデータとして扱うことができたり色々できます。

詳しくはドキュメントを参照してください。

まとめ

値の中身を信用できないということはコードを読む負担や修正のコストをかなり増やすことになります。
今回紹介したライブラリ等を活用してなるべく値を完全なものにしたいですね!

最後に

GoQSystemでは一緒に働いてくれる仲間を募集中です!

ご興味がある方は以下リンクよりご確認ください。

10
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
11