関連記事の記事IDを1件差し替えるだけの、単純な作業のはずでした。
この記事のrelated_articles(ACFの選択式フィールド)は、選んだ記事IDの配列がシリアライズされて保存される設定でした。既存の記事から生データをコピーして、wp post meta update <ID> related_articles 'a:2:{i:0;s:3:"755";i:1;s:3:"701";}'のようにそのまま貼り付けて更新しました。ところが記事ページを開くと、Warning: foreach() argument must be of type array|object, string givenが表示されてしまいました。
get_field()が配列でなく文字列を返していた
まずは事実確認です。管理画面でACFフィールドの中身を見ると、一見それらしい値が入っています。ただテーマ側のget_field('related_articles')をvar_dump()してみたら、配列を期待していたのに、返ってきたのは文字列でした。
しかもその文字列の中身は、さっき自分が貼り付けたはずのa:2:{i:0;s:3:"755";i:1;s:3:"701";}そのままだったんです。
WordPress coreの「親切」が裏目に出ていた
調べていくと、原因はWordPress core側のmaybe_serialize()にありました。このメソッドは、渡された値がis_serialized()の判定で「もうシリアライズ済みの文字列に見える」場合、後方互換性のためにあえてもう一度serialize()をかける仕様になっています。実際に手元のPHPで再現したところ、確かにそうなりました。
自分が更新用に渡したa:2:{i:0;s:3:"755";i:1;s:3:"701";}という文字列は、PHP配列ではなく、すでに「シリアライズ済みの形式に見える文字列」でした。is_serialized()がこれを検知し、maybe_serialize()がその文字列自体をもう一度serialize()にかけてしまいます。結果、DBにはs:34:"a:2:{...}";のような、文字列としてシリアライズされたデータが入れ子になって保存されていました。
get_field()はこの二重シリアライズまでは想定しておらず、1回だけアンシリアライズした結果(まだシリアライズされたままの文字列)をそのまま返します。テーマ側は配列が来る前提でforeachを回すので、文字列を渡されてWarningになっていた、という流れでした。
直し方は、JSON配列で渡すだけ
対処はシンプルでした。wp post meta update <ID> related_articles '["755","701"]' --format=jsonのように、値をJSON配列で渡します。wp-cli側が--format=jsonを見てJSONをPHP配列にデコードしてからupdate_post_meta()に渡してくれるので、そこで初めて正しく1回だけシリアライズされます。
更新後にget_field()を確認したら、ちゃんと配列が返ってきました。Warningも消えました。
同じミスを別の記事でもやっていた
ここで気になって、他の記事のrelated_articlesも一通り確認してみました。すると755番の記事でも、過去に同じやり方(生のシリアライズ文字列を直接貼り付ける方法)で更新していたことが分かり、同じ二重シリアライズが起きていました。こちらもJSON配列での更新に直して解消しました。
まとめ
ACFのシリアライズ配列フィールドをwp-cliで直接触るときは、既存の生データをそのままコピーして使いたくなりますが、そこに落とし穴がありました。
wp post meta updateで配列っぽい値を渡すときは、渡す値の形式に一手間。生の文字列を貼り付けず、--format=json付きのJSON配列にする。これだけ覚えておけば、同じ事故は防げそうです。