『SymfonyとDoctrineで簡単クリーンアーキテクチャ』をやってみる

2021/11/03に公開

PHP Conference Japan 2021でお話しさせていただいた、『SymfonyとDoctrineで簡単クリーンアーキテクチャ』ですが、実際にやってみようと思います。

当日のセッションはこちら

https://youtu.be/9SjjUqV8yhA

やってみるユースケース

『ユーザが商品を購入する』『複数いる配送係に注文内容連絡する』っていうのをやります。

モデリング

概念モデル図

ユーザは複数の注文ができます。注文にはどの商品をいくつ買ったかがわかる注文明細が紐づいています。
配送係に連絡はするものの、注文には紐づかないので独立した形にしました。

クラス図

『ユーザが商品を注文する』というユースケースを実装するクラスと、『配送係に注文内容を連絡する』というユースケースを実装するクラスを用意します。
ここで、配送係に連絡するためには配送係を取得しないといけないので、データサービスを用意し、『配送係を取得する』処理を別途用意します。

登壇時にお話ししましたが、ユースケースはビジネスの業務を記載するところとし、『DBへの永続化』や『メールの送信』という技術的な処理は記載しないようにします。
よって、今回のユースケースでは注文データや通知のためのデータを作成するのみにします。

プロジェクト作成

では、コードに落としていきましょう。まずはプロジェクトを作成します。
SymfonyにはSymfony CLIというものがあり、プロジェクトの作成やローカルWebサーバの起動などいろいろなことを行うことができます。
こちらの記事 をぜひご参考にしてください。
Symfonyのプロジェクトを作成するには

symfony new "プロジェクト名"

で作成ができます。Symfonyは最小限の構成でプロジェクトを作成します。必要なものは随時追加していく方針です。この時点で必要になるものは

  • DB
  • HTML表示(テンプレート)

あたりが必要になりそうです。また開発用に、

  • テスト
  • デバッグツール
  • ファイル作成ツール

が必要になります。これらのコンポーネントをインストールしていきます。SymfonyにはRecipeというものがあり、これを使うと必要なものがまとめてインストールできます。
また、エイリアスが設定されており、たとえば、Packagistに登録されているsymfony/orm-packというレシピであれば、ormでインストールできます。
上記のコンポーネントであれば、

symfony composer require orm
symfony composer require twig

symfony composer require test --dev
symfony composer require profiler --dev
symfony composer require maker --dev

でインストールできます。

エンティティ作成

さて、プロジェクトの基盤はできました。次にエンティティを作っていきます。上記の図から、必要なエンティティは

  • ユーザ(User)
  • 商品(Item)
  • 注文(Order)
  • 注文明細(OrderDetail)
  • 配送係(DeliveryClerk)

というのが、わかりました。英語表記に変えてエンティティを作成していきます。先ほどインストールしたファイル作成ツールを利用します。

マッピングをAnnotation形式からAttribute形式に変える

と、その前に。せっかくなのでマッピングをAttributeに変えてみましょう。ormコンポーネントをインストールすると、config/packages/doctrine.yamlというファイルが作成されます。
設定を少し変更してみます。

    orm:
        auto_generate_proxy_classes: true
        naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
        auto_mapping: true
        mappings:
            App:
                is_bundle: false
-                 type: annotation
+                 type: attribute
                dir: '%kernel.project_dir%/src/Entity'
                prefix: 'App\Entity'
                alias: App

typeをattributeに変えることで、マッピング設定を変更することができます。Attributeのほうが、PHP8っぽいですね!

作成

さて、実際に作っていきましょう。

symfony console make:entity "エンティティ名"

このコマンドを実行し、ウィザードにしたがって設定すると、src/Entityにエンティティクラスを、src/Reposirotyにレポジトリクラスを作成します。
Symfonyのレポジトリは、基本参照のみに利用します。しかし、保存処理も行うことは可能です。
エンティティは以下のようなクラスが作成されます。


#[ORM\Entity(repositoryClass: ItemRepository::class)]
class Item
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private $id;

    #[ORM\Column(type: 'string', length: 255)]
    private $name;

    // 中略

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): self
    {
        $this->name = $name;

        return $this;
    }

    // 以下略

