YelpCampをクリーンアーキテクチャで再構築する② - 実装・テスト編

今回はクリーンアーキテクチャ (Clean Architecture) を用いて、前回の YelpCamp アプリを実際にリファクタリングしていきます。

前回のリファクタリング戦略をおさらいすると以下のようになっています。

  1. ドメイン層に Entities と Repository Interface を配置
  2. Controller に散らばったビジネスロジックを UseCase に切り出す
  3. Presentation (Controller / View) は UseCase を呼ぶだけにする
  4. DB アクセスを Infrastructure に移動し、Interface 実装として登録

また、クリーンアーキテクチャの大前提である

  • 内側が外側に依存してはいけない。
    (内側のモジュールが外側のメソッドを呼び出してはいけない。)

というルールを守って実装していきます。

クリーンアーキテクチャでは機能 (ユースケース) 毎に個別に開発していくことが可能ですので、この記事では「ユーザー登録機能」に絞り、必要な部分だけを各層に実装していく、いわゆる「縦スライス」の形で進めていきます。また、モックを使用したテストも実装していきます。

オートローダーの設定 (composer.json) を行います
・オートローダーの設定方法

まず最初に「PSR-4」というオートローダーを設定します。この機能を使用するとファイル内に「require」文などを書く必要がなくなります。(ただし、関数だけのファイルの場合は「require」文が必要です。) 「use」句で名前空間と型を指定するだけで、クラスなどが定義されているファイルの位置を自動的に検索してくれます。通常の開発時にも便利ですが、実装とテストケースが別々のフォルダにある場合などでも、実際にソースファイルが定義されている場所をテストファイル内に記述する必要がありません。

今回オートローダーの対象になるフォルダは以下のようになっています。

<app>
└── src/
    ├── adapter/
    ├── application/
    ├── domain/
    └── infrastructure/

「composer.json」ファイルを開いて以下のように修正します。

{
    "require-dev": {
        "phpstan/phpstan": "^2.1",
        "pestphp/pest": "^3.0",
        "codeception/codeception": "^5.3",
        "codeception/module-webdriver": "^4.0",
        "codeception/module-phpbrowser": "^3.0",
        "codeception/module-asserts": "^3.0"
    },
    "config": {
        "allow-plugins": {
            "pestphp/pest-plugin": true
        }
    },
    "autoload": {
        "psr-4": {
            "Domain\\": "src/domain/",
            "Application\\": "src/application/",
            "Adapter\\": "src/adapter/",
            "Infrastructure\\": "src/infrastructure/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Tests\\": "tests/"
        }
    }
}

修正が完了したら、VSCode のコンソール画面で以下のコマンドを実行してオートローダーを更新します。

composer dump-autoload
・オートローダーの使い方

クラスファイルにクラスが所属する名前空間を定義します。

<?php

declare(strict_types=1);

namespace Domain\Entities;

final class User
{

}

名前空間とディレクトリ構造は一致している必要があるようです。「Domain ⇒ src/domain」なので、上記の「User」クラスは「src/domain/Entities/User.php」というファイル内に記述する必要があります。PSR-4 は Windows や macOS ではフォルダ名の大文字と小文字の区別はしませんが、WSL2 (Linux 系) のシステムではフォルダ名も含め大文字と小文字を区別するので、フォルダ名も含めて同じにしておいた方がいいかも知れません。

<app> 
└─ src/
    ├─ adapter/
    ├─ application/
    ├─ domain/
    │  ├─ Entities/
    │  │   └─ User.php
    │  └─ Repositories/
    └─ infrastructure/

クラスを使用する場合は以下のように記述するだけで、必要なファイルを実行時に読み込んでくれるようです。

<?php

declare(strict_types=1);

namespace Domain\Repositories;

use Domain\Entities\User;

interface UserRepositoryInterface
{
    public function save(User $user): User;
}
ドメイン層に Entities と Repository Interface などを配置
・エンティティを切り出します

