目次
概要
本(Book)のCRUD処理をLaravelを使って、レイヤード・アーキテクチャで書いてみました。
環境構築には、Laravel Sail を使います。
開発環境
OS
- Windows11上のWSL2で動作するUbuntu
以下がインストールされている事
- Composer v2以上
環境構築
# Laravel をインストール(プロジェクト名:myapp)
$ composer create-project --prefer-dist laravel/laravel myapp
# Laravel Sail をインストール
$ cd myapp
$ composer require laravel/sail --dev
# Laravel Sail の設定。必要なサービスをSpaceキーで選択してEnter(※mysql, redis を選択しておく)
$ php artisan sail:install
┌ Which services would you like to install? ───────────────────┐
│ › ◻ mysql ┃ │
│ ◻ pgsql │ │
│ ◻ mariadb │ │
│ ◻ mongodb │ │
│ ◻ redis │ │
└────────────────────────────────────────────────── 0 selected ┘
.envを以下のように変更します。
:
APP_LOCALE=ja # jaに変更
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=ja_JP # ja_JPに変更
:
SESSION_DRIVER=redis # redisに変更
:
CACHE_DRIVER=redis # 追加
:
Laravel Sail では最初からテスト用の DB「testing」が用意されているので、.env を基にテスト用の環境設定ファイルを作成します。
$ cp .env .env.testing
.env.testing の内容を以下のように変更します。
APP_ENV=testing # testingに変更
DB_DATABASE=testing # testingに変更
phpunit.xml も以下のように修正します。
<env name="DB_DATABASE" value=":memory:"/>
↓
<env name="DB_DATABASE" value="testing"/>
Laravel Sail を起動します。
$ ./vendor/bin/sail up -d
DB のマイグレーションを実施します。
$ ./vendor/bin/sail artisan migrate # ローカル用
$ ./vendor/bin/sail artisan migrate --env=testing # テスト用
ブラウザで http://localhost/ にアクセスし、Laravel の画面が表示できれば成功です。
Book プログラムの構成
レイヤードアーキテクチャの各層と、ディレクトリの関係は以下です。
app/
├── Http/
│ └── Controllers/
│ └── BookController.php ← (1) プレゼンテーション層
├── Services/
│ └── BookService.php ← (2) アプリケーション層
├── Repositories/
│ ├── BookRepositoryInterface.php ← (3) リポジトリ層(インターフェース)
│ └── BookRepository.php ← (4) リポジトリ層(実装)
├── Models/
│ └── Book.php ← (5) ドメイン層(Eloquentモデル)※php artisanコマンドで作成
本体プログラムのコード
カスタム例外処理の作成
まず先に例外処理を作ります。
$ php artisan make:exception BookNotFoundException
<?php
namespace App\Exceptions;
use Exception;
class BookNotFoundException extends Exception
{
protected $message = '本がみつかりません';
}
(5) のドメイン層とマイグレーション作成
以下コマンドで Book モデルとマイグレーションを作成します。
$ php artisan make:model Book -m
Bookモデルはの内容は以下となります。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Book extends Model
{
use HasFactory; // Laravel 8以降では、Book モデルでファクトリを利用するためには、HasFactory トレイトを使用する必要があります
protected $fillable = ['title', 'author', 'description']; // 一括代入(fillable)を設定
}
マイグレーションファイルの内容は以下となります。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('books', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('author');
$table->text('description')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('books');
}
};
以下コマンドで、Bookテーブルのマイグレーションを実施します。
$ ./vendor/bin/sail artisan migrate # ローカル用
$ ./vendor/bin/sail artisan migrate --env=testing # テスト用
(3)(4) の Repository 層のインターフェース&実装
Repositories ディレクトリと、インターフェースのファイルを作成します。
$ mkdir -p app/Repositories
$ touch app/Repositories/BookRepositoryInterface.php #インターフェース
インターフェースの内容は以下となります。
<?php
namespace App\Repositories;
use App\Models\Book;
use Illuminate\Support\Collection;
interface BookRepositoryInterface
{
public function all(): Collection;
public function find(int $id): Book;
public function create(array $data): Book;
public function update(Book $book, array $data): bool;
public function delete(Book $book): bool;
}
実装のファイルを作成します。
touch app/Repositories/BookRepository.php # 実装
実装の内容は以下となります。
<?php
namespace App\Repositories;
use App\Models\Book;
use Illuminate\Support\Collection;
class BookRepository implements BookRepositoryInterface
{
public function all(): Collection
{
return Book::all();
}
public function find(int $id): Book
{
return Book::findOrFail($id);
}
public function create(array $data): Book
{
return Book::create($data);
}
public function update(Book $book, array $data): bool
{
return $book->update($data);
}
public function delete(Book $book): bool
{
return $book->delete();
}
}
(2) のアプリケーション層の実装
Services ディレクトリと、Service ファイルを作成します。
$ mkdir app/Services
$ touch app/Services/BookService.php
Service ファイルの内容は以下となります。
<?php
namespace App\Services;
use App\Models\Book;
use App\Repositories\BookRepositoryInterface;
use App\Exceptions\BookNotFoundException;
use Illuminate\Support\Collection;
class BookService
{
public function __construct(
protected BookRepositoryInterface $bookRepository
) {}
public function getAll(): Collection
{
return $this->bookRepository->all();
}
public function find(int $id): Book
{
try {
return $this->bookRepository->find($id);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
throw new BookNotFoundException();
}
}
public function create(array $data): Book
{
return $this->bookRepository->create($data);
}
public function update(Book $book, array $data): bool
{
return $this->bookRepository->update($book, $data);
}
public function delete(Book $book): bool
{
return $this->bookRepository->delete($book);
}
}
(1) のプレゼンテーション層の実装
コントローラーを作成します。
$ php artisan make:controller BookController --resource # CRUDメソッドも自動作成
コントローラーの内容は以下となります。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Exceptions\BookNotFoundException;
use App\Services\BookService;
use App\Models\Book;
class BookController extends Controller
{
public function __construct(
protected BookService $bookService
) {}
public function index()
{
$books = $this->bookService->getAll();
return view('books.index', compact('books'));
}
public function create()
{
return view('books.create');
}
public function store(Request $request)
{
$data = $request->validate([
'title' => 'required|string',
'author' => 'required|string',
'description' => 'nullable|string',
]);
$this->bookService->create($data);
return redirect()->route('books.index')->with('success', 'Book created!');
}
public function show($id)
{
try {
$book = $this->bookService->find($id);
return view('books.show', compact('book'));
} catch (BookNotFoundException $e) {
return response($e->getMessage(), 404);
// return redirect()->route('books.index')->with('error', $e->getMessage());
}
}
public function edit($id)
{
$book = $this->bookService->find($id);
return view('books.edit', compact('book'));
}
public function update(Request $request, $id)
{
$book = $this->bookService->find($id);
$data = $request->validate([
'title' => 'required|string',
'author' => 'required|string',
'description' => 'nullable|string',
]);
$this->bookService->update($book, $data);
return redirect()->route('books.index')->with('success', 'Book updated!');
}
public function destroy($id)
{
$book = $this->bookService->find($id);
$this->bookService->delete($book);
return redirect()->route('books.index')->with('success', 'Book deleted!');
}
}
サービスプロバイダでバインド
BookRepositoryInterface を必要としている場所には、BookRepository を渡すように Laravel に教えます。
use App\Repositories\BookRepositoryInterface; // 追加
use App\Repositories\BookRepository; // 追加
:
public function register(): void
{
$this->app->bind(BookRepositoryInterface::class, BookRepository::class); // 追加
}
Viewファイルの作成(必要最小限)
View 用 のディレクトリと、該当ファイルを作成します。
$ mkdir -p resources/views/books
$ touch resources/views/books/index.blade.php
$ touch resources/views/books/create.blade.php
$ touch resources/views/books/show.blade.php
$ touch resources/views/books/edit.blade.php
<h1>本の一覧</h1>
@if (session('success'))
<div style="color:green;">
{{ session('success') }}
</div>
@endif
<a href="{{ route('books.create') }}">新規作成</a>
<ul>
@foreach ($books as $book)
<li>
<a href="{{ route('books.show', $book->id) }}">{{ $book->title }}</a>
<a href="{{ route('books.edit', $book->id) }}">[編集]</a>
<form action="{{ route('books.destroy', $book->id) }}" method="POST" style="display:inline">
@csrf
@method('DELETE')
<button type="submit">削除</button>
</li>
@endforeach
</ul>
<h1>本の新規作成</h1>
<form method="POST" action="{{ route('books.store') }}">
@csrf
<div>
<label>タイトル:</label><br>
<input type="text" name="title" required>
</div>
<div>
<label>著者:</label><br>
<input type="text" name="author" required>
</div>
<div>
<label>説明:</label><br>
<textarea name="description" rows="4" required></textarea>
</div>
<button type="submit">登録</button>
</form>
<a href="{{ route('books.index') }}">← 一覧に戻る</a>
<h1>本の詳細</h1>
<p><strong>タイトル:</strong> {{ $book->title }}</p>
<p><strong>著者:</strong> {{ $book->author }}</p>
<p><strong>説明:</strong> {{ $book->description }}</p>
<a href="{{ route('books.edit', $book->id) }}">編集</a>
<a href="{{ route('books.index') }}">一覧に戻る</a>
<h1>本の編集</h1>
<form method="POST" action="{{ route('books.update', $book->id) }}">
@csrf
@method('PUT')
<div>
<label>タイトル:</label><br>
<input type="text" name="title" value="{{ $book->title }}" required>
</div>
<div>
<label>著者:</label><br>
<input type="text" name="author" value="{{ $book->author }}" required>
</div>
<div>
<label>説明:</label><br>
<textarea name="description" rows="4" required>{{ $book->description }}</textarea>
</div>
<button type="submit">更新</button>
</form>
<a href="{{ route('books.index') }}">← 一覧に戻る</a>
単体(Unit)テストのコード
ユニットテストなので、リポジトリ層とアプリケーション層の メソッド単位 のテストを行います。
まず最初にテスト用データを生成するファクトリを作成します。
ファクトリの作成
$ php artisan make:factory BookFactory --model=Book
ファクトリの内容は以下となります。
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class BookFactory extends Factory
{
public function definition(): array
{
return [
'title' => $this->faker->sentence,
'author' => $this->faker->name,
'description' => $this->faker->paragraph,
];
}
}
リポジトリ層のテストコード
以下のコマンドで リポジトリ層の Unit テスト用ファイルの雛形を作成します。
$ php artisan make:test BookRepositoryTest --unit
作成された Unit テスト用のファイルを以下のようにしてください。
<?php
namespace Tests\Unit;
use Tests\TestCase;
use App\Models\Book;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Repositories\BookRepository;
use App\Repositories\BookRepositoryInterface;
class BookRepositoryTest extends TestCase
{
use RefreshDatabase;
protected BookRepositoryInterface $bookRepo;
protected function setUp(): void
{
parent::setUp();
$this->bookRepo = new BookRepository();
}
public function test_allメソッドの確認()
{
Book::factory()->count(3)->create();
$books = $this->bookRepo->all();
$this->assertCount(3, $books);
}
public function test_createメソッドの確認()
{
$data = [
'title' => '私の本',
'author' => '山田太郎',
'description' => '本の説明'
];
$book = $this->bookRepo->create($data);
$this->assertDatabaseHas('books', ['title' => '私の本']);
$this->assertEquals('山田太郎', $book->author);
$this->assertEquals('本の説明', $book->description);
}
public function test_findメソッドの確認()
{
$book = Book::factory()->create();
$found = $this->bookRepo->find($book->id);
$this->assertEquals($book->title, $found->title);
}
public function test_updateメソッドの確認()
{
$book = Book::factory()->create();
$this->bookRepo->update($book, ['title' => '更新したタイトル']);
$this->assertDatabaseHas('books', ['title' => '更新したタイトル']);
}
public function test_deleteメソッドの確認()
{
$book = Book::factory()->create();
$this->bookRepo->delete($book);
$this->assertDatabaseMissing('books', ['id' => $book->id]);
}
}
アプリケーション層のテスト
以下のコマンドで アプリケーション層の Unit テスト用ファイルの雛形を作成します。
$ php artisan make:test BookServiceTest --unit
作成された Unit テスト用のファイルを以下のようにしてください。
<?php
namespace Tests\Unit;
use Tests\TestCase;
use App\Models\Book;
use App\Services\BookService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Repositories\BookRepositoryInterface;
class BookServiceTest extends TestCase
{
use RefreshDatabase;
protected BookService $bookService;
protected function setUp(): void
{
parent::setUp();
$this->bookService = app(BookService::class); // DIコンテナから取得
}
public function test_getAllメソッドの確認()
{
Book::factory()->count(2)->create();
$books = $this->bookService->getAll();
$this->assertCount(2, $books);
}
public function test_createメソッドの確認()
{
$data = [
'title' => '私の本',
'author' => '山田太郎',
'description' => '本の説明',
];
$book = $this->bookService->create($data);
$this->assertDatabaseHas('books', ['title' => '私の本']);
$this->assertEquals('山田太郎', $book->author);
$this->assertEquals('本の説明', $book->description);
}
public function test_findメソッドの確認(正常)()
{
$book = Book::factory()->create();
$found = $this->bookService->find($book->id);
$this->assertEquals($book->title, $found->title);
}
public function test_findメソッドの確認(例外:本が見つからなかった時)()
{
$this->expectException(\App\Exceptions\BookNotFoundException::class);
$this->bookService->find(999); // 存在しないID
}
public function test_updateメソッドの確認()
{
$book = Book::factory()->create(['title' => '古い']);
$this->bookService->update($book, ['title' => '新しい']);
$this->assertDatabaseHas('books', ['title' => '新しい']);
}
public function test_deleteメソッドの確認()
{
$book = Book::factory()->create();
$this->bookService->delete($book);
$this->assertDatabaseMissing('books', ['id' => $book->id]);
}
public function test_createメソッドの確認(内部で呼んでいるRepositoryをモックにした版)()
{
$repo = Mockery::mock(BookRepositoryInterface::class);
$repo->shouldReceive('create')->once()->andReturn(new Book(['title' => 'モックのタイトル']));
$service = new BookService($repo);
$book = $service->create(['title' => 'モックのタイトル']);
$this->assertEquals('モックのタイトル', $book->title);
}
}
単体(Unit)テストの実行結果
$ ./vendor/bin/sail up -d # Laravel起動
$ ./vendor/bin/sail artisan test tests/Unit # Unitフォルダ内のみ指定してテスト実行
PASS Tests\Unit\BookRepositoryTest
✓ allメソッドの確認 0.96s
✓ createメソッドの確認 0.02s
✓ findメソッドの確認 0.01s
✓ updateメソッドの確認 0.02s
✓ deleteメソッドの確認 0.02s
PASS Tests\Unit\BookServiceTest
✓ get allメソッドの確認 0.02s
✓ createメソッドの確認 0.02s
✓ findメソッドの確認(正常) 0.02s
✓ findメソッドの確認(例外:本が見つからなかった時) 0.02s
✓ updateメソッドの確認 0.02s
✓ deleteメソッドの確認 0.02s
✓ createメソッドの確認(内部で呼んでいる repositoryをモックにした版) 0.01s
Tests: 12 passed (17 assertions)
Duration: 1.20s
機能(Feature)テストのコード
機能テストなので、プレゼンテーション層の HTTPリクエスト単位 のテストを行います。
プレゼンテーション層のテストコード
以下のコマンドで プレゼンテーション層の Feature テスト用ファイルの雛形を作成します。
$ php artisan make:test BookControllerTest
作成された Feature テスト用のファイルを以下のようにしてください。
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Models\Book;
use Tests\TestCase;
class BookControllerTest extends TestCase
{
use RefreshDatabase;
public function test_indexアクションの確認()
{
Book::factory()->count(3)->create(); // 3件のダミーデータを作成
$response = $this->get(route('books.index'));
$response->assertStatus(200);
$response->assertViewIs('books.index'); // View名は books.indexか確認
$response->assertViewHas('books'); // Viewにbooksが渡されているか確認
$this->assertCount(3, $response->viewData('books')); // Viewに渡されたbooksの数を確認
}
public function test_createアクションの確認()
{
$response = $this->get(route('books.create'));
$response->assertStatus(200);
$response->assertViewIs('books.create'); // View名は books.createか確認
}
public function test_storeアクションの確認()
{
$data = [
'title' => 'PHP入門',
'author' => '山田太郎',
'description' => 'PHPの入門書です。',
];
$response = $this->post(route('books.store'), $data);
$response->assertRedirect(route('books.index')); // リダイレクト先の確認
$response->assertSessionHas('success', 'Book created!'); // セッションに成功メッセージがあるか確認
$this->assertDatabaseHas('books', $data); // データベースにデータが保存されているか確認
}
public function test_showアクションの確認(正常)()
{
$book = Book::factory()->create(); // ダミーデータを作成
$response = $this->get(route('books.show', $book->id));
$response->assertStatus(200);
$response->assertViewIs('books.show'); // View名は books.showか確認
$response->assertViewHas('book', $book); // Viewにbookが渡されているか確認
$response->assertSee($book->title); // Viewにタイトルが表示されているか確認
$response->assertSee($book->author); // Viewに著者名が表示されているか確認
$response->assertSee($book->description); // Viewに説明が表示されているか確認
}
public function test_showアクションの確認(例外)()
{
// 存在しないIDを指定してリクエスト
$response = $this->get(route('books.show', ['book' => 999]));
$response->assertStatus(404);
$response->assertSee('本がみつかりません'); // エラーメッセージが表示されているか確認
}
public function test_editアクションの確認()
{
$book = Book::factory()->create(); // ダミーデータを作成
$response = $this->get(route('books.edit', $book->id));
$response->assertStatus(200);
$response->assertViewIs('books.edit'); // View名は books.editか確認
$response->assertViewHas('book', $book); // Viewにbookが渡されているか確認
$response->assertSee($book->title); // Viewにタイトルが表示されているか確認
$response->assertSee($book->author); // Viewに著者名が表示されているか確認
$response->assertSee($book->description); // Viewに説明が表示されているか確認
}
public function test_updateアクションの確認()
{
$book = Book::factory()->create(); // ダミーデータを作成
// 更新するデータ
$updated = [
'title' => '更新後のタイトル',
'author' => '更新後の著者名',
'description' => '更新後の説明',
];
$response = $this->put(route('books.update', $book->id), $updated);
$response->assertRedirect(route('books.index')); // リダイレクト先の確認
$response->assertSessionHas('success', 'Book updated!'); // セッションに成功メッセージがあるか確認
$this->assertDatabaseMissing('books', $book->toArray()); // 古いデータが削除されているか確認
$this->assertDatabaseHas('books', $updated); // 新しいデータが保存されているか確認
}
public function test_destroyアクションの確認()
{
$book = Book::factory()->create(); // ダミーデータを作成
$response = $this->delete(route('books.destroy', $book->id));
$response->assertRedirect(route('books.index')); // リダイレクト先の確認
$response->assertSessionHas('success', 'Book deleted!'); // セッションに成功メッセージがあるか確認
$this->assertDatabaseMissing('books', ['id' => $book->id]); // 古いデータが削除されているか確認
}
}
機能(Feature)テストの実行結果
$ ./vendor/bin/sail artisan test tests/Feature
PASS Tests\Feature\BookControllerTest
✓ indexアクションの確認 0.79s
✓ createアクションの確認 0.02s
✓ storeアクションの確認 0.02s
✓ showアクションの確認(正常) 0.02s
✓ showアクションの確認(例外) 0.02s
✓ editアクションの確認 0.01s
✓ updateアクションの確認 0.02s
✓ destroyアクションの確認 0.02s
Tests: 8 passed (33 assertions)
Duration: 0.95s