laravel + livewire + tailwind で webカンファレンス

(2024-12-24)

以前から、laravel + livewire + tailwind で chat を使ったカンファレンスを作りたいと思っていました。

本来であれば画像をアップロードすべきですが、それはとても難しいのでまずは文字だけのカンファレンスサイトを作成します。

データベース設計

データベース名は database.sqlite で以下の 3 つのテーブルを作成します。

users
   id: 主キー
   name: ユーザー名
   email: メールアドレス
   password: パスワード

themes
   id: 主キー
   user_id: 外部キー (users.id)
   title: テーマのタイトル名

messages
   id: 主キー
   user_id: 外部キー (users.id)
   theme_id: 外部キー (theme.id)
   content: メッセージ内容

プロジェクト作成

まずはプロジェクトを作成します。

composer create-project --prefer-dist laravel/laravel laraconf

livewire を設定します。

cd laraconf
composer require livewire/livewire

tailwind を設定します。

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

データベースはデフォルトでは sqlite です。
/database/database.sqlite で自動作成されます。

Breeze のインストール

composer require laravel/breeze --dev
php artisan breeze:install

サーバーを立ち上げます。

php artisan serve
http://127.0.0.1:8000/ からアクセスしてユーザー登録します。

migration によるテーブル作成

users テーブルはデフォルトで作成されます。

database/migrations/2024_xxxxxxxxxx_create_users_table
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
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();
});

こんな感じです。
最初は chatGPT に従って新たに作成しようとしたのですが、どうしてもエラーで新規作成できませんでした。おそらく users テーブルを削除することはできないのだろうと思います。

次に、themes テーブルを作成します。

php artisan make:migration create_themes_table

マイグレーションファイルを編集します。

database/migrations/2024_xxxxxxxxxx_create_themes_table
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('themes', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('title');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('themes');
}
};

messages テーブルを作成します。

php artisan make:migration create_messages_table

マイグレーションファイルを編集します。

database/migrations/2024_xxxxxxxxxx_create_messages_table
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('theme_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->text('content');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('messages');
}
};

マイグレーションを実行します。

php artisan migrate

モデルの作成

モデルを作成します。

User モデルはデフォルトで作成されていますが、一部編集します。
最後の方に、以下の function messages() を追加します。

app/Models/User.php
<?php
....
class User extends Authenticatable
{
....
public function messages()
{
return $this->hasMany(Message::class);
}
}

Theme モデルを作成します。

php artisan make:model Theme

app/Models/Theme.php を編集します。

app/Models/Theme.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Theme extends Model
{
use HasFactory;
protected $fillable = ['user_id', 'title'];
public function user()
{
return $this->belongsTo(User::class);
}
public function messages()
{
return $this->hasMany(Message::class);
}
}

Message モデルを作成します。

php artisan make:model Message

app/Models/Message.php を編集します。