前準備が終わりましたので、ドメイン層のエンティティから作成していきます。YelpCamp のエンティティの候補として、「Campground」、「User」、「Review」などが考えられますが、ユーザー登録に必要な「User」エンティティを「src/domain/Entities/User.php」ファイル内に実装します。

リファクタリング前のコードはこちら
<?php

declare(strict_types=1);

namespace models;

use InvalidArgumentException;
use utilsSession;
use utilsYelpCampError;

class UserSchema extends Session
{
    public int $id;
    public string $username;
    public string $email;
    public string $password;

    /**
     * @var string
     */
    protected static $SESSION_NAME = '_user';

    /**
     * @param object $obj
     */
    public static function cast(object $obj): self
    {
        if (!($obj instanceof self)) {
            $name = get_class($obj);
            throw new InvalidArgumentException("{$name} は UserSchema ではありません。");
        }
        return $obj;
    }

    public static function getModel(bool $check_email = true): self
    {
        $username = get_param('username', '');
        $email = get_param('email', '');
        $password = get_param('password', '');

        if (empty($username)) throw new YelpCampError('ユーザー名がありません。');
        if (empty($email) && $check_email) throw new YelpCampError('emailがありません。');
        if (empty($password)) throw new YelpCampError('パスワードがありません。');

        $user = new UserSchema;
        $user->username = $username;
        $user->email = $email;
        $user->password = $password;

        return $user;
    }
}

コードは少し長くなりましたが、このリファクタリングによって

  • 余分な責務の排除
  • フレームワーク / DB / Session 依存なし
  • 必須項目のみ

という実装に変わりました。(オリジナルコードにある「$id」項目は実際に使用していないので削除しました。)

<?php

declare(strict_types=1);

namespace Domain\Entities;

use InvalidArgumentException;

/**
 * User エンティティ(最小構成)
 * username, email, passwordHash の整合性のみ保持
 */
final class User
{
    public function __construct(
        private string $username,
        private string $email,
        private string $passwordHash
    ) {
        $this->assertValid();
    }

    /**
     * 新規作成用ファクトリメソッド
     */
    public static function create(
        string $username,
        string $email,
        string $passwordHash
    ): self {
        return new self(
            username: $username,
            email: $email,
            passwordHash: $passwordHash
        );
    }

    // ====================
    // Getter
    // ====================

    public function username(): string { return $this->username; }

    public function email(): string { return $this->email; }

    public function passwordHash(): string { return $this->passwordHash; }

    // ====================
    // Validation
    // ====================

    private function assertValid(): void
    {
        if ($this->username === '') {
            throw new InvalidArgumentException('ユーザー名がありません。');
        }

        if ($this->email === '') {
            throw new InvalidArgumentException('emailがありません。');
        }

        if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('メールアドレスの形式が不正です。');
        }

        if ($this->passwordHash === '') {
            throw new InvalidArgumentException('パスワードがありません。');
        }
    }
}
・インターフェイスを作成します

ベースアプリでは、データベース操作は以下のようになっていました。(15 行目と 22 行目)

function register(): void
{
    try {
        $new_user = UserSchema::getModel();
        $user = UserQuery::fetchByUserName($new_user->username);

        if (!empty($user)) {
            Flush::push(Flush::ERROR, "そのユーザー名はすでに使われています");
            redirect(GO_REFERER);
            return;
        };
        UserQuery::insert($new_user);

        login('Yelp Campへようこそ!');
    } catch (Throwable $e) {
        Flush::push(Flush::ERROR, "エラーが発生しました");
        redirect('/register');
    }
}

これらの関数をインターフェイスとして以下のように切り出します。それぞれの関数名も責務が分かりやすい名称に変更しておきます。

<?php

declare(strict_types=1);

namespace Domain\Repositories;

use Domain\Entities\User;

interface UserRepositoryInterface
{
    /**
     * ユーザー名で検索します
     *
     * @return User|null
     */
    public function findByUsername(string $username): ?User;

    /**
     * ユーザーを永続化(新規作成)
     *
     * 挿入後 User を返します
     *
     * @return User 保存後のユーザーエンティティ
     */
    public function save(User $user): User;
}