privateなプロパティとGetter/Setterが出来上がります。何かしらドメインに紐づく処理を追加しても構いません。
とりあえず必要そうなUser, Item, DeliveryClerkを作っておきます。

エンティティにビジネスロジックを追加する

たとえば在庫を減らす処理などを追加するのであれば、


    public function reduceStock(int $quantity): self
    {
        // 在庫を減らす処理
        
        return $this;
    }

をエンティティクラスに追加します。テストが必要になるので、テストクラスを作成しましょう。

symfony console make:test

ウィザードにしたがって作成していきます。テストの種類を選択できるので通常?のTestCaseを選択します。

 Which test type would you like?:
  [TestCase       ] basic PHPUnit tests
  [KernelTestCase ] basic tests that have access to Symfony services
  [WebTestCase    ] to run browser-like scenarios, but that don't execute JavaScript code
  [ApiTestCase    ] to run API-oriented scenarios
  [PantherTestCase] to run e2e scenarios, using a real-browser or HTTP client and a real web server
 > TestCase


Choose a class name for your test, like:
 * UtilTest (to create tests/UtilTest.php)
 * Service\UtilTest (to create tests/Service/UtilTest.php)
 * \App\Tests\Service\UtilTest (to create tests/Service/UtilTest.php)

 The name of the test class (e.g. BlogPostTest):
 > Entity\Item

 created: tests/Entity/ItemTest.php

           
  Success! 
           

在庫を減らすテストは以下のようにしてみました。

class ItemTest extends TestCase
{
    /**
     * @test
     * @dataProvider dataProviderForReduceStock
     * @throws OutOfStockException
     */
    public function reduceStock($stock, $quantity): void
    {
        $item = new Item();
        $item
            ->setStock($stock)
            ;

        $item->reduceStock($quantity);
        $this->assertEquals($stock - $quantity, $item->getStock());
    }

    public function dataProviderForReduceStock(): array
    {
        return [
            'greaterThan' => [
                'stock' => 7,
                'quantity' => 5,
            ],
            'even' => [
                'stock' => 7,
                'quantity' => 7,
            ],
        ];
    }

    /**
     * @test
     * @throws OutOfStockException
     */
    public function reduceStockIfOutOfStock()
    {
        $stock = 5;
        $quantity = 7;
        $item = new Item();
        $item
            ->setStock($stock)
        ;

        $this->expectException(OutOfStockException::class);
        $item->reduceStock($quantity);
    }
}

在庫があるときにちゃんと減らせるか、在庫がないときに例外を投げるかをテストします。例外は新規にOutOfStockExceptionを作ります。
これを満たすような実装をします。

    public function reduceStock(int $quantity): self
    {
        if ($this->stock < $quantity) {
            throw new OutOfStockException();
        }

        $this->stock -= $quantity;

        return $this;
    }

ユースケースクラス作成

注文するユースケース作成

つづいてユースケースを作っていきます。『注文する』ユースケースクラスとしてPurchaseを用意します。

class Purchase
{
    public function createOrder(User $user, Item $item, int $quantity): Order
    {
        $order = new Order();
        // 注文データを作る処理
        
        return $order;
    }
}

ここでは、該当の商品を該当のユーザが注文するデータを作成して返す処理を行います。データベースには書き込まず、データを作るだけにとどめます。

注文エンティティ作成

そういえば、Order, OrderDetail エンティティを作り忘れていたので、作りましょう。先ほどと同様ですが、

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > user

 Field type (enter ? to see all types) [string]:
 > ManyToOne

 What class should this entity be related to?:
 > User

 Is the Order.user property allowed to be null (nullable)? (yes/no) [yes]:
 > no

 Do you want to add a new property to User so that you can access/update Order objects from it - e.g. $user->getOrders()? (yes/no) [yes]:
 > 

 A new property will also be added to the User class so that you can access the related Order objects from it.

 New field name inside User [orders]:
 > 

 Do you want to activate orphanRemoval on your relationship?
 A Order is "orphaned" when it is removed from its related User.
 e.g. $user->removeOrder($order)
 
 NOTE: If a Order may *change* from one User to another, answer "no".

 Do you want to automatically delete orphaned App\Entity\Order objects (orphanRemoval)? (yes/no) [no]:
 >        

 updated: src/Entity/Order.php
 updated: src/Entity/User.php

