2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PHPStan(Larastan) level maxが通るようにLaravelでクリーンアーキテクチャを実装

Posted at

はじめに

実務では静的解析ツール(Larastan)を最高レベルに設定してしまうと警告が多発してしまうため控えておりましたが、
個人開発で最高レベルに設定しても問題ございませんように実装ができましたので参考になれば幸いです。

Larastan導入に関する参考記事

Larastanの設定レベルに関する参考記事

クリーンアーキテクチャとは

今回の静的解析設定ファイル
phpstan.neon
includes:
    - ./vendor/larastan/larastan/extension.neon

parameters:
    level: max
    paths:
        - app
        - bootstrap
        - database
        - routes

結論

  • レイヤーを跨ぎデータを受け渡す際にはDTO(Data Transfer Object)を使用する
  • 配列(Collectionを含む)を使用しない
  • repositoryでDTOを生成する際にstdClassを使用する
    • 後ほど詳細に説明いたします

前提

  • Laravel version: 11.14.0

その他意識したこと

  • Frameworksに依存しないようにDomainフォルダ内でLaravelの固有のクラス(collection, carbon, etc.)を使用しない

フォルダ構成

routesやresourcesや今回の記事に不要な処理などは除いております

.
└── app/
    ├── Domain/
    │   ├── Project/
    │   │   ├── ValueObject/
    │   │   │   ├── Email.php
    │   │   │   └── Name.php
    │   │   ├── ProjectDto.php
    │   │   ├── ProjectEntity.php
    │   │   ├── ProjectFactory.php
    │   │   └── ProjectRepositoryInterface.php
    │   └── ...
    ├── Http/
    │   └── Controllers/
    │       └── ProjectController.php
    ├── Infrastructure/
    │   └── Repositories/
    │       └── ProjectRepository.php
    └── UseCases/
        └── Project/
            └── ProjectFindAction.php

各ファイル内容

ProjectController.php

コントローラでDTOからEntityを生成

ProjectController.php
<?php

namespace App\Http\Controllers;

use App\Domain\Project\ProjectFactory;
use App\UseCases\Project\ProjectFindAction;
use Inertia\Response;

class ProjectController extends Controller
{
    /**
     * プロジェクト一覧画面
     *
     * @param \App\UseCases\Project\ProjectFindAction $projectFindAction
     * @return \Inertia\Response
     */
    public function index(ProjectFindAction $projectFindAction): Response
    {
        $projectDtoArr   = $projectFindAction->findAll();
        $projectEntities = array_map(function ($dto) {
            return ProjectFactory::create($dto);
        }, $projectDtoArr);

        // ...その他画面表示などの処理
    }
}
ProjectFindAction.php

DTOの一次元配列であることをPHPDocで明示的に宣言

ProjectFindAction.php
<?php

namespace App\UseCases\Project;

use App\Domain\Project\ProjectDto;
use App\Domain\Project\ProjectRepositoryInterface;

/**
 * Use cases for project acquisition
 */
final class ProjectFindAction
{
    private ProjectRepositoryInterface $repository;

    public function __construct(ProjectRepositoryInterface $repository)
    {
        $this->repository = $repository;
    }

    /**
     * プロジェクトを全て取得
     *
     * @return ProjectDto[]
     */
    public function findAll(): array
    {
        return $this->repository->findAll();
    }
ProjectRepositoryInterface.php
ProjectRepositoryInterface.php
<?php

namespace App\Domain\Project;

/**
 * Interface with external(DB)
 */
interface ProjectRepositoryInterface
{
    public const TABLE_NAME = 'projects';

    /**
     * 全てのプロジェクトを取得
     *
     * @return ProjectDto[]
     */
    public function findAll(): array;
ProjectRepository.php

DTOの一次元配列であることを関数を対象とするもの(interfaceで宣言済)とは別に返り値を対象にPHPDocで明示的に宣言

ProjectRepository.php
<?php

namespace App\Infrastructure\Repositories;

use App\Domain\Project\ProjectDto;
use App\Domain\Project\ProjectRepositoryInterface;
use Illuminate\Support\Facades\DB;
use stdClass;

/**
 * Project DB repository
 */
final class ProjectRepository implements ProjectRepositoryInterface
{
    public function findAll(): array
    {
        /** @var ProjectDto[] */
        return DB::table(ProjectRepositoryInterface::TABLE_NAME)
            ->whereNull('deleted_at')
            ->get()
            ->map(function ($value) {
                /** @var stdClass $value */
                return new ProjectDto(
                    id: $value->id,
                    departmentId: $value->department_id,
                    name: $value->name,
                    summary: $value->summary,
                );
            })
            ->toArray();
    }

なぜstdClassで宣言しているかについて 

こちらが今回の記事の重要なポイントです

