🕛

Laravelでミリ秒精度の日時を扱うときのTips

2022/10/06に公開

Laravelで開発中に、
ミリ秒で保存できないーーー!!!!
なんか全部2022-10-01 12:34:56.000みたいに小数点以下が0になるーー!!
ってイライラしたのでまとめてみました。

ℹ️ 要約

Eloquentモデルの$casts'datetime:Y-m-d H:i:s.v'を指定するだけだと小数点以下の時間は保存されない。
保存前の日時指定の際に、保存したい書式でフォーマットする必要がある。

  1. マイグレーションファイルで引数の$precisionに小数点以下の精度を指定する。
  2. $dates$dateFormatをモデルに指定する。
    もしくは、
    DateTime型(Carbonインスタンスなど)を保存する際にはフォーマットした値を指定する。

※Laravel v9.34.0 (PHP v8.1.11)・MySQL, PostgreSQLで検証

https://github.com/Ry0xi/laravel-milliseconds

🌀 ミリ秒まで保存したかったけどうまくいかなかった

次の章で説明する、

  1. マイグレーションファイルで引数の$precisionに小数点以下の精度を指定する。

の部分はやっていたのですが、上手く行きませんでした。

その時の状況はこんな感じです。
Userモデルを作成し、日付型のカラムは$castsを指定して自動でCarbonインスタンスに変換してもらいます。

app/Models/User.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    use HasFactory;

    protected $fillable = [
        'name',
        'email',
        'password',
        'first_logged_in_at'
    ];

    protected $hidden = [
        'password',
    ];

    protected $casts = [
        'first_logged_in_at' => 'datetime:Y-m-d H:i:s.v',
        'created_at' => 'datetime:Y-m-d H:i:s.v',
        'updated_at' => 'datetime:Y-m-d H:i:s.v',
    ];
}
マイグレーションファイル(後で説明あり)
database/migrations/2014_10_12_000000_create_users_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->string('password');
            // precisionを指定すると、保存する時間の精度を指定できる
            $table->timestamps(3);
            $table->dateTime('first_logged_in_at', 3);
        });
    }

    public function down()
    {
        Schema::dropIfExists('users');
    }
};

簡単にUserモデルを作成できるようにUserFactoryモデルを作成しました。

database/factories/UserFactory.php
<?php

namespace Database\Factories;

use App\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<User>
 */
class UserFactory extends Factory
{
    public function definition(): array {
        return [
            'name' => fake()->name(),
            'email' => fake()->unique()->safeEmail(),
            'password' => 'password',
            'first_logged_in_at' => Carbon::now() // <- Carbonインスタンスを作成
        ];
    }
}

今回注目するべきは、UserFactoryのCarbon::now()の部分です。

Laravelはデフォルトで、Carbonインスタンスを指定したら自動で日付文字列に変換してデータベースに保存してくれます。

Userモデルで$casts = ['first_logged_in_at' => 'datetime:Y-m-d H:i:s.v'];を指定しているので、2022-10-01 12:34:56.789のように年-月-日 時間:分:秒.ミリ秒の形式でフォーマットしてくれるはずだと思っていました。

https://readouble.com/laravel/9.x/ja/eloquent-mutators.html#date-casting

https://www.php.net/manual/ja/datetime.format.php


tinkerで試してみます。

% docker compose exec laravel.test php artisan tinker 
Psy Shell v0.11.8 (PHP 8.1.11 — cli) by Justin Hileman

>>> $user = User::factory()->make();
=> App\Models\User {#4668
     name: "Gus Jacobs",
     email: "tierra92@example.net",
     #password: "password",
     first_logged_in_at: Carbon\Carbon @1664975793 {#3728
       date: 2022-10-05 13:16:33.767047 UTC (+00:00),
     },
   }

>>> $user->save();
=> true

>>> $user
=> App\Models\User {#4668
     name: "Gus Jacobs",
     email: "tierra92@example.net",
     #password: "password",
     first_logged_in_at: Carbon\Carbon @1664975793 {#3728
       date: 2022-10-05 13:16:33.767047 UTC (+00:00),
     },
     updated_at: "2022-10-05 13:16:49",
     created_at: "2022-10-05 13:16:49",
     id: 1,
   }

>>> $savedUser = User::find(1);
=> App\Models\User {#3727
     id: 1,
     name: "Gus Jacobs",
     email: "tierra92@example.net",
     #password: "password",
     created_at: "2022-10-05 13:16:49.000",
     updated_at: "2022-10-05 13:16:49.000",
     first_logged_in_at: "2022-10-05 13:16:33.000",
   }

>>> $user->first_logged_in_at;
=> Illuminate\Support\Carbon @1664975793 {#3689
     date: 2022-10-05 13:16:33.767047 UTC (+00:00),
   }
