📖

CodeIgniter3ユーザーのためのLaravel10入門:会員制Twitterアプリを作りながら学ぶ

2023/11/26に公開

はじめに

この記事では、CodeIgniter3ユーザー向けにLaravel10の機能を紹介します。私が所属する企業のメンバーを対象に、具体的なアプリケーションを通してLaravel10の魅力を解説します。

以下のようなアプリケーションを作成するシナリオで、実際に手を動かしながら、どういった機能があるかを見ていきましょう。

  • 会員制のTwitterライクなアプリケーション
  • 会員登録、ログイン、閲覧、投稿、削除の機能
  • 投稿は30文字以内、一人5個まで
  • 自分の投稿のみ削除できる

ソースコードはこちら。
https://github.com/coffee-r/LaravelTwitter

プロジェクトの作成

まず最初に、Laravel10プロジェクトを作成しましょう。Laravelの公式インストールガイドに従い、次のComposerコマンドを実行します。
https://readouble.com/laravel/10.x/ja/installation.html

composer create-project laravel/laravel LaravelTwitter

このコマンドにより、LaravelTwitterという名前のディレクトリが作成され、その中にはLaravelフレームワークとサンプルコードが含まれています。生成されたディレクトリ構造は以下の通りです。

LaravelTwitter/
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── resources/
├── routes/
├── storage/
├── tests/
├── vendor/
├── ...

このディレクトリ構造は、Laravelアプリケーションの主要な要素を含んでおり、開発を始めるための基盤となります。

簡易的なDocker開発環境の構築

Laravel Sailを使用して、簡単にDocker開発環境を構築しましょう。Laravel SailはLaravelに組み込まれた機能で、以下のComposerコマンドを使ってインストールできます。

composer require laravel/sail --dev

次に、artisanコマンドを使用してLaravel Sailのセットアップを行います。artisanはLaravelが提供するコマンドラインインターフェイスです。

php artisan sail:install

これによりプロジェクト内に docker-compose.yml ファイルが生成されます。データベースの内容を可視化するため、phpMyAdminを組み込んでおきましょう。

docker-compose.yml
~~~
    phpmyadmin:
        image: phpmyadmin/phpmyadmin
        links:
            - mysql:mysql
        ports:
            - 8080:80
        environment:
            #PMA_USER: "${DB_USERNAME}"
            #PMA_PASSWORD: "${DB_PASSWORD}"
            PMA_HOST: mysql
         networks:
            - sail
~~~

そして、以下のコマンドを使用してDocker開発環境を起動できます。

./vendor/bin/sail up -d
./vendor/bin/sail npm install # 初回のみ
./vendor/bin/sail npm run dev

以上の手順が完了すると、ブラウザで localhost を開くと、Laravelのデフォルト画面が表示されます。

認証機能の作成とフロントエンドのセットアップ

Laravel Breezeを使用して、簡単に認証機能とフロントエンドをセットアップしましょう。Laravel Breezeは、以下のComposerコマンドを使用してインストールできます。

composer require laravel/breeze --dev

次に、artisanコマンドを使用してBreezeのインストールを行います。

./vendor/bin/sail artisan breeze:install

コマンドを実行すると、いくつかの選択肢が表示されます。今回は以下のように選択します。

  • React with Inertia
  • TypeScript (experimental)
  • PHPUnit

これで、 localhost を開くと、Laravelのデフォルト画面にログインと会員登録の項目が追加されます。

さっそく会員登録を行ってみましょう。

すると、データベースにusersテーブルが作成されていないため、以下のようなエラーが表示されるはずです。

データベースにusersテーブルを作っていないので、データ保存ができないというエラーです。

マイグレーションを使用してローカルの開発環境にusersテーブルを作成する

Laravelのマイグレーション機能は、データベーステーブルの構造を定義・変更するための強力なツールです。マイグレーションを実行することで、CREATE TABLE や ALTER TABLE などのSQLコマンドを発行し、またマイグレーションをロールバックすることで DROP TABLE なども可能です。