このようにフィールドタイプにOneToOne, OneToManyなどを指定すると他のエンティティとのアソシエーションを設定することができます。
同じようにOrderDetailも作っていきます。
※MySQLではorderが予約語なので、OrderDetailにはheaderという名前でOrderとのアソシエーションを設定します。

上記のユースケースのテストを作ります。

class PurchaseTest extends TestCase
{
    public function testSomething(): void
    {
        $user = new User();
        $user
            ->setName('お名前')
            ->setPostcode('111-2222')
            ->setAddress('大阪府')
            ->setTel('090-0000-1111')
            ;
        $item = new Item();
        $item
            ->setName('商品')
            ->setPrice(100)
            ->setStock(20)
            ;
        $quantity = 10;

        $purchase = new Purchase();
        $order = $purchase->createOrder($user, $item, $quantity);

        $this->assertSame($user, $order->getUser());
        $this->assertEquals($user->getName(), $order->getName());
        $this->assertEquals($user->getPostcode(), $order->getPostcode());
        $this->assertEquals($user->getAddress(), $order->getAddress());
        $this->assertEquals($user->getTel(), $order->getTel());
        $this->assertCount(1, $order->getOrderDetails());
        $this->assertEquals($item->getPrice() * $quantity, $order->getTotal());

        $orderDetail = $order->getOrderDetails()?->first();
        $this->assertInstanceOf(OrderDetail::class, $orderDetail);
        $this->assertSame($item, $orderDetail->getItem());
        $this->assertEquals($item->getName(), $orderDetail->getName());
        $this->assertEquals($item->getPrice(), $orderDetail->getPrice());
        $this->assertEquals($quantity, $orderDetail->getQuantity());
        $this->assertEquals($item->getPrice() * $quantity, $orderDetail->getSubtotal());
    }
}

Orderにはユーザ情報が一通り入った上で合計金額が設定されているはずでOrderDetailには商品情報が一通り入った上で小計が設定されているはずなので、そこをテストします。
あとは、このテストが通るように実装していきます。

    public function createOrder(User $user, Item $item, int $quantity): Order
    {
+         $orderDetail = new OrderDetail();
+         $orderDetail
+             ->setName($item->getName())
+             ->setPrice($item->getPrice())
+             ->setQuantity($quantity)
+             ->calculateSubtotal()
+             ->setItem($item)
+             ;
+ 
        $order = new Order();
+         $order
+             ->setName($user->getName())
+             ->setPostcode($user->getPostcode())
+             ->setAddress($user->getAddress())
+             ->setTel($user->getTel())
+             ->setUser($user)
+             ->setOrderedAt(new \DateTimeImmutable())
+             ->addOrderDetail($orderDetail)
+             ->calculateTotal()
+         ;

        return $order;
    }

Order::calculateTotal, OrderDetail::calculateSubtotalはそれぞれ合計金額の計算をおこないます。ここは別途テストを作成して確認します。
(ここでは割愛)

通知するユースケース作成

さて、続いて『通知する』ユースケースをつくっていきます。ユースケースクラスとしてShipmentManagementを用意します。

class ShipmentManagement
{
    public function createNotifications(Order $order): array
    {
        $notifications = [];
        // 配送係を取得する処理
        // 各配送係用の通知データを作る処理

        return $notifications;
    }
}

配送係が複数いることもあるので、各配送係ごとに通知するためのデータを作って返します。

通知エンティティ作成

まだ通知エンティティを作成していなかったので作成します。他のエンティティと同様のやりかたでも構いませんが、
データベースに保存しなくてもよい場合は、src/Entityに直接クラスファイルを作成するだけでOKです。

class Notification
{
    private ?Order $order = null;
    private ?DeliveryClerk $deliveryClerk = null;

    /**
     * @return Order|null
     */
    public function getOrder(): ?Order
    {
        return $this->order;
    }

    /**
     * @param Order|null $order
     * @return Notification
     */
    public function setOrder(?Order $order): self
    {
        $this->order = $order;
        return $this;
    }

    /**
     * @return DeliveryClerk|null
     */
    public function getDeliveryClerk(): ?DeliveryClerk
    {
        return $this->deliveryClerk;
    }

