🕘

Laravel の JsonResource 内の Carbon が UCT で JSON に出力される件

2022/08/10に公開

コントローラが Model を return した場合

こんなモデルがあったとして、

<?php
/**
 * ユーザマスタ
 *
 * @property integer $user_id
 * @property string $user_name
 * @property string $email
 * @property string $password
 * @property \Illuminate\Support\Carbon $register_datetime
 * @property \Illuminate\Support\Carbon $created_at
 * @property \Illuminate\Support\Carbon $updated_at
 * @property \Illuminate\Support\Carbon $deleted_at
 */
class User extends Model implements
    AuthenticatableContract,
    AuthorizableContract,
    CanResetPasswordContract
{
    use Authenticatable, Authorizable, CanResetPassword, MustVerifyEmail, SoftDelete;

    protected $table = "m_users";
    protected $primaryKey = 'user_id';

    protected $dates = [
        'register_datetime',
        'created_at',
        'updated_at',
    ];

    protected $casts = [
        'user_id' => 'integer',
        'register_datetime' => 'date:Y-m-d H:i:s',
        'created_at' => 'date:Y-m-d H:i:s',
        'updated_at' => 'date:Y-m-d H:i:s',
        'deleted_at' => 'date:Y-m-d H:i:s',
    ];
}

コントローラが、以下のようにモデルを直接返却すると、

class MeController extends Controller
{
    public function __invoke() : User
    {
        /** @var User $user */
        $user = Auth::user();

        return $user;
    }
}

レスポンスはこうなります。

{
    "user_id": 1,
    "user_name": "Roku",
    "email": "roku@example.com",
    "password": "$2y$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "register_datetime": "2022-07-31T23:00:00.000000Z",
    "created_at": "2022-07-31T23:00:00.000000Z",
    "updated_at": "2022-07-31T23:00:00.000000Z",
    "deleted_by": null
}

なお、DB に格納されている register_datetime その他の値は
2022-08-01 08:00:00 です。
つまり、UCT で返っています。

Laravel の config/app.php の timezone は、
Asia/Tokyo になっているものとします。

後述の通り、この場合、Model::toJson() がコールされます。
Model::toJson() から追いかけていくと、
Model が配列化されていく過程で、Dates プロパティについては、
HasAttributes トレイトの serializeDate() で変換されていることがわかります。

protected function serializeDate(DateTimeInterface $date)
{
    return $date instanceof \DateTimeImmutable ?
        CarbonImmutable::instance($date)->toJSON() :
        Carbon::instance($date)->toJSON();
}

ところが、Carbon::toJson() はタイムゾーンを考慮してくれないため、
Laravel の設定に反して UTC になってしまいます。

APIのレスポンスを JST にしたいなら、
例えば Model で上記をオーバーライドして、

protected function serializeDate(DateTimeInterface $date)
{
    return $date->format('Y-m-d H:i:s');
}

等とする方法があります。

コントローラが JsonResource を return した場合

一方、Controller が以下のように JsonResource を返している場合、

class MeController extends Controller
{
    public function __invoke() : User
    {
        /** @var User $user */
        $user = Auth::user();

        return new UserResource($user);
    }
}

class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'user_id' => $this->user_id,
            'user_name' => $this->user_name,
            'email' => $this->email,
            'register_datetime' => $this->register_datetime,
        ];
    }
}

それでもレスポンスはこうなります。

{
    "user_id": 1,
    "user_name": "Roku",
    "email": "roku@example.com",
    "register_datetime": "2022-07-31T23:00:00.000000Z",
}

JsonResource を追いかけてみると、

public function jsonSerialize()
{
    return $this->resolve(Container::getInstance()->make('request'));
}
public function resolve($request = null)
{
    $data = $this->toArray(
        $request = $request ?: Container::getInstance()->make('request')
    );

    if ($data instanceof Arrayable) {
        $data = $data->toArray();
    } elseif ($data instanceof JsonSerializable) {
        $data = $data->jsonSerialize();
    }

    return $this->filter((array) $data);
}
protected function filter($data)
{
    $index = -1;

    foreach ($data as $key => $value) {
        $index++;

        if (is_array($value)) {
            $data[$key] = $this->filter($value);

            continue;
        }

        if (is_numeric($key) && $value instanceof MergeValue) {
            return $this->mergeData(
                $data, $index, $this->filter($value->data),
                array_values($value->data) === $value->data
            );
        }

        if ($value instanceof self && is_null($value->resource)) {
            $data[$key] = null;
        }
    }

    return $this->removeMissingValues($data);
}

Carbon については、何ら例外的な処理を行わないまま、
そのまま json_encode されています。

Carbon::jsonSerialize() を追いかけてみると、
Carbon::serializeUsing() によって、
Carbon に独自のシリアライズ方式を定義することもできるようですが、
このメソッドは deprecated になっています。

そこで、JsonResource によって生成されるレスポンスをJSTにするには、