Laravel Breezeには既にいくつかのマイグレーションファイルが用意されています。その中に users テーブルのマイグレーションファイルが含まれているので、その内容を確認してみましょう。

database/migrations/2014_10_12_000000_create_users_table.php
<?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('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('users');
    }
};

このマイグレーションファイルでは、users テーブルに対して各カラムの定義を行っています。

次に、以下のコマンドを使用してマイグレーションを実行してみましょう。

./vendor/bin/sail artisan migrate

実行後、phpMyAdminでローカルデータベースの中身を確認すると、users テーブルが作成されていることがわかります。

また、実行したマイグレーションの履歴は migrations テーブルに保存されており、phpMyAdminで確認できます。

ここには各マイグレーションの実行履歴が保存され、バージョン管理のような機能が提供されています。

私が勤めている会社でのCodeIgniter3のModelクラスとLaravel10のModelクラスの使い方の違い

データの参照について

CodeIgniter3
$query = $this->db->query('SELECT id, name FROM users');

// 基本的にresult_array()を使用していて連想配列で結果を受け取っている
foreach ($query->result_array() as $row)
{
    echo $row['id'];
    echo $row['name'];
}
Laravel10
$users = User::all();

foreach ($users as $user) {
    echo $user->id;
    echo $user->name;
}

データの保存

CodeIgniter3
$data = array(
    'name' => $name,
);

$this->db->insert('users', $data);
Laravel10
$user = new User();
$user->name = 'name';

$user->save();

ツイート閲覧機能の実装

ツイートを永続化するため、マイグレーション機能を使用して posts テーブルを作成し、データベース操作を行う Post モデルクラスも作成します。以下のartisanコマンドを使用して、ModelとMigrationの作成を行います。

./vendor/bin/sail artisan make:model Post --migration

生成されたマイグレーションファイルを編集します。

database/migrations/2023_11_25_200743_create_posts_table.php
<?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('posts', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('user_id');
            $table->text('message');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

その後、マイグレーションコマンドを使用してデータベースに posts テーブルを作成します。

./vendor/bin/sail artisan migrate

次に、ツイート閲覧機能のためのコントローラー (PostController) を作成します。

./vendor/bin/sail artisan make:controller PostController

生成されたコントローラーファイル (app/Http/Controllers/PostController.php) を編集します。

app/Http/Controllers/PostController
<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Inertia\Inertia;
use Inertia\Response;

class PostController extends Controller
{
    public function index(): Response
    {
        // 投稿を取得する
        $posts = Post::join('users', 'users.id', '=', 'posts.user_id')
                    ->select(['posts.id as post_id', 'user_id', 'message', 'users.name as user_name'])
                    ->orderBy('posts.id', 'desc')
                    ->get();
        
        // 投稿データをビューに渡す
        return Inertia::render('Posts', [
            'posts' => $posts,
        ]);
    }
}

次に、ルーティング設定を行います。

routes/web.php
~~~
Route::middleware('auth')->group(function () {
    ~~~
    Route::get('/posts', [PostController::class, 'index'])->name('posts');
});
~~~

以上で、投稿一覧画面が表示されるようになりました。しかし、現時点ではまだ投稿が存在していません。そのため、ダミーデータを作成するためにFactoryとSeederを使用します。

./vendor/bin/sail artisan make:factory Post
./vendor/bin/sail artisan make:seeder PostSeeder

PostFactoryでダミーデータの定義を行います。

database/factories/PostFactory.php
<?php

namespace Database\Factories;

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Post>
 */
class PostFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'message' => fake()->text(30)
        ];
    }
}

その後、PostSeederでダミーデータを作成します。

database/seeders/PostSeeder.php
<?php

namespace Database\Seeders;

use App\Models\Post;
use Illuminate\Database\Seeder;

class PostSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Post::factory()->count(5)->create();
    }
}
./vendor/bin/sail artisan db:seed --class=PostSeeder