以上でエンティティ層の作成は終了です。切り出されたファイルは以下の場所に保存しておきます。

<app>
└─ src/
    ├─ adapter/
    ├─ application/
    ├─ domain/
    │  ├─ Entities/
    │  │   └─ User.php
    │  └─ Repositories/
    │       └─ UserRepositoryInterface.php
    └─ infrastructure/
ビジネスロジックをユースケースに切り出します
・入出力オブジェクトを作成します

まず最初にユーザーの登録処理に必要な入出力データを考えてみます。

入力時には YelpCamp アプリのユーザー登録フォームより以下のデータが渡されます。

  • ユーザー名
  • メールアドレス
  • パスワード

これらのデータを持つ「UserRegisterInput」という入力用のクラスを作成します。

<?php

declare(strict_types=1);

namespace Application\UseCases\User;

/**
 * ユーザー登録ユースケースの入力データ
 * (Controller や CLI などの外側の層から渡される)
 */
final class UserRegisterInput
{
    public function __construct(
        private string $username,
        private string $email,
        private string $plainPassword
    ) {}

    public function username(): string
    {
        return $this->username;
    }

    public function email(): string
    {
        return $this->email;
    }

    public function plainPassword(): string
    {
        return $this->plainPassword;
    }
}

出力用のデータでは以下の項目が必要です。

  • ユーザー名 (コントローラーでセッション管理に必要)

上記のデータを持つ「UserRegisterOutput」という出力用のクラスを作成します。

<?php

declare(strict_types=1);

namespace Application\UseCases\User;

/**
 * ユーザー登録ユースケースの出力データ
 * Controller はこれをもとに ViewModel やレスポンスを組み立てる。
 */
final class UserRegisterOutput
{
    public function __construct(
        private string $username
    ) {}

    public function username(): string
    {
        return $this->username;
    }
}
・例外クラスを作成します

ベースアプリではエラーが発生した場合フラッシュメッセージを呼び出していましたが、この仕組みはこの層の外側にある Adapter (Presentation) 層の機能なので実装方法を変更します。エラーが発生した場合は例外をスローして、外側 (Adapter 層) で例外をキャッチしてフラッシュメッセージに変換するようにします。

とりあえず「ApplicationException」というクラスを作成して、Application 層のエラーはこのクラスを使用して例外をスローします。将来的に細かく例外を判定する必要が生じた場合は、このクラスの派生クラスなどで対応するようにします。

<?php

declare(strict_types=1);

namespace Application\Exception;

use RuntimeException;

final class ApplicationException extends RuntimeException {}
・ユースケースを作成します

次に処理の本体である「UserRegisterUseCase」というユースケースクラスを作成してユーザー登録処理を記述します。

このクラスは以下の 2 つの部分で構成されています。

  • UserRepositoryInterface の実体をコンストラクターで注入 (24 ~ 26 行目)
  • 「execute」関数でユーザー情報をデータベースに保存 (28 ~ 55 行目)

データベース操作はコンストラクターで注入された、リポジトリインターフェイスを使用して行います。

<?php

declare(strict_types=1);

namespace Application\UseCases\User;

use Domain\Entities\User;
use Domain\Repositories\UserRepositoryInterface;
use Application\Exception\ApplicationException;

/**
 * ユーザー登録ユースケース
 *
 * - 重複ユーザー名チェック
 * - パスワードハッシュ
 * - User エンティティ生成
 * - リポジトリへ保存
 *
 * フラッシュメッセージやレスポンス生成は行わず、
 * 例外や Output を返すところまでが責務。
 */
final class UserRegisterUseCase implements UserRegisterUseCaseInterface
{
    public function __construct(
        private UserRepositoryInterface $userRepository
    ) {}