    /**
     * @param DeliveryClerk|null $deliveryClerk
     * @return Notification
     */
    public function setDeliveryClerk(?DeliveryClerk $deliveryClerk): self
    {
        $this->deliveryClerk = $deliveryClerk;
        return $this;
    }
}

引き続き、ユースケースのテストを作ります。

class ShipmentManagementTest extends TestCase
{
    /**
     * @test
     */
    public function createNotifications(): void
    {
        $order = new Order();
        $clerks = [
            new DeliveryClerk(),
            new DeliveryClerk(),
        ];
        $deliveryClerkRepository = $this->createMock(DeliveryClerkRepository::class);
        $deliveryClerkRepository
            ->method('findAll')
            ->willReturn($clerks)
        ;
        
        $management = new ShipmentManagement($deliveryClerkRepository);
        $notifications = $management->createNotifications($order);

        $this->assertCount(count($clerks), $notifications);
        for ($i = 0; $i < count($clerks); $i ++) {
            $clerk = $clerks[$i];
            $notification = $notifications[$i];
            $this->assertSame($clerk, $notification->getClerk());
            $this->assertSame($order, $notification->getOrder());
        }
    }
}

該当のDeliveryClerkの数だけNotificationの配列が作成されているはずで、それぞれにはOrderDeliveryClerk指定されているはずなので、そこをテストします。
ここでは、OrderDeliveryClerk中身には興味がないのであえてセットしたりテストしていません
該当のDeliveryClerkを取得する部分は、DeliveryClerkRepositoryモックしています。
モックすることで実際のクラスのメソッドを実行するのではなく、『このオブジェクトのこのメソッドは〜なデータを返すことな!』ということにすることができます。
DBからの参照はRepositoryクラスが行うので、ユースケースのコンストラクタにRepositoryクラスを引数にすれば機能は満たせます。
あとは、このテストが通るように実装していくだけですが、

DBからデータを取得するデータサービスを作る

たしかに上記のように該当のテーブルのRepositoryクラスをコンストラクタの引数にし、そのオブジェクトを注入すれば機能は満たせます。
しかし、このやり方だと必要なテーブルが増えるごとにコンストラクタに引数が追加され、その度にテストのモックが増えていくことになります。
そこで、『DBからデータを取得する』という処理をひとつのクラスにまとめてしまいます。

interface ShipmentManagementDataServiceInterface
{
    /**
     * @return array<DeliveryClerk>
     */
    public function findDeliveryClerks(): array;
}

ShipmentManagementDataServiceInterfaceは、ユースケースで必要になる『DBからデータを取得する』メソッドを定義するインターフェイスです。
今回は該当の配送係を取得するメソッドが必要になるので定義します。定義したらまずはテストを修正します。