ツイート投稿機能の実装

投稿一覧画面に入力フォームを用意します。

ツイート投稿機能を実装するために、まずはフォームバリデーションを行います。フォームバリデーションをコントローラーに直接記述することも可能ですが、コードの構造をより明確にするため、FormRequestを使用します。以下のartisanコマンドを使用して、StorePostRequest フォームリクエストを作成します。

./vendor/bin/sail artisan make:request StorePostRequest

生成された StorePostRequest ファイルを編集してフォームバリデーションのルールを定義します。

app/Http/Requests/StorePostRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StorePostRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'message' => 'required|max:30',
        ];
    }
}

次に、データベースの値に応じたバリデーションおよび投稿データの永続化を行うため、このユースケースを満たすクラスを新たに作成します。これにより、コントローラーの実装がより薄くなり、コードの見通しが良くなると考えています。

app/UseCases/PostStoreAction.php
<?php

namespace App\UseCases;

use App\Models\Post;

class PostStoreAction
{
    public function __invoke(int $user_id, Post $post)
    {
        // 投稿ユーザーの投稿数を取得
        $countPosts = Post::where('user_id', $user_id)->count();

        // 投稿数が5つ以上は投稿できない
        if ($countPosts >= 5) {
            abort(400, '投稿数の上限に達しました。');
        }

        // 投稿データを保存する
        $post->save();
    }
}

コントローラー内でフォームリクエストおよびPostStoreActionクラスを使用してツイート投稿機能を実現します。

app/Http/Controllers/PostController.php
~~~
    public function store(StorePostRequest $request, PostStoreAction $postStoreAction): RedirectResponse
    {
        // モデルクラスをインスタンス化 (バリデーションした値を同時に入れる)
        $post = new Post($request->validated());

        // ログインユーザーのユーザーIDをセット
        $post->user_id = Auth::user()->id;

        // データベースの値に応じたバリデーション
        // 及び永続化
        $postStoreAction(Auth::user()->id, $post);

        // フラッシュメッセージと共に一覧画面に遷移
        return back()->with('message', '投稿しました');
    }
~~~

なお、コントローラーのメソッドの引数にあるフォームリクエスト、別レイヤーとして切り出した処理のクラスはLaravelがよしなにDIしてくれます。

最後にルーティングの設定をします。

routes/web.php
    Route::get('/posts', [PostController::class, 'index'])->name('posts');
    Route::post('/posts', [PostController::class, 'store'])->name('posts.store'); // 追加

投稿を試してみましょう。

これで、ツイート投稿機能が実装されました。フォームバリデーションやデータベースの値に応じたバリデーションも適切に行われています。

ツイート削除機能の実装

投稿一覧画面に削除ボタンを追加します。

削除機能については先にコントローラーを実装してみましょう。

app/Http/Controllers/PostController
    public function destroy(Post $post): RedirectResponse
    {
        // 投稿を削除
        $post->delete();

        // フラッシュメッセージと共に一覧画面に遷移
        return back()->with('message', '投稿を削除しました');
    }

アクションメソッドの引数にモデルクラスが入ってきていますが、これはLaravelのルートモデルバインディングという機能を使用しており、ルーティング設定に一手間かけることで画面からフォームリクエストされた投稿IDを元に投稿モデルを取得することをしています。

routes/web.php
// 通常はパスパラメーターがコントローラーのアクションメソッドに渡るが、モデル名を指定するとモデルのインスタンス化までしてくれる
Route::delete('/posts/{post:id}', [PostController::class, 'destroy'])->name('posts.destroy'); 

これで投稿の削除ができるようになりました。しかし、他のユーザーの投稿も削除できる状態です。
自身が投稿した投稿のみ削除できるように、いわゆる認可処理を書きましょう。これを実装するにはLaravelのPolicyという機能を使うのが便利です。

まずはartisanコマンドでPolicyクラスを作成します。