class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'user_id' => $this->user_id,
            'user_name' => $this->user_name,
            'email' => $this->email,
            'register_datetime' => $this->register_datetime->format('Y-m-d H:i:s'),
        ];
    }
}

のように、個々の Resource クラスで変換するか、

class BaseResource extends JsonResource
{
    protected function filter($data)
    {
        $data = parent::filter($data);

        foreach ($data as $key => $value) {
            if ($value instanceof CarbonInterface) {
                $data[$key] = $value->format('Y-m-d H:i:s');
            } else {
                $data[$key] = $value;
            }
        }

        return $data;
    }
}

JsonResource::resolve() なり JsonResource::filter() なりを、
オーバーライドすることになりそうです。

補足1 JsonSerializable インターフェイス

例えば以下のような Entity チックなクラスのオブジェクトを
json_encode した場合、

<?php

class User
{
    private string $user_name;
    private string $email;

    public function setUserName(string $user_name)
    {
        $this->user_name = $user_name;
    }

    public function setEmail(string $email)
    {
        $this->email = $email;
    }
}

$user = new User();
$user->setUserName('Roku');
$user->setEmail('roku@example.com');

echo json_encode($user, JSON_PRETTY_PRINT);

{}

private なプロパティは、JSONとして出力されません。
また、public なプロパティも、全てそのまま出力されます。

以下のように、JsonSerializable インターフェイスを実装すれば、
json_encode() された場合の挙動を変更することができます。

<?php

class User implements JsonSerializable
{
    private string $user_name;
    private string $email;

    public function setUserName(string $user_name)
    {
        $this->user_name = $user_name;
    }

    public function setEmail(string $email)
    {
        $this->email = $email;
    }

    public function jsonSerialize()
    {
        return [
            'user_name' => $this->user_name,
            'email' => $this->email,
        ];
    }
}

$user = new User();
$user->setUserName('Roku');
$user->setEmail('roku@example.com');

echo json_encode($user, JSON_PRETTY_PRINT);

{
    "user_name": "Roku",
    "email": "roku@example.com"
}

なお、Laravel のソース内では、以下のように、
JsonSerializable であれば、jsonSerialize() を呼んでから json_encode する、
という実装が散見されますが、

if ($data instanceof JsonSerializable) {
    $json = json_encode($data->jsonSerialize());
}

本来 JsonSerializable::jsonSerialize() は、
json_encode() で内部的に呼ばれるので、
それ以外の条件に優先する必要がなければ、上記のような実装は不要です。

補足2 コントローラから return された値の行方

コントローラから return された値は、
Illuminate\Routing\Router::toResponse() で
Response に変換されます。

public static function toResponse($request, $response)
{
    if ($response instanceof Responsable) {
        $response = $response->toResponse($request);
    }

    if ($response instanceof PsrResponseInterface) {
        $response = (new HttpFoundationFactory)->createResponse($response);
    } elseif ($response instanceof Model && $response->wasRecentlyCreated) {
        $response = new JsonResponse($response, 201);
    } elseif ($response instanceof Stringable) {
        $response = new Response($response->__toString(), 200, ['Content-Type' => 'text/html']);
    } elseif (! $response instanceof SymfonyResponse &&
                ($response instanceof Arrayable ||
                $response instanceof Jsonable ||
                $response instanceof ArrayObject ||
                $response instanceof JsonSerializable ||
                $response instanceof \stdClass ||
                is_array($response))) {
        $response = new JsonResponse($response);
    } elseif (! $response instanceof SymfonyResponse) {
        $response = new Response($response, 200, ['Content-Type' => 'text/html']);
    }

    if ($response->getStatusCode() === Response::HTTP_NOT_MODIFIED) {
        $response->setNotModified();
    }

    return $response->prepare($request);
}

コントローラ が JsonResource を return した場合、
JsonResource は Responsable であるため、
JsonResource::toResponse() がコールされます。

その先の道のりもまだ長いですが、
JsonResource の諸々のプロパティを配列化した後、
Illuminate\Http\JsonResponse (以下 JsonResponse) に変換されています。

一方、コントローラ が Model を return した場合、
上記1つめの elseif 節で JsonResponse に変換されています。

そして最終的に JsonResponse::setData() で、

public function setData($data = [])
{
    $this->original = $data;

    if ($data instanceof Jsonable) {
        $this->data = $data->toJson($this->encodingOptions);
    } elseif ($data instanceof JsonSerializable) {
        $this->data = json_encode($data->jsonSerialize(), $this->encodingOptions);
    } elseif ($data instanceof Arrayable) {
        $this->data = json_encode($data->toArray(), $this->encodingOptions);
    } else {
        $this->data = json_encode($data, $this->encodingOptions);
    }

    if (! $this->hasValidJson(json_last_error())) {
        throw new InvalidArgumentException(json_last_error_msg());
    }

    return $this->update();
}

JsonResource の場合は、前処理で既に配列に変換されているため、
else 節に入り、そのまま json_encode されます。
Model は Jsonable なので、Model::toJson() が呼ばれることになります。

Discussion