  • そのままの状態では対象のプロパティが存在しないと警告が発生
    • id: $value->id,
  • stdClass - 公式ドキュメント
  • DTOで宣言してしまうとカラム名のスネークケースとPHPで一般的なキャメルケースで齟齬が生まれてしまい警告が発生
  • その他Modelクラスなどで宣言してしまうとテーブルを結合した際やカラム名に対してエイリアスを使用した際に警告が発生
  • 以上の理由から動的プロパティは非推奨ですが警告の発生を抑えるため限られた範囲内でのみ使用を許可いたしましたが...より良い方法があればご教示いただきたいです!
ProjectFactory.php

DTOからビジネスロジックをプロパティに持たせる(値オブジェクトを使用する)形でEntityを生成

ProjectFactory.php
<?php

namespace App\Domain\Project;

use App\Domain\Project\ValueObject\Name;
use App\Domain\Project\ValueObject\Summary;

final class ProjectFactory
{
    public static function create(ProjectDto $dto): ProjectEntity
    {
        return new ProjectEntity(
            $dto->id,
            $dto->departmentId,
            new Name($dto->name),
            new Summary($dto->summary),
        );
    }
}
Name.php
Name.php
<?php

namespace App\Domain\Project\ValueObject;

use App\Domain\ValueObjectInterface\StringValue;
use InvalidArgumentException;

/**
 * プロジェクト名VO
 */
final class Name implements StringValue
{
    public const MAX_LEN = 100;

    public function __construct(private string $value)
    {
        $this->validate($value);
        $this->value = $value;
    }

    public function validate(string $value): void
    {
        if (mb_strlen($value) > self::MAX_LEN) {
            throw new InvalidArgumentException(
                sprintf(
                    '%s must be %d or less characters, got %d.',
                    __CLASS__,
                    self::MAX_LEN,
                    mb_strlen($value),
                ),
            );
        }
    }

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

※StringValue interfaceではvalidateメソッドの実装の強制とvalueメソッドでstring型を返すことを強制しております。

Summary.phpに関しましては、Name.phpと実装がほぼ同じであるため記述を省略しております。

ProjectEntity.php

今後プロパティに変更が生じさせないためイミュータブル(不変)になるように定義

ProjectEntity.php
<?php

namespace App\Domain\Project;

use App\Domain\Project\ValueObject\Name;
use App\Domain\Project\ValueObject\Summary;

/**
 * プロジェクトentity
 */
final class ProjectEntity
{
    private ?int $id;
    private int $departmentId;
    private Name $name;
    private Summary $summary;

    public function __construct(
        private ?int $id,
        private int $departmentId,
        private Name $name,
        private Summary $summary,
    ) {
        $this->id           = $id;
        $this->departmentId = $departmentId;
        $this->name         = $name;
        $this->summary      = $summary;
    }

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

    public function getDepartmentId(): int
    {
        return $this->departmentId;
    }

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

    public function getSummary(): Summary
    {
        return $this->summary;
    }

    /**
     * Property to array
     *
     * @return array<string, int|null|string>
     */
    public function toArray(): array
    {
        return [
            'id'           => $this->id,
            'departmentId' => $this->departmentId,
            'name'         => $this->name->value(),
            'summary'      => $this->summary->value(),
        ];
    }
}

もしプロパティを連想配列で返す場合はPHPDocでデータ型を細かく指定

余談

  • こちらのサイトですがフォルダ構成を表現する際にとても助かりました:bow:

  • LaravelのライブラリのCarbonですがFrameworksに依存させるさせないの前に継承元のDateTimeクラスが非推奨になったため、そもそも使用を避けておりましたが、しっかりDateTimeImmutableクラスを継承したCarbonImmutableが存在することを最近知りました

  • 今回作成したドメインですが今後オーバーヘッドが増えそうなので、DBに連動したCoreDomain、アプリケーションの操作に連動したDomainなど区別して定義しても良いかと感じました。
2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?