Symfony + Ray.MediaQueryという可能性
Symfony Advent Calendar 2025 の1日目の記事です!🎄✨
Twitter (X) でもちょいちょいSymfonyネタを呟いてます。よろしければ フォロー お願いします🤲
はじめに
Ray.MediaQuery は、Javaの Doma に似たコンセプト(だそうです)のデータベースアクセスフレームワークです。
先日 PHPカンファレンス香川2025 の懇親会で、作者である @koriym さんによる紹介LTを拝聴し、そのコンセプトに強く興味を持ったので、普段使っているSymfonyとの統合について軽くPoCしてみました。
本稿では、その内容と率直な感想をシェアしたいと思います。
PoCのコードベースは以下のGitHubリポジトリで公開しています。
その前に:Ray.MediaQueryについて基本原理を簡単に紹介
まずは実際の利用イメージをご覧ください。以下は、Ray.MediaQueryのGitHubリポジトリのREADMEで紹介されているサンプルコードです。それぞれに簡単な解説を付記します。
interface OrderRepository
{
#[DbQuery('order_detail', factory: OrderDomainFactory::class)]
public function getOrder(string $id): Order;
}
- これがDB操作を集約したリポジトリの定義
- MVCのコントローラーなど利用側のコードでは、DIによって
OrderRepositoryを受け取り、そのgetOrder()メソッドを呼ぶことでDBクエリを実行し、結果をオブジェクトの形で受け取る -
OrderRepositoryはインターフェースとして宣言するだけでよく、実装を書く必要はない- これは、Ray.MediaQueryが依存しているDIコンテナである Ray.Di の、Nullオブジェクト束縛 という機能による効果
-
getOrder()メソッドなどDBクエリを実行させたいメソッドには#[DbQuery]アトリビュートを付与する- アトリビュートを付与しただけでDBクエリが実行されるのは、Ray.Diが依存している Ray.Aop という アスペクト指向フレームワーク が提供するメソッドインターセプションによる効果
-
#[DbQuery]アトリビュートに渡している'order_detail'という文字列から、(設定されているディレクトリ直下の)order_detail.sqlというSQLファイルが読み込まれ、そのクエリが実行される -
getOrder()メソッドの引数string $idの値は、DBクエリ実行時にorder_detail.sqlのSQL内で使用されているパラメーター:idにバインドされる -
#[DbQuery]アトリビュートのfactory引数で指定されているOrderDomainFactoryが、DBクエリの結果をOrderオブジェクトに変換するためのファクトリーとなる
// Factory injects services and enriches data from SQL
class OrderDomainFactory
{
public function __construct(
private TaxService $taxService,
private InventoryService $inventory,
private RuleEngine $rules,
) {}
public function factory(string $id, float $subtotal): Order
{
return new Order(
id: $id,
subtotal: $subtotal,
tax: $this->taxService->calculate($subtotal),
canShip: $this->inventory->check($id),
rules: $this->rules,
);
}
}
-
#[DbQuery]アトリビュートのfactory引数に指定されたOrderDomainFactoryは、DBクエリ実行後にRay.Aopの効果によってfactory()メソッドが自動で呼び出される -
factory()メソッドの引数string $id, float $subtotalは、DBクエリの結果セットの列名に対応している- 例えばこの場合なら、SQLが
SELECT id, subtotal FROM order WHERE id = :idなどとなっていることが想定される
- 例えばこの場合なら、SQLが
- DI(この場合はコンストラクタインジェクション)によってサービスを受け取ることもできるため、複雑なビジネスロジックをファクトリーに集約できる
- ちなみに、
#[DbQuery]を付与したメソッドがオブジェクト単体ではなくオブジェクトのコレクションを取得する内容であっても、factory()メソッドはオブジェクト単体を組み立てる実装でよい-
#[DbQuery]を付与したメソッドの戻り値の型(arrayかどうか)などによって自動で判定される -
#[DbQuery]アトリビュートのtype引数で明示もできる
-
// Domain object with business logic
class Order
{
public function __construct(
public string $id,
public float $subtotal,
public float $tax,
public bool $canShip,
private RuleEngine $rules,
) {}
public function getPriority(): string
{
return $this->rules->calculatePriority($this);
}
}
- ファクトリーがオブジェクトのコンストラクタに、DIによって受け取ったサービスをそのまま渡すこともできるので、オブジェクトは複雑なビジネスロジックを持った「ドメインオブジェクト」として存在できる
Ray.MediaQueryは、ORMに対するアンチテーゼです。
GitHubリポジトリのREADMEなどの公式ドキュメントからピックアップしたRay.MediaQueryのコンセプトの要点は以下のとおりです。
- SQLはSQLのまま、オブジェクトはオブジェクトのまま
- ORMのようにSQLを抽象化して見えなくする代わりに、SQLを最大性能で活かしながらOOPの原則を維持する
- SQL、ファクトリー、ドメインオブジェクトをそれぞれ独立してテスト可能
- この独立性・透明性はAIによるアシストも最大化する(ORMの複雑な抽象化レイヤーはAIにとってブラックボックスだったが、Ray.MediaQueryではすべてが明示的)
さらなる詳細については以下の公式ドキュメントなどを参照してください。
- ray-di/Ray.MediaQuery: A media access mapping framework
- Ray.MediaQuery/BDR_PATTERN-ja.md
- MediaQuery | BEAR.Sunday
0. 集計機能を持つ簡単なSymfonyアプリを用意する
PoCにあたり、まずはSymfony + Doctrineで集計機能を持つ簡単なアプリを用意しました。
- 売上・ユーザー・チームというエンティティがある
- 売上には1人のユーザーが紐づく
- 集計画面では、ある日付におけるあるチーム(に所属するユーザー)の売上を絞り込んで一覧化できる

