症状

managed_file(ファイルアップロード)や #ajax を含む Drupal フォームで、validateForm() に入った瞬間にこんな致命的エラーが出ました。

Typed property ...::$someService must not be accessed before initialization

コンストラクタで注入したサービスが、バリデーション時には初期化されていない。注入は書いてあるのに、消えている。

原因: フォームキャッシュへのシリアライズ

managed_file や #ajax を含むフォームは、リクエストをまたぐためにフォームキャッシュへシリアライズされます。ここで注入サービスの宣言方法が効いてきます。

サービスをコンストラクタ昇格で private にしていました。

class MyForm extends FormBase {
  public function __construct(
    private MyService $myService,   // private だと壊れる
  ) {}
}

FormBase は DependencySerializationTrait を use しています。この trait の __sleep() は、シリアライズ時に親クラスのスコープで get_object_vars($this) を呼んでプロパティを退避します。ここが罠です。

get_object_vars() は呼び出したスコープから見えるプロパティしか返しません。親スコープ(trait 側)からは、サブクラスの private プロパティが不可視です。だから private で宣言した注入サービスは __sleep() の退避対象から漏れ、シリアライズされない。復元(__wakeup)時にも戻らない。結果、validateForm() で「初期化前アクセス」の致命的エラーになります。

対策: protected で宣言する

private を protected に変えます。

class MyForm extends FormBase {
  public function __construct(
    protected MyService $myService,   // protected なら親スコープから見える
  ) {}
}

protected なら親スコープ(trait の __sleep)から可視になるので、退避・再注入の対象になります。シリアライズを跨いでもサービスが生き残る。

readonly も不可です。readonly は復元時の再代入を阻むので、__wakeup がプロパティに書き戻せず、やはり壊れます。「注入サービスは private でも readonly でもなく protected」と覚えるのが早い。

検証: シリアライズ往復で確かめる

「protected にしたから大丈夫」を机上で信じず、シリアライズの往復で確認します。

$restored = unserialize(serialize($form));
$ref = new \ReflectionProperty($restored, 'myService');
// 復元後に初期化されているか
var_dump($ref->isInitialized($restored));   // true になるべき

serialize → unserialize した後に、プロパティが初期化されたままか(ReflectionProperty::isInitialized())を見る。これが true なら、フォームキャッシュを跨いでも壊れません。

どうしても private で持ちたい特殊事情があるなら、独自の __sleep()(return [] でサービスを退避対象から外す)+ __wakeup()(コンテナから取り直す)を書く逃げ道もあります。ただ通常は protected にするのがいちばん素直です。

ついでに踏んだ、同系統の罠

同じ「アップロード付きフォーム」で、Drupal 11 特有の落とし穴を2つ併発したので短く共有します。

#upload_validators は制約プラグイン形式に変わった。 旧来の手続き型 file_validate_extensions / file_validate_size は削除済みで PluginNotFoundException になります。新形式は 'FileExtension' => ['extensions' => $ext] / 'FileSizeLimit' => ['fileLimit' => $bytes]。

外部URLへの 302 は TrustedRedirectResponse。 S3 の presigned URL のような外部URLへ素の RedirectResponse でリダイレクトすると、RedirectResponseSubscriber に「外部URLへのリダイレクトは許可されない」と拒否されます。TrustedRedirectResponse を使う。presigned は毎回変わるので CacheableMetadata で max-age=0 を付けます。

まとめ

  • managed_file / #ajax を含むフォームはフォームキャッシュへシリアライズされる。 注入サービスの宣言方法が効いてくる
  • 注入サービスを private にすると、DependencySerializationTrait::__sleep() の get_object_vars() が親スコープで private を拾えず脱落する。 復元されず「初期化前アクセス」の致命的エラー
  • protected で宣言する。 親スコープから可視になり退避・再注入される。readonly は再代入を阻むので不可
  • unserialize(serialize($form)) 後に ReflectionProperty::isInitialized() で確かめる。 机上で信じない

この記事は、事業・Webサイトの売買プラットフォーム RIKKA M&A を開発する中で得た知見です。RIKKA M&A は GitHub リポジトリを解析する技術デューデリジェンスを備え、Drupal 11 / PHP / LLM で構築・運用しています。

サイト・アプリ・AIプロダクトM&Aのプラットフォーム RIKKA M&A