題の通りです。これは MySQL の値としての null と JSON としての null の二種類が存在すること、Laravelに自動で JSON をでコードするようにできる機能があることの二つが原因で起きる現象です。
まず MySQL の not null 制約の中で JSON としての null を格納する方法を説明します。これの具体例として次があげられます。
-- auto-generated definition
create table jsons
(
json_data json not null,
json_nullable json
);
insert into jsons(json_data) values('null'); -- JSONとして解釈できる null と null そのものを格納
insert into jsons(json_data, json_nullable) values('{"a": 1}', '{"b": 2}'); -- nullでないJSONの例
select * from jsons;
-- json_data json_nullable
-- null NULL
-- {"a": 1} {"b": 2}
’null’を保存できます。これは null を JSON としてエンコードした際に得られる文字列であり、MySQL は JSON があるので null でないとして”null”を保存します。
また Laravel の Eloquent にはキャストという機能があり、これはデータベースの特定のカラムから値を読み取る時、自動で型変換をする機能です。これを JSON 型にかけると次の様になります。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Json extends Model
{
public $table = 'jsons';
protected $casts = [
'json_data' => 'json',
];
}
// どこかで
$res=\App\Models\Json::query()->first()->toArray();
dd($res);
// array:3 [▼
// "json_data" => null
// "json_nullable" => null
// ]
このキャスト機能の json 指定では自動で JSON をパースし、パース結果の値をプロパティにできます。これを先ほどの null を元にした Json の値をパースした所 null が返ってきました。not null 制約が付いたカラムの値が null を返してきたわけです。
どちらも便利な機能ですが、組み合わせると厄介な性質が現れます。うまいことバリデーションなりなんなりで対処して、そもそも JSON としての null も保存できない様にした方が無難です。