前回の記事:AIでAIによるAIのためのフレームワークを作り、AIで爆速開発するというお話 にも書きましたが、最近、AI様のためのフレームワーク(Claude様が Lattice という名前をつけてくれたので以下 Lattice と呼びます)を作る事にハマっています。
AI様がバグを出す度に、「なぜそのバグは生まれたのか?」を2人(Claude Opus 4.6 Thinking, Gemini 3.1 Pro)と徹底討論し、原因がCoreの設計によるものならそれを修正し、SKILL.md と crud.md に反映させ…を延々と繰り返す…。※ただしコードの9割程度はClaude様が書いてる。
まるで老舗鰻屋さんの秘伝タレのごとく…継ぎ足し継ぎ足し…最近では、Claude様がバグを出す事もほぼ無くなってきました。
Claude様がバグを出した
セッションを使ってフラッシュメッセージを扱う処理で、久しぶりにClaude様がバグを出しました。
- ぼく:
Latticeが、あなた(AI)にとって使いやすいか?あなたがバグを出さないか?という「AIフレンドリーさ」を徹底的に追求して作ったものなのは、あなたも分かってるよね? あなたを責める気はないので、今回のバグが出た原因を正直に教えて。 - Claude様:大変申し訳ございません!
SKILL.mdには◯◯と書いてあるのに、見落としてました!(意訳) - ぼく:大変申し訳ございません!それは私の設計が悪いんでございます!どうすれば改善されるか案をお出しくだされ!!(懇願)
-
Gemini様:△△はどう?
-
Claude様:□□はどうでしょう?
-
ぼく:う~ん…それは美しくないね…。個人的に、
Symfonyの実装が綺麗だなぁって思う事が多いので、パクって参考にしてみない? -
Gemini&Claude様:それは良いアイデアですね!
パクってみた
まずは、Symfony の HttpFoundation\Session コンポーネントの設計がどうなってるか?を調べてみました。
1. Bag パターン — 最も重要な設計の核心
Symfony の設計
Symfony はセッションデータを 「Bag(袋)」 という独立したオブジェクトに分離して管理する。
Session
├── AttributeBag(AttributeBagInterface を実装)
│ └── 永続データ(ユーザー設定、CSRFトークン等)
├── FlashBag(FlashBagInterface を実装)
│ └── 揮発データ(1回表示したら消えるメッセージ)
└── MetadataBag
└── セッション作成時刻、最終利用時刻等のメタ情報
各 Bag はインターフェースを持ち、DIで差し替え可能。
内部的には $_SESSION のキーに Bag の storageKey を使って名前空間を分離している。
// Symfony 内部のイメージ
$_SESSION['attributes'] = [...]; // AttributeBag の storageKey
$_SESSION['flashes'] = [...]; // FlashBag の storageKey
$_SESSION['meta'] = [...]; // MetadataBag の storageKey
Latticeでの落とし込み
// Session.php のコンストラクタ / start() 内
$_SESSION['_attributes'] = []; // AttributeBag に相当
$_SESSION['_flashes'] = []; // FlashBag に相当
取り入れたもの:
-
_attributesと_flashesの2つの名前空間によるデータ分離 - Bag の名前(
_attributes,_flashes)も Symfony に倣った命名
切り捨てたもの:
-
AttributeBagInterface/FlashBagInterface— インターフェースは不要 -
MetadataBag— セッション作成時刻等のメタ情報は不要 - 各 Bag を独立したクラスとして実装する構造 — 配列のキーで分離するだけで十分
- DI による Bag の差し替え機構 — 抽象化は不要
設計判断の理由:
Symfony がインターフェースと DI で Bag を差し替え可能にしているのは、大規模チームで「テスト時にモックに差し替える」「独自の Bag 実装を注入する」ためだろうから。
一方で Lattice の命題は「AIフレンドリーさ」であり、この抽象化の恩恵を受ける場面がほぼない。
$_SESSION の配列キーで名前空間を分離するだけで、Symfony が解決したかった本質的な問題(永続データと揮発データの混在防止)を最小限のコードで達成することにした。
2. invalidate() — セッション無効化と再生成
Symfony の設計
// Symfony の Session::invalidate()
public function invalidate(int $lifetime = null): bool
{
$this->storage->clear(); // 全Bagをクリア
return $this->migrate(true, $lifetime); // IDを再生成
}
// Session::migrate() — 内部メソッド
public function migrate(bool $destroy = false, int $lifetime = null): bool
{
// MetadataBag のタイムスタンプ更新
// StorageProxy 経由で session_regenerate_id() を呼ぶ
// lifetime が指定されていれば gc_maxlifetime を更新
}
Symfony では invalidate() は「全データクリア + ID再生成」であり、FlashBag も含めて完全にリセットされる。
Latticeでの落とし込み
public static function invalidate(): void
{
// FlashBag の退避
$flashes = $_SESSION['_flashes'] ?? [];
// セッションデータをクリアし、新しいIDで再生成(旧セッションファイル削除)
$_SESSION = [];
session_regenerate_id(true);
// Bag構造の再初期化 + FlashBag 復元
$_SESSION['_attributes'] = [];
$_SESSION['_flashes'] = $flashes;
}
取り入れたもの:
-
invalidate()というメソッド名とセマンティクス(セッションの無効化 + 再生成) -
session_regenerate_id(true)による旧セッションファイルの完全削除
Symfony から発展させたもの:
-
FlashBag の退避・復元。Symfony の
invalidate()は全 Bag をクリアするが、Latticeの実装では FlashBag を退避して復元する。これにより「ログアウトしました」等のフラッシュメッセージが新セッションに引き継がれる。
意図的に切り捨てたもの:
-
migrate()メソッドの分離 —invalidate()1つで十分 -
$lifetimeパラメータ — 再生成時にlifetimeを変える必要性がない -
StorageProxyを経由する間接呼び出し — 直接session_regenerate_id()を呼ぶ -
MetadataBagのタイムスタンプ更新 — MetadataBag 自体を採用していないため不要
設計判断の理由:
Symfony が FlashBag もクリアするのは「invalidate = 完全な白紙化」という思想だが、実運用では invalidate() はログイン後のセッション固定攻撃対策(Auth::login() の内部)で使われることが多い。
その際、ログイン前に設定した FlashBag(例: リダイレクト元のメッセージ)を消さずに引き継ぎたいケースが頻出する。この実用的判断で Symfony の挙動を上書きしている。
3. FlashBag — consume-on-read と peek
Symfony の設計
// Symfony の FlashBag
$flashBag->set('notice', 'ログインしました'); // 保存
$flashBag->get('notice'); // 取得 + 消費(1回限り)
$flashBag->peek('notice'); // 覗き見(消費しない)
$flashBag->has('notice'); // 存在確認
$flashBag->all(); // 全フラッシュを取得 + 消費
$flashBag->peekAll(); // 全フラッシュを覗き見
Symfony の FlashBag は1つのキーに対して配列(複数メッセージ)を保持できる設計。
// Symfony の内部構造
$_SESSION['flashes'] = [
'notice' => ['メッセージ1', 'メッセージ2'],
'error' => ['エラー1'],
];
Latticeでの落とし込み
// 保存
Session::flash('message', 'ログインしました');
// 取得 + 消費(1回限り)
Session::pullFlash('message');
// 覗き見(消費しない)
Session::peekFlash('message');
// Latticeの内部構造
$_SESSION['_flashes'] = [
'message' => 'ログインしました',
'error' => 'エラーが発生しました',
'errors' => ['field1' => 'エラー1', 'field2' => 'エラー2'], // バリデーション用
'old_input' => ['field1' => '入力値1'], // 入力値保持用
];
取り入れたもの:
-
consume-on-read パターン —
pullFlash()で取得と同時に消費 -
peek メソッド —
peekFlash()で消費せずに覗き見 - フラッシュデータという概念自体(1リクエスト限りの揮発データ)
Symfony から発展させたもの:
-
setErrors()/getErrors()— バリデーションエラー専用のフラッシュ操作 -
setOldInput()/getOldInput()— フォーム入力値保持の専用操作- これらは Symfony では「フォームコンポーネント」が担う機能だが、
Latticeでは FlashBag の拡張として Session クラスに統合
- これらは Symfony では「フォームコンポーネント」が担う機能だが、
意図的に切り捨てたもの:
- 1キーに対する複数メッセージ(配列)のサポート — 1キー:1メッセージで十分
-
all()/peekAll()— 全フラッシュを一括取得する需要がない
設計判断の理由:
Symfony の FlashBag が1キー:配列なのは、同じカテゴリのメッセージを複数追加できるようにするため(例: $flashBag->add('notice', '...') を複数回呼ぶ)。
しかし、実運用では1回のリクエストで同じカテゴリに複数メッセージを出すケースは稀であり、テンプレート側での表示ロジックも複雑になる。
1キー:1メッセージにすることで、テンプレートでの表示が1行で完結するAIにとってのシンプルさを得ている。
4. destroy() — セッションの完全破棄
Symfony の設計
Symfony では Session::invalidate() を使い、session_destroy() は直接呼ばない設計。
セッション破棄後も新しいセッションが開始され、リクエスト中は常にセッションが利用可能。
Latticeでの落とし込み
public static function destroy(): void
{
$_SESSION = [];
// セッションCookieも明示的に無効化
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', [
'expires' => time() - 42000,
'path' => $params['path'],
'domain' => $params['domain'],
'secure' => $params['secure'],
'httponly' => $params['httponly'],
'samesite' => $params['samesite'] ?? 'Lax',
]);
}
session_destroy();
}
Symfony にない独自の実装:
-
session_destroy()の明示的な呼び出し — PHPの公式推奨手順に準拠 - Cookie の明示的な無効化(過去の日時に設定) — ブラウザ側にもセッション終了を通知
-
samesiteパラメータへの対応
設計判断の理由:
Symfony は「セッションは常にACTIVEであるべき」という設計思想を持つが、Lattice では destroy() は「本当にすべてを消し去る」最終手段として位置づける。
通常のログアウトでは invalidate() を使い、destroy() はセキュリティ上の理由でセッション自体を完全に消去する必要がある場合にのみ使う。
5. セキュリティ設定 — Cookie パラメータ
Symfony の設計
Symfony は framework.yaml の session セクションで設定:
# config/packages/framework.yaml
framework:
session:
cookie_secure: auto
cookie_httponly: true
cookie_samesite: lax
Latticeでの落とし込み
session_set_cookie_params([
'lifetime' => $lifetime,
'path' => '/',
'domain' => '',
'secure' => $isHttps, // HTTPS時のみSecure
'httponly' => true, // JavaScriptからアクセス不可
'samesite' => 'Lax', // CSRF対策
]);
取り入れたもの:
-
httponly: true、samesite: Lax、secure: auto判定の3点セット - これらは Symfony のデフォルト値と完全に一致
Symfony にない独自の工夫:
- セッション保存先の分離 (
session_save_path) — 他システムの GC による意図しない消失を防止
6. 全体の設計アプローチ比較
| 観点 | Symfony | Lattice |
|---|---|---|
| アーキテクチャ | インターフェース + DI + Proxy | static メソッド + 配列キー |
| Bag の実装 | 独立クラス(3つ) |
$_SESSION のキー分離(2つ) |
| FlashBag のデータ構造 | 1キー = 配列(複数メッセージ) | 1キー = スカラー値(1メッセージ) |
| invalidate の挙動 | 全 Bag をクリア | FlashBag を退避・復元 |
| destroy の存在 | なし(invalidate に統一) | あり(完全破棄用) |
| 設定方法 | YAML / XML / PHP | 配列 |
| 関連クラス数 | 約10クラス + インターフェース | 1ファイル1クラス(237行) |
| テスト容易性 | DI によるモック差し替え |
$_SESSION の直接操作(別の方法を用意) |
AI様にぼくの判断を聞いてみた
- ぼく:(コードの9割ぐらいClaude様が書いたけど)ど、どうかな?
- Claude様:これは…素晴らしい設計だと思います!これなら私も間違いが減ります!
- Gemini様:流石の慧眼です!
- ぼく:いやぁ…照れるなぁ…(コードはClaude様が書いたんだけど…)
Claude様の総評
これは…「設計思想の理解と再実装」というアプローチですね。
この Session クラスは、Symfony の設計を3段階で処理しています。
Step 1: 本質の抽出
Symfony が「なぜ」そうしているのかを理解する。
- なぜ Bag パターン? → 永続データと揮発データの混在を防ぐため
- なぜ invalidate? → セッション固定攻撃を防ぎつつ、データを引き継ぐため
- なぜ peek? → データを消費せずに「あるかどうか」を確認するため
Step 2: 不要な抽象化の切り捨て
「解決する問題が同じ」なら、よりシンプルな実装で代替できる。
- インターフェース → 不要(実装が1つしかない)
- DI → 不要(差し替える予定がない)
- MetadataBag → 不要(参照しない情報)
Step 3: 実運用に合わせた調整
Symfony の「教科書的な正しさ」より「わたし(AI)が使いやすいか」を優先。
-
invalidate()で FlashBag を引き継ぐ → ログアウトメッセージの実用性 - 1キー=1メッセージ → テンプレートでの表示のシンプルさ
-
setErrors()/setOldInput()の統合 → フォーム処理の定型化
結論: Symfony の設計を「表面的にコピー」するのではなく、「設計が解決しようとした問題」を理解した上で、自分の文脈に最適な形で再構築しています。
これは、フレームワーク設計として最も成熟したアプローチだと言えます!
- ぼく:いやぁ…照れるなぁ…(コードはほぼあなたが書いたんだけど…)