app/Models/Message.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Message extends Model
{
use HasFactory;
protected $fillable = ['user_id', 'theme_id', 'content'];
public function theme()
{
return $this->belongsTo(Theme::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}

Livewire コンポーネントの作成

php artisan make:livewire ThemeCreate

編集。

app/Livewire/ThemeCreate.php
<?php
namespace App\Livewire;
use Livewire\Component;
use App\Models\Theme;
use Illuminate\Support\Facades\Auth;
class ThemeCreate extends Component
{
public $title;
public function render()
{
return view('livewire.theme-create')->layout('livewire.base');
}
public function saveTheme()
{
$this->validate([
'title' => 'required|max:255',
]);
Theme::create([
'user_id' => Auth::id(),
'title' => $this->title,
]);
$this->reset('title'); // フォームをリセット
session()->flash('success', 'テーマが作成されました!');
}
}

ThemeList 作成。

php artisan make:livewire ThemeList

編集。

app/Livewire/ThemeList.php
<?php
namespace App\Livewire;
use Livewire\Component;
use App\Models\Theme;
class ThemeList extends Component
{
public $themes;
public function mount()
{
$this->themes = Theme::with('user')->orderBy('created_at', 'desc')->get();
}
public function render()
{
return view('livewire.theme-list')->layout('livewire.base');
}
}

ThemeShow 作成。

php artisan make:livewire ThemeShow

編集。

app/Livewire/ThemeShow.php
<?php
namespace App\Livewire;
use Livewire\Component;
use App\Models\Theme;
use App\Models\Message;
class ThemeShow extends Component
{
public $theme;
public $content;
public function mount($theme_id)
{
$this->theme = Theme::with('messages.user')->findOrFail($theme_id);
}
public function saveMessage()
{
$this->validate([
'content' => 'required|string|max:1000',
]);
Message::create([
'user_id' => auth()->id(),
'theme_id' => $this->theme->id,
'content' => $this->content,
]);
$this->reset('content');
session()->flash('message', 'メッセージが送信されました!');
}
public function render()
{
$messages = $this->theme->messages()->orderBy('created_at', 'desc')->get();
return view('livewire.theme-show', compact('messages'))->layout('livewire.base');
}
}

MessageCreate 作成。

php artisan make:livewire MessageCreate

編集。

app/Livewire/MessageCreatee.php
<?php
namespace App\Livewire;
use Livewire\Component;
use App\Models\Message;
use Illuminate\Support\Facades\Auth;
class MessageCreate extends Component
{
public $theme_id;
public $content;
public function mount($theme_id)
{
$this->theme_id = $theme_id;
}
public function saveMessage()
{
$this->validate([
'content' => 'required|string|max:1000',
]);
Message::create([
'user_id' => Auth::id(),
'theme_id' => $this->theme_id,
'content' => $this->content,
]);
$this->reset('content');
session()->flash('message', 'メッセージが送信されました!');
}
public function render()
{
return view('livewire.message-create')->layout('livewire.base');
}
}

View の作成

resources/views/livewire/theme-create.blade.php

resources/views/livewire/theme-create.blade.php
<div class="p-4 bg-white shadow-md rounded">
<h2 class="text-xl font-bold mb-4">テーマを作成</h2>
@if (session()->has('success'))
<div class="p-4 mb-4 text-green-700 bg-green-100 rounded">
{{ session('success') }}
</div>
@endif
<form wire:submit.prevent="saveTheme">
<div class="mb-4">
<label for="title" class="block text-sm font-medium text-gray-700">テーマのタイトル</label>
<input type="text" id="title" wire:model="title"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
placeholder="テーマのタイトルを入力してください">
@error('title') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<button type="submit"
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
保存
</button>
</div>
</form>
</div>

resources/views/livewire/theme-list.blade.php

resources/views/livewire/theme-list.blade.php
<div class="p-4">
<h2 class="text-xl font-bold mb-4">テーマ一覧</h2>
@foreach ($themes as $theme)
<div class="mb-4 p-4 bg-white shadow-md rounded border border-slate-200">
<h3 class="text-lg font-semibold">
<!-- テーマIDをURLパラメータとして渡してテーマ詳細ページへ遷移 -->
<a href="{{ route('theme.show', ['theme_id' => $theme->id]) }}" class="text-blue-600 hover:text-blue-800">
{{ $theme->title }}
</a>
</h3>
<p class="text-sm text-gray-500">作成者: {{ $theme->user->name }}</p> <!-- ユーザー名 -->
<p class="text-sm text-gray-500">作成日時: {{ $theme->created_at->format('Y-m-d H:i') }}</p>
</div>
@endforeach
@if ($themes->isEmpty())
<p class="text-gray-500">まだテーマが作成されていません。</p>
@endif
</div>

resources/views/livewire/theme-show.blade.php

resources/views/livewire/theme-show.blade.php
<div class="p-4">
<h2 class="text-xl font-bold mb-4">{{ $theme->title }}</h2>
<p class="text-sm text-gray-500">作成者: {{ $theme->user->name }}</p>
<p class="text-sm text-gray-500">作成日時: {{ $theme->created_at->format('Y-m-d H:i') }}</p>
<!-- メッセージ入力フォーム -->
<div class="mt-4">
<form wire:submit.prevent="saveMessage">
<div class="mb-4">
<textarea wire:model="content" class="w-full p-2 border rounded" rows="4" placeholder="メッセージを入力してください..."></textarea>
@error('content') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
</div>
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
メッセージを送信
</button>
</form>
</div>
<!-- メッセージ一覧 -->
<h3 class="mt-8 text-lg font-semibold">メッセージ一覧</h3>
<div class="bg-white p-4 shadow-md rounded border border-slate-200">
@foreach ($messages as $message)
<div class="mb-4">
<p><span class="font-semibold">{{ $message->user->name }}</span><span class="text-xs text-gray-600">{{ $message->created_at->format('Y-m-d H:i') }}</span></p>
<p>{{ $message->content }}</p>
</div>
@endforeach
</div>
@if (session()->has('message'))
<div class="bg-green-100 text-green-700 p-4 mb-4 rounded">
{{ session('message') }}
</div>
@endif
</div>

resources/views/livewire/message-create.blade.php

resources/views/livewire/message-create.blade.php
<div class="p-4">
<form wire:submit.prevent="saveMessage">
@if (session()->has('message'))
<div class="bg-green-100 text-green-700 p-4 mb-4 rounded">
{{ session('message') }}
</div>
@endif
<div class="mb-4">
<textarea wire:model="content" class="w-full p-2 border rounded" rows="4" placeholder="メッセージを入力してください..."></textarea>
@error('content') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
</div>
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
メッセージを送信
</button>
</form>
</div>

resources/views/livewire/base.blade.php

resources/views/livewire/base.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@vite('resources/css/app.css')
<title>laravel</title>
</head>
<body>
{{ $slot }}
</body>
</html>

ルーティング

routes/web.php
<?php
use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;
use App\Livewire\ThemeCreate;
use App\Livewire\ThemeList;
use App\Livewire\ThemeShow;
use App\Livewire\MessageCreate;
Route::get('/', function () {
return view('welcome');
});
Route::get('/dashboard', function () {
return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::get('/theme/create', ThemeCreate::class)->name('theme.create');
Route::get('/theme/list', ThemeList::class)->name('theme.list');
Route::get('/theme/{theme_id}', ThemeShow::class)->name('theme.show');
});
require __DIR__.'/auth.php';

Dashboard の編集

resources/views/dashboard.php
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">{{ __('Menu') }}</h2>
<ul class="list-disc leading-10 mx-10 px-2">
<li class="text-blue-800 hover:text-orange-800 delay-150"><a href="{{ route('theme.create') }}">テーマ作成</a></li>
<li class="text-blue-800 hover:text-orange-800 delay-150"><a href="{{ route('theme.list') }}">テーマ一覧</a></li>
</ul>
</x-slot>
</x-app-layout>

tailwind のビルドとサーバー起動

npm run build
php artisan serve

アドレスバーからアクセスします。

http://127.0.0.1:8000/

ポイント

ここまでで一応動くプログラムは作成できたのですが、特に Model に関しては全く理解できず、ほとんど chatGPT の言う通りに作ってきただけです。

全く理解できていないのに前に進んでいいのかわからないので、chatGPT に訊いてみました。

Model の簡単な設定だけで何故 3 つのテーブルがリレーションできるのか?

これが最大のポイントかと思います。

chatGPT の答えは「Laravelで3つのテーブル(users, themes, messages)が結合できるのは、Eloquent ORM(Object Relational Mapping)のリレーション機能によるものです。」

hasMany 一つのレコードが複数の関連レコードを持つ場合に使う(例: User が複数の Message を持つ)
belongsTo 一つのレコードが他のテーブルの特定のレコードに属する場合に使う(例: Message が特定の User に属する)
class User extends Authenticatable
{
public function messages()
{
return $this->hasMany(Message::class);
}
}

User モデルは、自分に関連付けられた messages テーブルの複数レコードを取得できます。

Laravelは、デフォルトで messages テーブルの user_id カラムを使って、User と Message を関連付けます。

class Message extends Model
{
public function user()
{
return $this->belongsTo(User::class);
}
public function theme()
{
return $this->belongsTo(Theme::class);
}
}

Message モデルは、自分が属する User と Theme を取得できます。

user() メソッドは、messages テーブルの user_id カラムを使って users テーブルの該当レコードを取得します。

同様に、theme() メソッドは、messages テーブルの theme_id カラムを使って themes テーブルの該当レコードを取得します。

class Theme extends Model
{
public function user()
{
return $this->belongsTo(User::class);
}
public function messages()
{
return $this->hasMany(Message::class);
}
}

Theme モデルは、作成者の情報(User)や、関連するメッセージのリスト(messages)を取得できます。

user() メソッドは、themes テーブルの user_id カラムを使って users テーブルの該当レコードを取得します。

messages() メソッドは、messages テーブルの theme_id カラムを使って該当するメッセージをすべて取得します。

結合が動作する理由

Eloquentのデフォルトの挙動

LaravelのEloquentでは、belongsTo や hasMany を使用した場合、自動的にテーブル名(スネークケース)+ _id というカラム名を探して関連付けを行います。

例: user() メソッドは user_id を使用して結合を試みます。

例: theme() メソッドは theme_id を使用して結合を試みます。

外部キーの利用

テーブル間の結合は、データベースの外部キー制約(FOREIGN KEY)をもとに行われます。

テーブル構造:

messages.theme_id は themes.id に関連。

messages.user_id は users.id に関連。

themes.user_id は users.id に関連。

リレーションのチェーン

定義されたリレーションは、簡単にチェーンさせて利用可能です。

例: $theme->messages->first()->user

テーマに関連するメッセージの最初のメッセージを取得し、それを投稿したユーザーを取得する。

例: $user->messages->first()->theme

ユーザーが投稿したメッセージの最初のメッセージのテーマを取得する。

以上が chatGPT の説明ですが、これは全く理解できない。

SQL で書くと以下のような感じになるそうです。

SELECT messages.id AS message_id,
messages.content AS message_content,
users.id AS user_id,
users.name AS user_name,
themes.id AS theme_id,
themes.title AS theme_title
FROM messages
JOIN users ON messages.user_id = users.id
JOIN themes ON messages.theme_id = themes.id
WHERE themes.id = ?;

最後に chatGPT に訊いてみました。

何だかとてもすごいが、それを知らなくても前に進んでいいんだろうか?

chatGPT の答えは、

はい、Eloquentモデルとそのリレーションを使いこなせていれば、前に進む上で必ずしも生SQLやクエリビルダを知っている必要はありません。 EloquentはLaravelの強力な機能で、簡単かつ直感的にデータベース操作ができるよう設計されています。 標準的なCRUD操作や基本的なリレーション操作はEloquentで十分対応可能です。

Eloquentモデルとそのリレーションの理解は必要なようです、当然ですが。これは難しい。