    public function execute(UserRegisterInput $input): UserRegisterOutput
    {
        // 1. ユーザー名の重複チェック
        $existing = $this->userRepository->findByUsername($input->username());
        if ($existing !== null) {
            // Controller など外側の層で catch してフラッシュメッセージに変換する
            throw new ApplicationException('そのユーザー名はすでに使われています。');
        }

        // 2. パスワードハッシュ(ドメインでは平文を持たない)
        $passwordHash = password_hash($input->plainPassword(), PASSWORD_DEFAULT);

        // 3. User エンティティ生成
        $user = User::create(
            username: $input->username(),
            email: $input->email(),
            passwordHash: $passwordHash,
        );

        // 4. リポジトリ経由で永続化
        $saved = $this->userRepository->save($user);

        // 5. 出力モデルに詰めて返す
        return new UserRegisterOutput(
            username: $saved->username(),
        );
    }
}
・ユースケースのインターフェイスを作成します

最後にこのユースケースの呼び出し方を定義するインターフェイスを作成します。
このユースケースは、外側 (Adapter 層) の Controller から呼ばれますが、インターフェイスを定義しておく事により Controller を修正することなく

  • イベント発行する版の UserRegisterUseCase
  • メール送信も行う UserRegisterUseCase
  • テスト用の FakeUserRegisterUseCase

などの差し替えが可能になります。イメージ的には以下のようになります。依存の逆転方法と構造はほぼ同じですが、インターフェイスの実装のされ方が異なります。Infrastructure 層の実装では外側のデータベースの交換が可能でしたが、Application 層のインターフェイスは「内側の交換」が可能になります。

実際のコードは以下のようになります。

<?php

namespace Application\UseCases\User;

interface UserRegisterUseCaseInterface
{
    public function execute(UserRegisterInput $input): UserRegisterOutput;
}

「UserRegisterUseCase.php」ファイルのクラスも、このインターフェイスを実装するように修正しておきます。

final class UserRegisterUseCase implements UserRegisterUseCaseInterface
{
    public function __construct(
        private UserRepositoryInterface $userRepository
    ) {}

    public function execute(UserRegisterInput $input): UserRegisterOutput
    {
        // ここにビジネスフロー本体
    }
}

以上でユースケース層の作成は終了です。作成したファイルは以下の場所に保存しておきます。

<app>
└─ src/
    ├─ adapter/
    ├─ application/
    │  ├─ Exception/
    │  │   └─ ApplicationException.php
    │  └─ UseCases/
    │      └─ User/
    │          ├─ UserRegisterInput.php
    │          ├─ UserRegisterOutput.php
    │          ├─ UserRegisterUseCase.php
    │          └─ UserRegisterUseCaseInterface.php
    ├─ domain/
    └─ infrastructure/
Adapter (Controller / Presentation) 層を実装します

Adapter 層にコントローラーを実装します。操作対象である UserRegisterUseCase のインスタンスはコンストラクターで注入されます。

このクラスでは以下の処理を行っています。

  • 入力されたパラーメーターを取り出してユースケースの入力データを作成
  • ユースケースの実行
  • ログイン処理
  • フラッシュメッセージのセットとリダイレクト
  • 内側の層で発生した例外をフラッシュメッセージに変換

ログイン処理は最終的に「UserLoginUseCase」などを作成して移譲するべきですが、今回はなるべく修正をしない方針なので、ベースアプリの機能を使用しています。

<?php

declare(strict_types=1);

namespace Adapter\Controllers;

use Application\UseCases\User\UserRegisterUseCaseInterface;
use Application\UseCases\User\UserRegisterInput;
use Application\UseCases\User\UserRegisterOutput;
use Application\Exception\ApplicationException;
use models\UserSchema;
use utils\Flush;
use InvalidArgumentException;
use Throwable;

final class UserController
{
    public function __construct(
        private UserRegisterUseCaseInterface $registerUseCase
    ) {}