./vendor/bin/sail artisan make:policy PostPolicy

Policyクラスを編集します。

app/Policies/PostPolicy.php
<?php

namespace App\Policies;

use App\Models\Post;
use App\Models\User;
use Illuminate\Auth\Access\Response;

class PostPolicy
{
    public function delete(User $user, Post $post): Response
    {
        if ($post->user_id === $user->id) {
            return Response::allow();
        }

        return Response::deny('この投稿の削除権限がありません。');
    }
}

さらにコントローラークラスに1行追加します。

app/Http/Controllers/PostController.php
    public function destroy(Post $post): RedirectResponse
    {
	// 認可 (先ほど作ったPolicyクラスが内部で動作する)
        $this->authorize('delete', $post);
	
        // 投稿を削除
        $post->delete();

        // フラッシュメッセージと共に一覧画面に遷移
        return back()->with('message', '投稿を削除しました');
    }

試しに自分の投稿とダミーデータの投稿を削除してみましょう。ダミーデータの場合は自身のユーザーIDとダミーユーザーIDが一致しないため、削除権限がないというメッセージが表示されます。

5個しか投稿できないというテストのコードを書く

データベースの値に応じたバリデーションはPostStoreActionに記述していました。このクラスに対して

  • 5つまで投稿できる
  • 6つは投稿できない

機能テストを書いてみましょう。

tests/Feature/UseCases/PostStoreActionTest.php
<?php

namespace Tests\Feature\UseCases;

use App\Models\Post;
use App\Models\User;
use App\UseCases\PostStoreAction;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;

class PostStoreActionTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @doesNotPerformAssertions
     */
    public function testユーザーは5つまで投稿できる()
    {
        $user = User::factory()->create();
        Post::factory()->count(4)->create(['user_id' => $user->id]);

        $post = new Post();
        $post->user_id = $user->id;
        $post->message = 'test';

        $postStoreAction = new PostStoreAction();
        $postStoreAction($user->id, $post);
    }

    public function test6つ目の投稿は失敗する()
    {
        $user = User::factory()->create();
        Post::factory()->count(5)->create(['user_id' => $user->id]);

        $post = new Post();
        $post->user_id = $user->id;
        $post->message = 'test';

        $postStoreAction = new PostStoreAction();
        $this->expectException(HttpException::class);
        $postStoreAction($user->id, $post);
    }
}

RefreshDatabaseというtraitが出てきました。これは公式ドキュメントから引用すると

以前のテストデータが後続のテストに干渉しないように、各テストの後にデータベースをリセットする

機能になります。

また、テストコードでは初めの方に登場していたFactoryを活用することで一時的にダミーデータを作ることができます。

テストコードの実行は以下のコマンドから行うことができます。

./vendor/bin/sail test

自分の投稿のみ削除できるというテストのコードを書く

コントローラーのテストコードを書いてみましょう。

tests/Http/Controllers/PostControllerTest.php
<?php

namespace Tests\Feature\Http\Controllers;

use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PostControllerTest extends TestCase
{
    use RefreshDatabase;

    public function testDelete投稿者自身が削除()
    {
        $post = Post::factory()->create();
        $user = User::find($post->user_id);

        $this->actingAs($user)
            ->delete('/posts/'.$post->id)
            ->assertStatus(302);
    }

    public function testDelete投稿者以外が削除()
    {
        $post = Post::factory()->create();
        $user = User::factory()->create();

        $this->actingAs($user)
            ->delete('/posts/'.$post->id)
            ->assertStatus(403);
    }
}

actingAs($user)というのは、特定のユーザーとしてログインしているのをシュミレートするようなメソッドになります。

終わりに

バックエンド開発者の皆さんに向けてLaravelの魅力を紹介しました。新しいフレームワークへの乗り換えは一歩が大きいかもしれませんが、その先にはより柔軟で効率的な開発が広がっています。CodeIgniter3からの転換が、新しいプロジェクトのスタートになりますように。

Discussion