class ShipmentManagementTest extends TestCase
{
    /**
     * @test
     */
    public function createNotifications(): void
    {
        $order = new Order();
        $clerks = [
            new DeliveryClerk(),
            new DeliveryClerk(),
        ];
-        $deliveryClerkRepository = $this->createMock(DeliveryClerkRepository::class);
-        $deliveryClerkRepository
-            ->method('findAll')
-            ->willReturn($clerks)
-        ;
+        $dataService = $this->createMock(ShipmentManagementDataServiceInterface::class);
+        $dataService
+            ->method('findDeliveryClerks')
+            ->willReturn($clerks)
+        ;
        
-        $management = new ShipmentManagement($deliveryClerkRepository);
+        $management = new ShipmentManagement($dataService);
        $notifications = $management->createNotifications($order);

DeliveryClerkRepositoryのモックをShipmentManagementDataServiceInterfaceに置き換えました。これで、使用するテーブルが増えてもモック部分の定義やインスタンス生成を変更する必要がなくなりました。
続いて、実装クラスを作成します。

namespace App\Service;

use App\Core\ShipmentManagementDataServiceInterface;
use App\Repository\DeliveryClerkRepository;

class ShipmentManagementDataService implements ShipmentManagementDataServiceInterface
{
    public function __construct(private DeliveryClerkRepository $deliveryClerkRepository)
    {
    }

    public function findDeliveryClerks(): array
    {
        return $this->deliveryClerkRepository
            ->findAll()
            ;
    }

}

実装クラスはDBに依存しているクラスで、クリーンアーキテクチャのレイヤーだとユースケース層(赤)ではなくゲートウェイ層(緑)になります。
よって、ディレクトリはApp\CoreではなくApp\Serviceに配置します。ここではDeliveryClerkRepositoryが必要になるので、コンストラクタに引数として定義します。
この実装クラスのテストですが、Symfony(Doctrine)の機能しか使っていないので、必要がありません。

オートワイヤリング

Symfonyではオートワイヤリングという機能があり、DIの定義を自動解釈して必要なクラスのオブジェクトを自動注入してくれます。
通常のクラスはコンストラクタ・セッターに対して、Controllerは加えてアクションメソッドに対してオートワイヤリングが動作します。
また、インターフェイスで定義されているところは自動で実装クラスを注入してくれます。

class SomeService
{
    public function __construct(private SomeRepository $someRepository)
    {
    }
    
    // セッターインジェクションはRequiredアトリビュートをつけることでオートワイヤリングが発動
    #[Requred]
    public function setLogger(LoggerInterface $logger)
    {
    
    }
}

class SomeController
{
    // Controllerは特別にアクションメソッドでも発動
    public function someAction(SomeService $someService)
    {
    
    }
}

今回のShipmentManagementクラスはShipmentManagementDataServiceInterfaceの実装クラスにあたるShipmentManagementDataServiceが、
そしてShipmentManagementDataServiceクラスは、DeliveryClerkRepositoryがオートワイヤリングにより自動注入されます。

では、あらためてテストが通るようにユースケースクラスを実装していきます。

+    public function __construct(private ShipmentManagementDataServiceInterface $dataService)
+    {
+    }

    public function createNotifications(Order $order): array
    {
        $notifications = [];
+        $clerks = $this->dataService->findDeliveryClerks();

+        foreach ($clerks as $clerk) {
+            $notification = new Notification();
+            $notification
+                ->setOrder($order)
+                ->setDeliveryClerk($clerk)
+                ;
+            $notifications[] = $notification;
+        }

        return $notifications;
    }

これでテストが通れば、ユースケースの実装は終了です!

Webから注文できるようにする

Webから注文できるように、Controller部分を作成していきます。Controllerは以下のコマンドで作成できます。

symfony console make:controller "コントローラー名"

実行すると以下のようなクラスができあがります。

class PurchaseController extends AbstractController
{
    #[Route('/purchase', name: 'purchase')]
    public function index(): Response
    {
        return $this->render('purchase/index.html.twig', [
            'controller_name' => 'PurchaseController',
        ]);
    }
}

ここをカスタマイズしていきます。本来であれば、ログインしたユーザがカートに入った商品を購入。。としたいところですが、
長くなるので、一旦id=1のユーザ、商品を取得して注文させるようにします。

    #[Route('/purchase', name: 'purchase')]
    public function index(
        ItemRepository $itemRepository,
        UserRepository $userRepository,
        Purchase $useCase,
        EntityManagerInterface $entityManager
    ): Response {
        // とりあえずの処理
        $user = $userRepository->find(1);
        $item = $itemRepository->find(1);
        $quantity = 2;

        // 注文処理
        $order = $useCase->createOrder($user, $item, $quantity);
        $entityManager->persist($order);
        $entityManager->flush();

        return $this->render('purchase/index.html.twig', [
            'controller_name' => 'PurchaseController',
        ]);
    }

必要なクラスはオートワイヤリングで取得するようにメソッドの引数に定義します。
とりあえずの処理は割愛で、注文処理ですがユースケースクラスで注文データを作成し、EntityMangerInterface::persist()でDB管理オブジェクトとして登録します。
登録後、EntityManagerInterface::flush()で、DBと同期をとることによりDBへ保存されます。

テストデータ作成

ユーザや商品などのテストデータが必要になってきたので作成します。SymfonyにはDoctrineFixtureBundleというものがあり、これを使うと簡単にテストデータが作成できます。

symfony composer require orm-fixtures --dev

インストールすると、App\DataFixturesにクラスファイルができているので、ここにテストデータを作成する処理を書いていきます。

class AppFixtures extends Fixture
{
    public function load(ObjectManager $manager): void
    {
        $user = new User();
        $user
            ->setName('テスト太郎')
            ->setEmail('test@some-domain')
            ->setPostcode('111-2222')
            ->setAddress('大阪府なんとか市')
            ->setTel('090-0000-1111')
            ;
        $manager->persist($user);
        
        $item = new Item();
        $item
            ->setName('商品')
            ->setPrice(1200)
            ->setStock(100)
            ;
        $manager->persist($item);
        
        $deliveryClerk = new DeliveryClerk();
        $deliveryClerk
            ->setName('担当者')
            ->setEmail('delivery@some-domain')
            ->setIsActive(true)
            ;
        $manager->persist($deliveryClerk);

        $manager->flush();
    }
}

作成後、以下のコマンドを実行することでテストデータがDBに保存されます。

symfony console doctrine:fixtures:load

サーバ起動&動作確認

テストデータを作ったら、動作するか確認します。ローカルWebサーバを起動するには以下のコマンドを実行します。

symfony server:start -d

他にローカルWebサーバが起動していなければ、localhost:8000で起動します。
作成したControllerは/purchaseなので、localhost:8000/purchaseにアクセスすれば、データが作成されるはずです!

cascade設定する

されるはず!だったのですが、エラーが発生します。

A new entity was found through the relationship 'App\Entity\Order#orderDetails' that was not configured to cascade persist operations for entity: App\Entity\OrderDetail@6128. To solve this issue: Either explicitly call EntityManager#persist() on this unknown entity or configure cascade persist this association in the mapping for example @ManyToOne(..,cascade={"persist"}). If you cannot find out which entity causes the problem implement 'App\Entity\OrderDetail#__toString()' to get a clue.

要約すると、OrderにあるOrderDetailがDB管理対象になっていないというエラーです。解決するために設定を追加します。
エラーメッセージにcascadeを設定してねと書いてあるので、その通りにOrderに設定を追加します。

-    #[ORM\OneToMany(mappedBy: 'header', targetEntity: OrderDetail::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'header', targetEntity: OrderDetail::class, cascade: ['persist'], orphanRemoval: true)]
    private $orderDetails;

OneToManyの設定にcascade: ['persist']を追加すると、OrderがDB管理対象になる際に、あわせてOrderDetailを対象にすることができます。
これで再度、localhost:8000/purchaseにアクセスすれば、データが作成されるはずです!

sqlite> select * from `order`;
1|1|テスト太郎|111-2222|大阪府なんとか市|090-0000-1111|2021-11-03 11:48:26|2400
sqlite> select * from `order_detail`;
1|1|1|商品|1200|2|2400

注文後に通知する

Webからアクセスすると注文データができるようになりました。しかし、ユースケースはあるものの注文後に通知されません。
通知ユースケースを使って、通知できるようにしましょう。

同じようにControllerにDeliveryManagementを注入して作成したデータをもとに、メール送信などを行えばよいのですが、
『注文する』と『通知する』は別のユースケースです。『注文する』はユーザの関心ごとなのに対し、『通知する』は運営側の関心ごとです。
なので、『注文する』というアクションの中に『通知する』が入るのには少し違和感があります。

イベントを作成する

そこで、『注文した』というイベントを用意し、そのイベントが発生したら『通知する』というユースケースを実行するようにします。
以下のように考えるとイメージがつきやすいかなと思います。

イベント 実行するユースケース
localhost:8000/purchaseにアクセスしたら 注文する
注文したら 通知する

では、イベントを作成して行きましょう。App\EventOrderedEventというクラスを作成します。

namespace App\Event;

use App\Entity\Order;
use Symfony\Contracts\EventDispatcher\Event;

class OrderedEvent extends Event
{
    public function __construct(private Order $order)
    {
    }

    /**
     * @return Order
     */
    public function getOrder(): Order
    {
        return $this->order;
    }
}

どの注文のイベントかわかるように、Orderを引数としてコンストラクタを作成し、Getterを用意します。

イベントを送る

作成したイベントを送る処理を、Controllerのアクションに追加します。イベントを送るにはEventDispatcherInterfaceが必要になるので、引数に追加します。

    public function index(
        ItemRepository $itemRepository,
        UserRepository $userRepository,
        Purchase $useCase,
-        EntityManagerInterface $entityManager
+        EntityManagerInterface $entityManager,
+        EventDispatcherInterface $eventDispatcher
    ): Response {
        // とりあえずの処理
        $user = $userRepository->find(1);
        $item = $itemRepository->find(1);
        $quantity = 2;

        // 注文処理
        $order = $useCase->createOrder($user, $item, $quantity);
        $entityManager->persist($order);
        $entityManager->flush();

+        // 注文したイベントを送る
+        $event = new OrderedEvent($order);
+        $eventDispatcher->dispatch($event);

        return $this->render('purchase/index.html.twig', [
            'order' => $order,
        ]);
    }

イベントを受け取り、通知する

イベントを送るところまではできたので、イベントを受け取り通知を実行するようにします。
Symfonyには通知を受け取る方法に、EventListenerEventSubscriberがあります。
今回はEventSubscriberを使ってみます。クラスを作成するには以下のコマンドを実行し、ウィザードにしたがって入力して行きます。

symfony console make:subscriber

 Choose a class name for your event subscriber (e.g. ExceptionSubscriber):
 > NotifyNewOrderSubscriber # サブスクライバのクラス名 

 Suggested Events:
 * console.command (Symfony\Component\Console\Event\ConsoleCommandEvent)
 * console.error (Symfony\Component\Console\Event\ConsoleErrorEvent)
 * console.terminate (Symfony\Component\Console\Event\ConsoleTerminateEvent)
 * kernel.controller (Symfony\Component\HttpKernel\Event\ControllerEvent)
 * kernel.controller_arguments (Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent)
 * kernel.exception (Symfony\Component\HttpKernel\Event\ExceptionEvent)
 * kernel.finish_request (Symfony\Component\HttpKernel\Event\FinishRequestEvent)
 * kernel.request (Symfony\Component\HttpKernel\Event\RequestEvent)
 * kernel.response (Symfony\Component\HttpKernel\Event\ResponseEvent)
 * kernel.terminate (Symfony\Component\HttpKernel\Event\TerminateEvent)
 * kernel.view (Symfony\Component\HttpKernel\Event\ViewEvent)

  What event do you want to subscribe to?:
 > App\Event\OrderedEvent #どのイベントを受け取るか

 created: src/EventSubscriber/NotifyNewOrderSubscriber.php

実行すると以下のようなクラスができます。

class NotifyNewOrderSubscriber implements EventSubscriberInterface
{
    public function onOrderedEvent(OrderedEvent $event)
    {
        // ...
    }

    public static function getSubscribedEvents()
    {
        return [
            OrderedEvent::class => 'onOrderedEvent',
        ];
    }
}

あとは、ここに通知処理を書いていきます。今回は通知をログとして出力するようにしました。

class NotifyNewOrderSubscriber implements EventSubscriberInterface
{
+    public function __construct(private ShipmentManagement $shipmentManagement, private LoggerInterface $logger)
+    {
+    }

    public function onOrderedEvent(OrderedEvent $event)
    {
+        $notifications = $this->shipmentManagement->createNotifications($event->getOrder());
+
+        // 何かしらの通知処理
+        foreach ($notifications as $notification) {
+            $message = sprintf(
+                '%sさんへ。%sさんから注文がありました。(id: %d)',
+                $notification->getDeliveryClerk()->getName(),
+                $notification->getOrder()->getName(),
+                $notification->getOrder()->getId()
+            );
+            $this->logger->notice($message);
        }
    }

ロガーインストール

とりあえずログを出力するようにしましたが、ロガーがインストールされていないのでインストールします。
以下のコマンドでMonologがインストールされます。

symfony composer require log

これで、localhost:8000/purchaseにアクセスすると、『注文する』ユースケースと 『通知する』ユースケースが実行されるようになります!

最後に

モデルやユースケースの洗い出しから、ユースケースの作成、それを使ったWebサービスの構築を行っていきました。
ぜひ一度Symfonyと今回の実装を試していただき、感触を掴んでもらえたらと思います。

ここまでの サンプルを公開 していますので、ぜひこちらもご参考にしてください。

Discussion