    public function register(): void
    {
        try {
            // 1. リクエスト入力を取得(UI層の責務)
            $input = new UserRegisterInput(
                username: get_param('username', ''),
                email: get_param('email', ''),
                plainPassword: get_param('password', ''),
            );

            // 2. ユースケース呼び出し
            $output = $this->registerUseCase->execute($input);

            // 3. ログイン状態にする(UI/Infraの責務)
            $this->login($output);

            // 4. フラッシュ+リダイレクト(UI 層の責務)
            Flush::push(Flush::INFO, 'Yelp Campへようこそ!');
            redirect('/campgrounds');
            return;

            // 業務的エラー(重複ユーザーなど)
        } catch (ApplicationException | InvalidArgumentException $e) {
            Flush::push(Flush::ERROR, $e->getMessage());
            redirect('/register');
            return;

            // 予期しないエラー(DB障害など)
        } catch (Throwable $e) {
            Flush::push(Flush::ERROR, 'エラーが発生しました');
            redirect('/register');
            return;
        }
    }

    /**
     * UserRegisterOutput を元に簡易ログインを行う
     */
    private function login(UserRegisterOutput $data): void
    {
        // 既存 UserSchema を再構築(最低限の項目だけセット)
        $schema = new UserSchema();
        $schema->username = $data->username();

        // 既存 Session 機能を使ってログイン状態にする
        UserSchema::setSession($schema);
    }
}

以上で Adapter 層の作成は終了です。作成したファイルは以下の場所に保存しておきます。

<app>
└─ src/
    ├─ adapter/
    |   └─ Controllers/
    |      └─ UserController.php
    ├─ application/
    ├─ domain/
    └─ infrastructure/
Infrastructure (データベース / Router) 層を実装します
・データベースを実装します

まず最初にデータベースを実装していきます。ドメイン層で定義したインターフェイス「UserRepositoryInterface」を実装したクラス「PdoUserRepository」を作成します。実際のコードは以下のようになっています。データの保存はベースアプリと同じ「PDO」を使用しています。

<?php

declare(strict_types=1);

namespace Infrastructure\Db\User;

use PDO;
use Domain\Entities\User;
use Domain\Repositories\UserRepositoryInterface;

final class PdoUserRepository implements UserRepositoryInterface
{
    public function __construct(
        private PDO $pdo
    ) {}

    /**
     * ユーザー名で検索
     */
    public function findByUsername(string $username): ?User
    {
        $sql = 'SELECT username, email, password FROM users WHERE username = :username LIMIT 1';

        $stmt = $this->pdo->prepare($sql);
        $stmt->bindValue(':username', $username, PDO::PARAM_STR);
        $stmt->execute();

        /** @var array{username: string, email: string, password: string}|false $row */
        $row = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$row) {
            return null;
        }

        return new User(
            username: $row['username'],
            email: $row['email'],
            passwordHash: $row['password']
        );
    }

    /**
     * 新規ユーザー登録
     */
    public function save(User $user): User
    {
        $sql = 'INSERT INTO users (username, email, password) 
                VALUES (:username, :email, :password)';

        $stmt = $this->pdo->prepare($sql);

        $stmt->bindValue(':username', $user->username(), PDO::PARAM_STR);
        $stmt->bindValue(':email', $user->email(), PDO::PARAM_STR);
        $stmt->bindValue(':password', $user->passwordHash(), PDO::PARAM_STR);

        $stmt->execute();

        // 保存後の最新 User エンティティを返す
        // (将来 id などが追加された場合に備えて新しいインスタンスを返す)
        return new User(
            username: $user->username(),
            email: $user->email(),
            passwordHash: $user->passwordHash()
        );
    }
}
・ルーターを作成します

コントローラーへのルーティングクラスもこの層に実装します。

イメージですが、まず以下のような配列をコンストラクターで注入しておきます。その後ベースアプリで定義された HttpMethod 定数 (GET、POST 等) とエンドポイント (users/register 等) をキーにして、得られた配列にあるオブジェクト (コントローラー) の指定メソッドを実行します。

$routes = [
    'POST register' => [$userController, 'register'],
];

コードにすると以下のようになります。ルートが見つからない場合は ベースアプリで定義されている NotFoundException 例外をスローして、index.php で 404 ページを表示します。

<?php

declare(strict_types=1);

namespace Infrastructure\Http;

use HttpMethod;
use utils\NotFoundException;

final class Router
{
    /**
     * @param array<string, array{0: object, 1: string}> $routes
     */
    public function __construct(
        private array $routes
    ) {}