>>> $user->first_logged_in_at->format('Y-m-d H:i:s.v');
=> "2022-10-05 13:16:33.767"

>>> $savedUser->first_logged_in_at->format('Y-m-d H:i:s.v');
=> "2022-10-05 13:16:33.000"

なんと、保存前($user->first_logged_in_at)はミリ秒の値があったのに、保存後($savedUser->first_logged_in_at)はミリ秒のデータがなくなってしまっています!!

💡対処法

以下を順番にやっていくと小数点以下の精度をDBに保存して扱えます。

  1. マイグレーションファイルで引数の$precisionに小数点以下の精度を指定する。
  2. $dates$dateFormatをモデルに指定する。
    もしくは、
    DateTime型(Carbonインスタンスなど)を保存する際にはフォーマットした値を指定する。

1️⃣ マイグレーションファイルで$precisionに小数点以下の精度を指定する

以下は今回のマイグレーションファイルです。timestampsdateTimeの引数の$precisionに小数点以下の桁数を指定します。今回はミリ秒なので3を指定しています。

database/migrations/2014_10_12_000000_create_users_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->string('password');
            // precisionを指定すると、保存する時間の精度を指定できる
            $table->timestamps(3);
            $table->dateTime('first_logged_in_at', 3);
        });
    }

    public function down()
    {
        Schema::dropIfExists('users');
    }
};

migrationの実行後にテーブルスキーマを見てみるとこんな感じになっています。
ちゃんとtimestamp(3), datetime(3)となっています。

mysql> describe users;
+--------------------+-----------------+------+-----+---------+----------------+
| Field              | Type            | Null | Key | Default | Extra          |
+--------------------+-----------------+------+-----+---------+----------------+
| id                 | bigint unsigned | NO   | PRI | NULL    | auto_increment |
| name               | varchar(255)    | NO   |     | NULL    |                |
| email              | varchar(255)    | NO   | UNI | NULL    |                |
| password           | varchar(255)    | NO   |     | NULL    |                |
| created_at         | timestamp(3)    | YES  |     | NULL    |                |
| updated_at         | timestamp(3)    | YES  |     | NULL    |                |
| first_logged_in_at | datetime(3)     | NO   |     | NULL    |                |
+--------------------+-----------------+------+-----+---------+----------------+
7 rows in set (0.00 sec)

もし$precisionを指定しない場合はこんなふうになっています。

mysql> describe users;
+--------------------+-----------------+------+-----+---------+----------------+
| Field              | Type            | Null | Key | Default | Extra          |
+--------------------+-----------------+------+-----+---------+----------------+
| id                 | bigint unsigned | NO   | PRI | NULL    | auto_increment |
| name               | varchar(255)    | NO   |     | NULL    |                |
| email              | varchar(255)    | NO   | UNI | NULL    |                |
| password           | varchar(255)    | NO   |     | NULL    |                |
| created_at         | timestamp       | YES  |     | NULL    |                |
| updated_at         | timestamp       | YES  |     | NULL    |                |
| first_logged_in_at | datetime        | NO   |     | NULL    |                |
+--------------------+-----------------+------+-----+---------+----------------+
7 rows in set (0.00 sec)

https://readouble.com/laravel/9.x/ja/migrations.html#column-method-timestamp

2️⃣ 保存時はFormatした値を指定する

対処法1:日時の指定時に日付文字列にフォーマット

対処する1つの方法は、日時の指定時に日付文字列にフォーマットすることです。

UserFactoryを以下のように変更することでうまくいきます。

database/factories/UserFactory.php
 public function definition(): array {
     return [
         'name' => fake()->name(),
         'email' => fake()->unique()->safeEmail(),
         'password' => 'password',
- 	 'first_logged_in_at' => Carbon::now() // <- Carbonインスタンスを作成
+        'first_logged_in_at' => Carbon::now()->format('Y-m-d H:i:s.v') // <- 日付文字列にフォーマットする
     ];
 }

tinkerで試してみます。

>>> $user = User::factory()->make();
=> App\Models\User {#3718
     name: "Mr. Camryn Zulauf",
     email: "llang@example.com",
     #password: "password",
     first_logged_in_at: "2022-10-05 13:24:58.038",
   }

>>> $user->save();
=> true

>>> $user
=> App\Models\User {#3718
     name: "Mr. Camryn Zulauf",
     email: "llang@example.com",
     #password: "password",
     first_logged_in_at: "2022-10-05 13:24:58.038",
     updated_at: "2022-10-05 13:25:09",
     created_at: "2022-10-05 13:25:09",
     id: 1,
   }

>>> $savedUser = User::find(1);
=> App\Models\User {#4400
     id: 1,
     name: "Mr. Camryn Zulauf",
     email: "llang@example.com",
     #password: "password",
     created_at: "2022-10-05 13:25:09.000",
     updated_at: "2022-10-05 13:25:09.000",
     first_logged_in_at: "2022-10-05 13:24:58.038",
   }
