前提
Drupal で秘密情報(APIキー、SMTP認証、暗号化キー)を扱うとき、定石は「yml には空で置き、settings.php の $config[...] で env から上書きする」です。git に秘密が乗らず、環境ごとに値を差し替えられます。
// settings.php ―― yml 側は '' にしておき、ここで env から入れる
$config['some_module.settings']['api_key'] = getenv('SOME_API_KEY');
この上書き(config override)には落とし穴が複数あります。dev の SMTP を本番の SES から Mailpit に切り替えようとしたとき、私は下記を全部踏みました。「上書きしたのに効かない」が3つの別々の理由で起きます。
罠1: 空文字・空配列は上書きにならないことがある
いちばん最初に踏むのがこれです。
$config['some_module.settings']['api_key'] = ''; // 効かないことがある
Drupal の config override は、最終的に NestedArray::mergeDeepArray() で元の config にマージされます。このマージで、空の値が「無い」と見なされて無視されることがあります。特に Config Entity の configuration プロパティのような入れ子で顕著です。
結果として、「無効化したくて空文字を入れたのに、元の値が生き残る」という、直感と逆のことが起きます。秘密を消したつもりで消えていない、という形で踏むと危険です。
対策は、意味のあるダミー値を入れることです。
$config['some_module.settings']['api_key'] = 'disabled'; // 空にせず明示的な値
「空にする」ではなく「無効を意味する値で上書きする」に発想を変えます。
罠2: Config Entity は simple config と挙動が違う
同じ $config[name][key] の書き方でも、対象が simple config か Config Entity かで挙動が変わります。
Mailpit 切り替えのとき、SMTP の host は上書きが効いたのに、user と pass だけ反映されませんでした。同じ config の同じ階層を触っているのに、一部のフィールドだけ override が素通りします。
原因は、Config Entity がプラグインの defaultConfiguration() を + 演算子で合成する経路を持っていて、そこで override 後の値が既定値に戻されることがあるためです。simple config はそのまま反映されるので、同じ感覚で書くとハマります。
これは机上で判断できません。どこで値が落ちているかを実測で切り分けます。
// active config の生データ(override 前の実体)
\Drupal::config('some_module.settings')->getRawData();
// hydrate 後のエンティティが実際に持っている値
$entity->get('configuration');
この2つを比べると、「生データには override が乗っているが、エンティティにした瞬間に消える」といった落ち方が見えます。どちらで消えているかで対処が変わります。
罠3: settings.local.php の include 位置
これがいちばん質が悪く、SES のバウンス事故の真因でした。
settings.php は途中で settings.local.php を include します。問題は、include したあとにも settings.php の残りのコードが実行されることです。
// settings.php
if (file_exists(__DIR__ . '/settings.local.php')) {
include __DIR__ . '/settings.local.php'; // dev の Mailpit 設定はここで入る
}
// ... さらに下 ...
// これが local の設定を「本番の値」に戻していた
$config['symfony_mailer.mailer_transport.smtp']['configuration']['user'] = getenv('SMTP_USERNAME');
settings.local.php で Mailpit に向けたのに、その下にある env 由来の上書きが、無条件に本番の SMTP 認証へ戻していました。dev のつもりで本番の SES を掴んでいた、という状態です。メールのバウンスで初めて気づきました。
対策は、env 由来の上書きを環境で条件分岐することです。
$env = getenv('DRUPAL_SITE_ENV');
if ($env === 'pro' && getenv('SMTP_USERNAME')) {
$config['symfony_mailer.mailer_transport.smtp']['configuration']['user'] = getenv('SMTP_USERNAME');
$config['symfony_mailer.mailer_transport.smtp']['configuration']['pass'] = getenv('SMTP_PASSWORD');
}
本番でだけ本番の認証を入れる。dev では settings.local.php の Mailpit 設定が最後まで生き残ります。あるいは、settings.local.php の include を settings.php の末尾に置いて、後続コードに戻されないようにする手もあります。
補足すると、この「include より後に置いたコードが勝つ」性質は、逆に使えば正しく効きます。テスト用に trusted_host_patterns を足すときは、settings.local.php が代入で上書きするより後に追記することで、意図どおり残せます。順序を意識するかどうかだけの違いです。
罠4(おまけ): symfony_mailer だとメール config の上書きが丸ごと無視される
Drupal でメールを止めるとき、素朴には system.mail.interface.default を test_mail_collector に上書きします。symfony_mailer を有効にしていると、これは完全に無視されます。
symfony_mailer は plugin.manager.mail を MailManagerReplacement に差し替えていて、送信は独自の EmailFactory → mailer_transport エンティティ経由で行われます。従来のメール interface config は経路上に無いので、いくら上書きしても届きません。
止めたいなら mailer_transport エンティティ側を書き換えるか、symfony_mailer をモジュールごと無効化します。「メールの止め方」がモジュールの有無で変わる、という一段メタな落とし穴です。
まとめ
- 空文字での上書きは効かないことがある。
NestedArrayのマージで空が無視される。無効化は意味のあるダミー値で行う - Config Entity は simple config と挙動が違う。 一部フィールドだけ override が素通りする。
getRawData()(生)と$entity->get()(hydrate後)を比べてどこで落ちるか切り分ける settings.local.phpの include より後のコードが勝つ。 env 由来の上書きは環境で条件分岐しないと、dev が本番の値を掴む- symfony_mailer 有効時はメール interface config の上書きが無視される。 送信経路が別なので
mailer_transportを触る
config override は「書けば効く」ように見えて、効かない理由が複数あります。「上書きしたのに反映されない」ときは、この4つのどれを踏んでいるかを先に切り分けると速いです。
この記事は、事業・Webサイトの売買プラットフォーム RIKKA M&A を開発する中で得た知見です。RIKKA M&A は GitHub リポジトリを解析する技術デューデリジェンスを備え、Drupal 11 / PHP / LLM で構築・運用しています。

