はじめに
PHP(Laravel)ではGoやTypeScript等とは違い配列やコレクションの「中身の構造や型」を定義する術がありません。
こんな現象に見舞われたことはないでしょうか?
class Purchase
{
/**
* @param array $data
*/
public function get(array $data) // ← ?
{
// $dataを使って何かをする処理
}
}
「$data
...?」
これは$data
の命名が抽象的すぎて何を表してるのか分からない、という話ではなく、array
と型定義されてはいるもののその中身がどうなっているのか(どういう構造をしているのか)全くわからない、という問題です。
仮に引数の名前を分かりやすく、さらにアノテーションを詳しくしてみることにします。
class Purchase
{
/**
* @param array<int> $orders
*/
public function get(array $orders)
{
// $ordersを使って何かをする処理
}
}
なるほどこれなら「$orders
という配列の中に数値が入っているということは、注文のIDかなにかが複数入っているのか」という風に理解できるかと思います。
ただこのarray<int>
はあくまで注釈であって、「この配列の中身は全て数値である」ということを保証するものではないです。
つまり数値ではない可能性も全然あって、呼び出し元を確認してどんな情報が飛んできているのか確認し、さらに中身をデバッグして本当に数値だけなのか、といったことを検証する必要があります。
また配列の中身が数値だけではなく商品の情報やメタ情報なんかを含んだ重厚な構造をしているおそれもあり、
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
だとこんな風になります。
use Spatie\LaravelData\Data;
class OrderData extends Data
{
public function __construct(
public int $id,
) {
}
}
from
メソッドで元の値からオブジェクトを生成します。
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()
メソッドで元の配列に変換もできるので配列として扱いたい場合も問題なしです。
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]
}
}
元のコードを修正するとこうなります。
use App\Data\OrderData;
class Purchase
{
/**
* @param OrderData $orderData
*/
public function get(OrderData $data) // array → OrderData
{
// $dataを使って何かをする処理
}
}
引数の定義を確認するだけでどんな構造でどんな型の値が入っているのか一目で分かりますね。(オブジェクトとして定義されているため、値の有無と型は保証されています。)
use Spatie\LaravelData\Data;
class OrderData extends Data
{
public function __construct(
public int $id,
) {
}
}
php8.1以降であればreadonly
キーワードを付けることで不変性も担保できます。
use Spatie\LaravelData\Data;
class OrderData extends Data
{
public function __construct(
readonly public int $id,
) {
}
}
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の嬉しいところはプロパティにバリデーションルールを付与できる点です。
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()
メソッドでバリデーションルールを記述することもできます。
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'
];
}
}
配列のバリデーションについては中身についても考えたい場合はこの書き方がやりやすいです。
リクエストデータのバリデーション
ユースケースとしてリクエストデータのバリデーションを考えます。
コントローラーのメソッドでリクエストデータをバリデーションする場合、フォームリクエストクラスを作成してタイプヒントした後、その値を後続の処理へそのまま渡す、というような状況があると思います。
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'
];
}
}
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);
}
}
class PurchaseAction
{
/**
* @param array $orders
*/
public function execute(array $orders) // ← 今回問題としている事象が発生
{
// $dataを使った何かの処理
}
}
このOrderRequest
をOrderData
に差し替えます。
サービスコンテナによって依存注入をしてくれているのでそのままタイプヒントできます。
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);
}
}
use App\Data\OrderData;
class PurchaseAction
{
/**
* @param OrderData $data
*/
public function execute(OrderData $data) // ← 引数で受け取る値が分かりやすい!
{
// $dataを使った何かの処理
}
}
こんな風に「バリデーション」と「値の信用性の保証」の2つを同時に達成することができます!
ちなみに話は変わりますが、JSONのキーについてスネークケースである場合があるかと思います。
そんなときは下のように指定することでキャメルケースに変換して扱うことができるようになります。
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'
];
}
}
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に変換してくれたりもします。
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では一緒に働いてくれる仲間を募集中です!
ご興味がある方は以下リンクよりご確認ください。