>>> $user->first_logged_in_at->format('Y-m-d H:i:s.v');
=> "2022-10-05 13:24:58.038"

>>> $savedUser->first_logged_in_at->format('Y-m-d H:i:s.v');
=> "2022-10-05 13:24:58.038"

ばっちり揃いました!ちゃんと保存されていることが確かめられましたね。

対処法2:Eloquentモデルに$dateFormat, $datesを指定する

対処法1だと、毎回日時の指定時にフォーマットするのは面倒ですよね。

Eloquentモデルに$dateFormat, $datesを指定することで、毎回自分でフォーマットを行う必要がなくなります。

これはハマりポイントですが、Laravel9.xのドキュメントには

データベース内にモデルの日付を実際に保存するときに使用する形式を指定するには、モデルに$dateFormatプロパティを定義する必要があります。

と書かれていましたが、記載されていない$datesも指定しないと、日付の属性と認識してくれずに上手くいかないです。(なんで書かないんだ…)

Laravelの実装コードを読んでいって判明しました。

https://readouble.com/laravel/9.x/ja/eloquent-mutators.html#custom-casts:~:text=データベース内にモデルの日付を実際に保存するときに使用する形式を指定するには、モデルに%24dateFormatプロパティを定義する必要があります。

モデルに以下を追記して再度保存処理を検証します。

app/Models/User.php
/**
 * モデルの日付カラムのストレージ形式
 *
 * @var string
 */
protected $dateFormat = 'Y-m-d H:i:s.v';

/**
 * モデルの日付カラムを指定する。
 * ここに指定されたものが保存時に$dateFormatで保存される。
 * そのため、保存する際の指定でCarbonインスタンスのまま保存しても小数点以下まで保存できる。
 *
 * @var string[]
 */
protected $dates = [
    'first_logged_in_at',
    'created_at',
    'updated_at',
];

UserFactoryではフォーマットをしません。

database/factories/UserFactory.php
 public function definition(): array {
     return [
         'name' => fake()->name(),
         'email' => fake()->unique()->safeEmail(),
         'password' => 'password',
-        'first_logged_in_at' => Carbon::now()->format('Y-m-d H:i:s.v') // <- 日付文字列にフォーマットする
+        'first_logged_in_at' => Carbon::now() // <- Carbonインスタンスのまま
     ];
 }

tinkerで試してみます。

% docker compose exec laravel.test php artisan tinker 
Psy Shell v0.11.8 (PHP 8.1.11 — cli) by Justin Hileman

>>> $user = User::factory()->make();
=> App\Models\User {#4658
     name: "Wilber Fadel",
     email: "xpfannerstill@example.net",
     #password: "password",
     first_logged_in_at: "2022-10-05 14:13:31.809",
   }

>>> $user->save();
=> true

>>> $user
=> App\Models\User {#4658
     name: "Wilber Fadel",
     email: "xpfannerstill@example.net",
     #password: "password",
     first_logged_in_at: "2022-10-05 14:13:31.809",
     updated_at: "2022-10-05 14:13:44.511",
     created_at: "2022-10-05 14:13:44.511",
     id: 1,
   }
>>> $savedUser = User::find(1);
=> App\Models\User {#3989
     id: 1,
     name: "Wilber Fadel",
     email: "xpfannerstill@example.net",
     #password: "password",
     created_at: "2022-10-05 14:13:44.511",
     updated_at: "2022-10-05 14:13:44.511",
     first_logged_in_at: "2022-10-05 14:13:31.809",
   }

上手くいっています。ちなみに、タイムスタンプのcreated_atupdated_atもフォーマットを指定していないですが、小数点以下まで保存されています。

📝 まとめ

普通にCarbonインスタンスから保存する書式にフォーマットすれば、小数点以下も保存できる。(毎回自分でフォーマットするのが面倒・忘れそう

app/Models/User.php
protected $casts = [
    'first_logged_in_at' => 'datetime:Y-m-d H:i:s.v',
];
// これだとミリ秒まで保存されない
User::factory(['first_logged_in_at' => Carbon::now()])->create();

// 自分でフォーマットしたらミリ秒まで保存される
User::factory(['first_logged_in_at' => Carbon::now()->format('Y-m-d H:i:s.v')])->create();

モデルに$dateFormat = 'Y-m-d H:i:s.v';$dates = ['first_logged_in_at']; を指定した場合はCarbonインスタンスを指定するだけで$dateFormatの書式で保存できる。

// 自分でフォーマットしなくてもミリ秒まで保存できる
User::factory(['first_logged_in_at' => Carbon::now()])->create();
株式会社ゆめみ

Discussion