少しSQLをややこしくしたかったので、
- ユーザーはチーム所属履歴を持っている
- 集計画面では、指定された日付において所属していたチーム を軸に検索する
という仕様にしました。
この時点のコードは以下のとおりです。
1. Doctrineの代わりにRay.MediaQueryを使うようにしてみる
この状態から、アプリケーションレイヤーでDoctrineを使うのを一切やめて、すべてのDBクエリをRay.MediaQueryで行うようにしてみました。
エンティティは 完全なPOPOになり、コントローラアクションにあったDoctrineクエリビルダーによるSQL組み立てコードは純粋なSQLファイルにアウトソースされました。
PhpStormなど DataGrip 相当のDBツールがバンドルされているJetBrains IDEなら、SQLファイルはIDE上で直接実行できるのでかなり開発体験がよいです。

Ray.MediaQueryはRay.Diに依存しており、 Ray.Diのコンテナ上で動作するため、Symfonyアプリケーションから利用するためには自前で上手いことブリッジする必要がありました。
-
AppModule:Ray.Diにおける束縛の定義 -
AppModule内に(別にここじゃなくてもいいけど)Ray.Diのコンテナから取得したリポジトリの実体をSymfonyのコンテナに挿入するメソッド を実装 - Symfonyのカーネル を拡張するための トレイト を作成し、初期化完了したSymfonyのコンテナを 上記のメソッドに渡して拡張 するように
- Symfonyのコンテナは動的にサービスを追加することができるが、そのためには当該サービスIDに対してあらかじめ
syntheticというマークを付けておく必要があり、各リポジトリインターフェースに対して それを設定
また、Symfonyに備わっているDoctrineインテグレーションのおかげでほとんど無意識に使えていた (ちょっとした)便利な機能を封印されるのはちょっとストレスでした。
例えば、
{id}のようなルートパラメーターが自動でエンティティに変換される仕組みが使えない- それが使えれば、存在しないidが指定されたら自動で404になってくれるのに、使えないせいで 自分でNULLチェックして404を投げないといけない
- Symfony Formの
EntityTypeが 使えない
とかです。
それから、もう少しダメージが大きいものとして、Symfony Profiler でDBクエリのログが見られなくなるというのがありました。ファイルなどへのログ出力は多分簡単に追加実装できると思うのですが、Symfony Profilerで他の情報と一緒にパッとGUIで見られるのは結構大きいので。
ただ、よくよく考えてみるとSymfony ProfilerでDoctrineのタブを見るときって、N+1を解消したいときとか、クエリビルダーの生成したSQLを確認・実行してみたいときとかぐらいなので、Ray.MediaQueryを導入したらそもそも見る必要がなくなるかも、という気はしてきました。
あと、純粋なSQLを手に入れる代わりに、自明だけど無駄に長いSQL も自分(かAI)で書かなければいけません。リレーションシップが大量にあって必要なネストもそこそこ深いようなエンティティがあったらかなりしんどそうな気がしました。
ファクトリーのコード も、リレーションシップを解決するためだけにやたらコードを書く必要があり少ししんどいです。また、Ray.MediaQueryの公式ドキュメントに書かれているサンプルコードを真似して一度JSONにする方法をとりましたが、これだとすべてが mixed になってしまうので、真面目に型を解決しようと思ったら型注釈を大量に書かなければならず厳しいです(今回は baselineに吐き出して無視しました)。JSONでなくJOINベースで書くほうがよさそうかもしれません(factory() メソッドの引数は大量になってしまいますが)。
2. 基本的にはDoctrineを使いつつ、複雑なSQLにのみRay.MediaQueryを使うようにしてみる
Doctrineを完全に手放すのがちょっとストレス大きそうな感じもあったので、複雑なSQLが必要になる場面でのみ、部分的にRay.MediaQueryを使うというパターンを試してみました。
- エンティティはDoctrineエンティティに戻す
-
SaleControllerも、集計画面以外はもとに戻して、集計画面でのみRay.MediaQueryを使用 - DoctrineリポジトリとRay.MediaQueryのリポジトリが併存することになるので、Ray.MediaQueryのほうは
XxxQueryInterfaceという名前に変える - Ray.MediaQueryのファクトリーでDoctrineエンティティを作ることになるので、作ったエンティティを逐一 Unit of Work に 登録する必要が生じた
もしSQLが相当に複雑なら、クエリビルダーでやるよりはRay.MediaQueryを導入して生SQLで管理できるほうが保守しやすいとは思うけど、そうでなければあまり旨味がなさそうな感じがしました。
なお、ファクトリーで作ったエンティティをUnit of Workに登録するために、ファクトリーにDoctrineのエンティティマネージャーを注入する必要があり、これにやや強引なワークアラウンドが必要でした。
- Ray.MediaQueryの内部機構においてファクトリーを取得するために使用されるサービスを 自前のもので上書き
-
src/Ray/MediaQuery/Factory/配下のクラスファイルすべてを、上記のサービスに注入する
最初、Ray.Diの仕様を誤認していてなかなか上手く対応できなかったので、恥を忍んで作者の 郡山さんに相談 させていただき、色々と的確なアドバイスをいただきました。
さらに、より宣言的な方法でSymfonyコンテナとRay.Diコンテナを統合する 改善案 まで頂き、勉強させていただきました。
3. 複雑なSQLを、Ray.MediaQueryではなくDoctrineのNativeQueryを使うようにしてみる
生SQLの結果をもとにDoctrineエンティティを自前で組み立てるぐらいなら、初めからDoctrineの NativeQuery を使うでいいのでは、と思ったのでやってみました。
このコミットでは、Ray.MediaQueryを完全に削除して、単に集計画面のクエリの管理を以下の方法で行うように変更しました。
- SQLファイルに生SQLを書く (NativeQueryの制約により必然的にJSONベースではなくJOINベースに変更)
- Doctrineリポジトリに、そのSQLファイルの読み込んでNativeQueryを使ってDoctrineエンティティを作る処理を実装
これぐらいのユースケースなら、無理にRay.MediaQueryを統合するよりも素直にDoctrineのNativeQueryを使うほうが楽そうです。
感想
というわけで、いくつかのパターンを試してみました。
実際に触ってみる前は、複雑なSQLだけをRay.MediaQueryで置き換えるという使い方から入るのがいいかなと想像していましたが、そのケースだと意外と旨みが少ないかもしれません。
NativeQueryを使ってもしんどくなるような、もっと複雑な集計ロジックになってくるとまた話が変わってくるかもしれません。とはいえ、そこまで複雑な場合は普通、NativeQueryで直接エンティティを生成するのは諦めて、DBALで連想配列を取得して必要に応じてIDでエンティティを再取得するといった使い方をすると思うので、やはり複雑なSQLだけを置き換えるというのでは本領を発揮できないかもしれません。
今回PoCしてみて、個人的には、Symfonyプロジェクトに導入するとしてもやっぱり「完全置き換えパターン」かなと思いました。
- ファクトリーとドメインオブジェクトにビジネスロジックを集約できる
- すべてのビジネスロジックが単体テスト可能になる
という2点が、「Doctrine前提の便利機能が使えない」というデメリットを補って余りあるインパクトをもたらしそうな予感がします。
プロジェクトの規模や性質と相談しながら、今後実務への投入を検討したいなと思いました。
おわり。
Symfony Advent Calendar 2025、明日は空きです🥺どなたかぜひご参加ください!
@akky さんが埋めてくださいました!!!
Discussion