どこに行ってもCQRSの話題を耳にするのに、SymfonyやLaravelを引っ張り出さずにどう適用すればいいのか悩んでいませんか?朗報です。バニラPHPだけでCommand Query Responsibility Segregationを試すことができます。必要なのは明確なフォルダー構成と少しのクラスだけ。エディタを開いて、一緒に基礎を固めましょう。
前提条件: PHP 8.4と最小限のセットアップ
- PHP 8.4(CLIまたはWebサーバー)をインストール済みであること。
php -vでバージョンを確認し、readonlyプロパティ、厳格な型、First-Class Callableを活用しましょう。
- Composer(オートローディングに便利。必須ではありません)と軽量なWebサーバー(実験用なら
php -S 127.0.0.1:8000 -t public で十分)。
もし古いバージョンを使う必要がある場合は、ここで利用している機能(コンストラクタプロモーション、readonlyプロパティなど)が利用できるか確認し、必要に応じてサンプルコードを調整してください。
1. 2分で理解するCQRS
CQRSは**読み取り(Query)と書き込み(Command)**を分離する考え方です。これにより、2つの最適化された経路が得られます。
- コマンドは状態を変更します(アカウント作成、記事公開など)。アプリケーションデータは返しません。
- クエリは状態を読み取ります(記事一覧の取得、アカウント詳細の参照など)。副作用はありません。
この分離はDDDの思想から自然に導かれますが、厳密な形式ばらずとも取り入れられます。すぐに得られるメリットは以下のとおりです。
- テストが簡単:ハンドラーをユニットテストしやすくなります。
- コードが読みやすい:各ハンドラーが1つの責務に集中します。
- 段階的にスケールできる:読み取りと書き込みを独立して進化させられます。
ただしCQRSは構造上の複雑さを少し追加します。単純なCRUDであれば重すぎるかもしれません。ビジネスルールやスケール要件がそれを必要とするときに使いましょう。
2. PHPネイティブでの最小ディレクトリ構成
シンプルな構成で十分です。
src/
Domain/
Article.php
ArticleRepository.php
Application/
Command/
CreateArticle/
CreateArticleCommand.php
CreateArticleHandler.php
Query/
ListArticles/
ListArticlesQuery.php
ListArticlesHandler.php
Bus/
CommandBus.php
QueryBus.php
Infrastructure/
InMemoryArticleRepository.php
public/
index.php
Domain(ビジネスルール)、Application(ユースケース)、Infrastructure(具体的な実装)を明確に分けます。
3. 最小限のドメインモデル
Articleアグリゲートとリポジトリを定義します。
1<?php
2// src/Domain/Article.php
3
4final class Article
5{
6 public function __construct(
7 private readonly string $id,
8 private string $title,
9 private string $content,
10 private readonly \DateTimeImmutable $publishedAt,
11 ) {
12 }
13
14 public function id(): string
15 {
16 return $this->id;
17 }
18
19 public function title(): string
20 {
21 return $this->title;
22 }
23
24 public function content(): string
25 {
26 return $this->content;
27 }
28
29 public function publishedAt(): \DateTimeImmutable
30 {
31 return $this->publishedAt;
32 }
33}
1<?php
2// src/Domain/ArticleRepository.php
3
4interface ArticleRepository
5{
6 public function save(Article $article): void;
7
8 /** @return Article[] */
9 public function all(): array;
10}
4. コマンド側: 記事を作成する
コマンドは意図を運びます。ハンドラーはビジネスロジックを調整します。
1<?php
2// src/Application/Command/CreateArticle/CreateArticleCommand.php
3
4final class CreateArticleCommand
5{
6 public function __construct(
7 public readonly string $id,
8 public readonly string $title,
9 public readonly string $content,
10 public readonly string $publishedAt,
11 ) {
12 }
13}
1<?php
2// src/Application/Command/CreateArticle/CreateArticleHandler.php
3
4final class CreateArticleHandler
5{
6 public function __construct(private ArticleRepository $repository)
7 {
8 }
9
10 public function __invoke(CreateArticleCommand $command): void
11 {
12 $article = new Article(
13 $command->id,
14 $command->title,
15 $command->content,
16 new \DateTimeImmutable($command->publishedAt),
17 );
18
19 $this->repository->save($article);
20 }
21}
重要な注意点:ハンドラーは何も返しません。記事を再取得したい場合はクエリ経由で行います。
5. クエリ側: 記事一覧を取得する
1<?php
2// src/Application/Query/ListArticles/ListArticlesQuery.php
3
4final class ListArticlesQuery
5{
6}
1<?php
2// src/Application/Query/ListArticles/ListArticlesHandler.php
3
4final class ListArticlesHandler
5{
6 public function __construct(private ArticleRepository $repository)
7 {
8 }
9
10 /** @return array<int, array{id: string, title: string, publishedAt: string}> */
11 public function __invoke(ListArticlesQuery $query): array
12 {
13 return array_map(
14 static fn (Article $article) => [
15 'id' => $article->id(),
16 'title' => $article->title(),
17 'publishedAt' => $article->publishedAt()->format('Y-m-d H:i:s'),
18 ],
19 $this->repository->all()
20 );
21 }
22}
プレゼンテーション層をドメインモデルに結び付けず、直列化しやすい配列を返します。
6. スタート用のメモリ内リポジトリ
1<?php
2// src/Infrastructure/InMemoryArticleRepository.php
3
4final class InMemoryArticleRepository implements ArticleRepository
5{
6 /** @var array<string, Article> */
7 private array $storage = [];
8
9 public function save(Article $article): void
10 {
11 $this->storage[$article->id()] = $article;
12 }
13
14 public function all(): array
15 {
16 return array_values($this->storage);
17 }
18}
この実装は後でDoctrineやPDO、外部APIに差し替えてもハンドラーを触る必要はありません。
7. 超軽量なバスを配線する
ハンドラー解決を中央集約するとCQRSの良さが際立ちます。
1<?php
2// src/Application/Bus/CommandBus.php
3
4final class CommandBus
5{
6 /** @param array<class-string, callable> $handlers */
7 public function __construct(private array $handlers)
8 {
9 }
10
11 public function dispatch(object $command): void
12 {
13 $handler = $this->handlers[$command::class] ?? null;
14
15 if ($handler === null) {
16 throw new \RuntimeException('対応するハンドラーが見つかりません: ' . $command::class);
17 }
18
19 $handler($command);
20 }
21}
1<?php
2// src/Application/Bus/QueryBus.php
3
4final class QueryBus
5{
6 /** @param array<class-string, callable> $handlers */
7 public function __construct(private array $handlers)
8 {
9 }
10
11 public function ask(object $query): mixed
12 {
13 $handler = $this->handlers[$query::class] ?? null;
14
15 if ($handler === null) {
16 throw new \RuntimeException('対応するハンドラーが見つかりません: ' . $query::class);
17 }
18
19 return $handler($query);
20 }
21}
これらのバスは素朴ですが、仕組みを理解するには十分です。実案件ではPHP-DIやSymfony DIなどのDIコンテナに接続するのも良いでしょう。
8. public/index.phpでのブートストラップ例
1<?php
2require_once __DIR__ . '/../vendor/autoload.php';
3
4$repository = new InMemoryArticleRepository();
5
6$createHandler = new CreateArticleHandler($repository);
7$listHandler = new ListArticlesHandler($repository);
8
9$commandBus = new CommandBus([
10 CreateArticleCommand::class => $createHandler(...),
11]);
12
13$queryBus = new QueryBus([
14 ListArticlesQuery::class => $listHandler(...),
15]);
16
17$commandBus->dispatch(new CreateArticleCommand(
18 id: uniqid('article_', true),
19 title: 'CQRSの第一歩',
20 content: 'コマンドと読み取りをスムーズに分離してみよう…',
21 publishedAt: '2025-06-05 10:00:00',
22));
23
24$articles = $queryBus->ask(new ListArticlesQuery());
25
26header('Content-Type: application/json');
27echo json_encode($articles, JSON_PRETTY_PRINT);
(...) 演算子(First-Class Callable)はPHP 8.1から利用でき、PHP 8.4でも完全にサポートされています。より古いバージョン(8.0以下)を対象にする場合は、fn ($command) => $createHandler($command) のように書き換えてください。
9. 少しずつ発展させる
基盤ができたら、次のステップに進めます。
- バリデーション: Value Object(Title, Content)を追加するか、Symfony Validatorで検証してからアグリゲートを作成する。
- 本番向け永続化: ハンドラーを触らずにMySQL(PDO)やPostgreSQLのリポジトリを実装する。
- ドメインイベント: コマンド成功時に
ArticlePublished を発火し、別レイヤーで処理する。
- 専用プロジェクション: 読み取りに特化したテーブルを用意し、ページングや全文検索など重いクエリに備える。
- テスト: 各ハンドラーのユニットテストと、バスの機能テストを書く。
10. 初回リリース前のチェックリスト
この骨組みがあれば、既存プロジェクトに段階的にCQRSを導入したり、フレームワークなしで新しいサービスを立ち上げたりできます。読み取りと書き込みの境界を明確に保ち、アーキテクチャを反復的に育てていきましょう。楽しい実験を!