    public function dispatch(HttpMethod $method, string $path): void
    {
        $key = $method->name . ' ' . $path;

        if (!isset($this->routes[$key])) {
            throw new NotFoundException();
        }

        [$controller, $action] = $this->routes[$key];

        $controller->$action();
    }
}

このクラスの「dispatch」関数を呼ぶために、既存のルーター関数に「Router」インスタンスを引数として追加します。あとは「POST」メソッドの処理を書き替えて新しいルーター関数にバイパスします。「Router」インスタンスはさらに外側にある「index.php」で注入するようにします。今回は「登録処理(POST)」のみを縦スライスで移行します。

<?php

namespace routes;

use HttpMethod;
use Infrastructure\Http\Router;

function users(HttpMethod $method, string $rpath, Router $router): bool
{
    switch (true) {
        case $rpath === 'register':
            switch ($method) {
                case HttpMethod::GET:
                    \view\users\register\index();
                    break;

                case HttpMethod::POST:
                    // \controller\users\register();
                    $router->dispatch($method, $rpath);
                    break;
            }
            break;
・前準備を行うファイルを作成します

リファクタリングはほぼ終了ですが、実際に動作させるには

  • データベース操作用インスタンス (PdoUserRepository) の作成
  • ユースケース (UserRegisterUseCase) の作成
  • コントローラー (UserController) の作成
  • ルーティングテーブル / Router インスタンスの作成

などの処理を行って、それぞれ適切な場所に注入する必要があります。「container.php」というファイルを作成して、必要な処理をまとめておきます。「build_router」という関数で上記の処理を一括して行っています。

<?php

declare(strict_types=1);

namespace Infrastructure\Bootstrap;

use PDO;
use Domain\Repositories\UserRepositoryInterface;
use Application\UseCases\User\UserRegisterUseCase;
use Application\UseCases\User\UserRegisterUseCaseInterface;
use Adapter\Controllers\UserController;
use Infrastructure\Http\Router;
use Infrastructure\Db\User\PdoUserRepository;

/** 
 * @param array{
 *   driver?: string,
 *   dsn: string,
 *   user: string|null,
 *   pass: string|null,
 * } $config
 */
function build_pdo(array $config): PDO
{
    return new PDO(
        $config['dsn'],
        $config['user'],
        $config['pass'],
        [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
    );
}

function build_user_repository(PDO $pdo): UserRepositoryInterface
{
    return new PdoUserRepository($pdo);
}

function build_user_register_usecase(UserRepositoryInterface $repo): UserRegisterUseCaseInterface
{
    return new UserRegisterUseCase($repo);
}

function build_user_controller(UserRegisterUseCaseInterface $uc): UserController
{
    return new UserController($uc);
}

/**
 * ルーターまで含めて「全部組み立てて返す」コンポジション
 *
 * @param array{
 *     db: array{
 *         driver?: string,
 *         dsn: string,
 *         user: string|null,
 *         pass: string|null,
 *     }
 * } $config
 */
function build_router(array $config): Router
{
    $pdo            = build_pdo($config['db']);
    $userRepository = build_user_repository($pdo);
    $registerUc     = build_user_register_usecase($userRepository);
    $userController = build_user_controller($registerUc);

    $routes = [
        'POST register' => [$userController, 'register'],
    ];

    return new Router($routes);
}

ここまで作成したファイルはそれぞれ以下の場所に保存しておきます。

<app>
└─ src/
    ├─ adapter/
    ├─ application/
    ├─ domain/
    └─ infrastructure/
       ├─ Bootstrap/
       |  └─ container.php
       ├─ Db/
       |  └─ User/
       |     └─ PdoUserRepository.php
       └─ Http/
          └─ Router.php

あとはアプリの起点となる「index.php」ファイル内で、"全部入り" Router を作成して既存のルーティング関数に引数として渡してあげます。

/**
 * アプリケーション設定
 *
 * db.driver は将来的なデータベース実装切り替え用の識別子です。
 * (本記事では PDO 固定で使用します)
 *
 * @var array{
 *   db: array{
 *     driver?: string,
 *     dsn: string,
 *     user: string|null,
 *     pass: string|null,
 *   }
 * } $config
 */
$config = require __DIR__ . '/config.php';

require_once 'src/infrastructure/Bootstrap/container.php';

use Infrastructure\Bootstrap;

session_start();

$router = Bootstrap\build_router($config);

try {

    ['method' => $method, 'path' => $rpath] = get_method();

    switch (true) {

        case $rpath === '':
            require_once 'views/home.php';
            break;

        case \routes\users($method, $rpath, $router):
            break;

        case \routes\reviews($method, $rpath):
            break;

読み込んでいる設定ファイル「config.php」の最後に以下のような文を追加して、データベースへの接続情報を返すようにします。

return [
    'db' => [
        'driver' => 'pdo',
        'dsn'  => 'mysql:host=localhost;port=' . MYSQL_PORT . ';dbname=YelpCampCleanArch;charset=utf8mb4',
        'user' => 'clean_arch',
        'pass' => 'password',
    ],
];

以上で Infrastructure 層の作成は終了です。

動作確認を行います

リファクタリングが終了しましたので、実際に動作確認を行います。
VSCode のターミナル画面に「php -S localhost:8080」などと入力して、PHP の簡易サーバーを起動します。ブラウザーを起動して URL 欄に「localhost:8080/register」と入力して、新しいユーザーを登録します。

特に問題なく登録が完了してログイン状態になりました。

Xdebug が使用できる環境であれば、コントローラーファイル (src\adapter\Controllers\UserController.php) を開いて、「register」関数内にブレークポイントをセットして、デバッガーを起動しておくとステップ実行しながら確認できます。(Xdebug のセットアップ方法はお手数ですが、WSL2 の場合は こちらの記事、MAMP の場合は こちらの記事 などをご参照ください。)

データベースも問題ないようです。

mysql> use YelpCampCleanArch;
Database changed
mysql> select * from users;
+----------+-----------------+--------------------------------------------------------------+
| username | email           | password                                                     |
+----------+-----------------+--------------------------------------------------------------+
| test     | test@test       | $2y$10$VY0ST5jYSJqdcYJTDBm7q.WeipDtkSb2y1P6jgPZj1/zmKExCE.ii |
| test2    | test2@test2.com | $2y$10$97sgr/lrOfFmbSbVwYWObe8DoJRPqO/NgXbWZ2Jwx41npiVvYysUW |
+----------+-----------------+--------------------------------------------------------------+
2 rows in set (0.00 sec)

mysql>
モックを使用してテストを行います

リファクタリング後の動作確認も兼ねて、テストケースを作成しておきます。ベースアプリには既に、「Pest」、「PHPUnit」、「Codeception」がセットアップされています。今回は Pest を使用して単体テストを行いたいので、以下の「Unit」フォルダ内にテストを書いていきます。ソースファイルと同じディレクトリ構造にしておくと、あとでテストケースを探すのが楽かもしれません。

<app>
   └─ tests/
      ├─ Pest.php
      ├─ Codeception/
      ├─ Pest/
      │  ├─ Feature/
      │  └─ Unit/
      │     └─ Application/
      │        └─ UseCases/
      │           └─ User/
      │              └─ UserRegisterUseCaseTest.php
      └─PhpUnit/

「UserRegisterUseCaseTest.php」というファイルを作成して、以下のテストケースを追加します。Pest では PHPUnit の MockBuilder がそのまま使えるので、「createMock」関数を使用して実際のデータベースにアクセスせずにユーザー登録などのテストが行えます。

以下のテストケースでは

  • 新規ユーザー登録 (正常処理)
  • 重複ユーザー登録 (エラーのため例外を投げます)

というユースケースの動作を、モックを使用してテストしています。

<?php

use Domain\Entities\User;
use Domain\Repositories\UserRepositoryInterface;
use Application\UseCases\User\UserRegisterInput;
use Application\UseCases\User\UserRegisterOutput;
use Application\UseCases\User\UserRegisterUseCase;
use Application\Exception\ApplicationException;

it('registers a new user successfully', function () {

    // -------- Mock Repository --------
    $repo = $this->createMock(UserRepositoryInterface::class);

    // findByUsername → null(同名ユーザーなし)
    $repo->method('findByUsername')
        ->willReturn(null);

    // save() されたら、User を返す
    $repo->method('save')
        ->willReturnCallback(function (User $user) {
            return new User(
                username: $user->username(),
                email: $user->email(),
                passwordHash: $user->passwordHash(),
            );
        });

    // -------- UseCase --------
    $useCase = new UserRegisterUseCase($repo);

    $input = new UserRegisterInput(
        username: 'alice',
        email: 'alice@example.com',
        plainPassword: 'secret123'
    );

    $output = $useCase->execute($input);

    // -------- Assertions --------
    expect($output)->toBeInstanceOf(UserRegisterOutput::class);
    expect($output->username())->toBe('alice');
});

it('throws when username already exists', function () {

    // -------- Mock Repository --------
    $repo = $this->createMock(UserRepositoryInterface::class);

    // findByUsername → 既存ユーザーを返す
    $repo->method('findByUsername')
        ->willReturn(
            new User(
                username: 'alice',
                email: 'old@example.com',
                passwordHash: 'hashed_password'
            )
        );

    $useCase = new UserRegisterUseCase($repo);

    $input = new UserRegisterInput(
        username: 'alice',
        email: 'alice@example.com',
        plainPassword: 'secret123'
    );

    $this->expectException(ApplicationException::class);

    $useCase->execute($input);
});

VSCode のコンソール画面に「vendor/bin/pest」と入力するとテストが実行されます。

user01@DESKTOP-CQEA49P:~/YelpCampCleanArch$ vendor/bin/pest

   PASS  Tests\Pest\Unit\Application\UseCases\User\UserRegisterUseCaseTest
  ✓ it registers a new user successfully                                               0.04s  
  ✓ it throws when username already exists

  Tests:    2 passed (3 assertions)
  Duration: 0.08s

user01@DESKTOP-CQEA49P:~/YelpCampCleanArch$ 

MAMP で実行したい場合は、「composer.json」ファイルを開いて、「"pestphp/pest": "^3.0"」と修正してからVSCode のターミナル画面で「composer update」と入力すると実行できるようになります。

    "require-dev": {
        "phpstan/phpstan": "^2.1",
        "pestphp/pest": "^3.0",
        "codeception/codeception": "^5.3",
        "codeception/module-webdriver": "^4.0",
        "codeception/module-phpbrowser": "^3.0",
        "codeception/module-asserts": "^3.0",
        "phpstan/phpstan-strict-rules": "^2.0"
    },

Xdebug が使用できる環境であれば、デバッガーを使用してテストケースをステップ実行することが可能です。

まとめ

今回作成したファイルの一覧は以下のようになっています。今後リファクタリングを続けていくとユースケースがどんどん増えていきますので、「UseCases」フォルダ内の階層がどんどん深くなっていくと思われます。

<app>
└─ src/
   ├─ adapter
   │  └─ Controllers
   │     └─ UserController.php
   ├─ application
   │  ├─ Exception
   │  │  └─ ApplicationException.php
   │  └─ UseCases
   │     └─ User
   │        ├─ UserRegisterInput.php
   │        ├─ UserRegisterOutput.php
   │        ├─ UserRegisterUseCase.php
   │        └─ UserRegisterUseCaseInterface.php
   ├─ domain
   │  ├─ Entities
   │  │  └─ User.php
   │  └─ Repositories
   │     └─ UserRepositoryInterface.php
   └─ infrastructure
      ├─ Bootstrap
      │  └─ container.php
      ├─ Db
      │  └─ User
      │     └─ PdoUserRepository.php
      └─ Http
         └─ Router.php

PHPStan も特に問題ないようです。

今回は以上です。
次回は「拡張編」として、データベースを Laravel の Eloquent に置き換えてみます。