7)掲示板サイトの作成(Laravel)

PHPで掲示板を作成する方法についてサンプルコード付きで紹介します。
【はじめに】掲示板の構成

【下準備】プロジェクトの作成、データベースの作成
● bbsプロジェクトを作成します。
php composer.phar create-project laravel/laravel bbs --prefer-dist
cd bbs
データベースを作成します。
mysql --user=root --password=
MariaDB [(none)]> CREATE DATABASE `bbs_db`
-> CHARACTER SET utf8mb4
-> COLLATE utf8mb4_unicode_ci;
※「Query OK, 1 row affected (0.01 sec)」などと表示されたら成功
ENV編集
データベースの接続情報を.envに設定します。
DB_CONNECTION=mysql
DB_HOST=localhost
DB_DATABASE=bbs_db
DB_USERNAME=root
DB_PASSWORD=
DB_SOCKET=/Applications/MAMP/tmp/mysql/mysql.sock
APP_KEY=base64:xlcHbtqyZfDION8orQrwqm8SibCOBw5fwuEeM7MLfnU=
#### マイグレーション
● 「bbs\database\migrations\」以下にある全てのファイルを削除します。
● 以下コマンドを実行し、マイグレーションファイルを作成します。
$ php artisan make:migration create_posts_table --create=posts
$ php artisan make:migration create_comments_table --create=comments
● 作成したマイグレーションファイル2つを以下のように編集します。
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreatePostsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('posts', function (Blueprint $table) {
$table->increments('id');
$table->string('title', 50);
$table->text('body');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('posts');
}
}
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateCommentsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('comments', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('post_id');
$table->text('body');
$table->timestamps();
$table->foreign('post_id')->references('id')->on('posts');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('comments');
}
}
● 以下コマンドでマイグレーションを実行し、テーブルを作成します。
$ php artisan migrate
※新たにマイグレーションファイルを追加したときは「php artisan migrate:refresh」を実行します。
【モデルの作成】投稿(Post)、コメント(Comment)
テーブルに対応するモデルを作成します。
● 以下のコマンドを実行し、Post、Comment用のモデルを作成します。
$ php artisan make:model Post
$ php artisan make:model Comment
● 作成したモデルファイル2つを次のように編集します。
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $fillable = [
'title',
'body',
];
public function comments()
{
return $this->hasMany('App\Comment');
}
}
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Comment extends Model
{
protected $fillable = [
'body',
];
public function post()
{
return $this->belongsTo('App\Post');
}
}

【テストデータ作成】
テストデータ(50件の投稿、各投稿に2つのコメント)を作成します。
● 以下のコマンドを実行し、ファクトリを作成します。
$ php artisan make:factory PostFactory --model=Post
$ php artisan make:factory CommentFactory --model=Comment
作成したファクトリファイル2つを以下のように編集します。
<?php
use Faker\Generator as Faker;
$factory->define(App\Post::class, function (Faker $faker) {
return [
'title' => '徒然草',
'body' => "徒然(つれづれ)なるままに、日ぐらし、硯(すずり)に向かいて、心にうつりゆくよしなし事(ごと)をそこはかとなく書きつくれば怪しうこそ物狂(ものぐる)おしけれ",
];
});
<?php
use Faker\Generator as Faker;
$factory->define(App\Comment::class, function (Faker $faker) {
return [
'body' => "千早ぶる神代もきかず龍田川からくれなゐに水くくるとは",
];
});
● 「database/seeds/DatabaseSeeder.php」を以下のように編集します。
<?php
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*
* @return void
*/
public function run()
{
// PostsTableSeeder(投稿のテストデータ登録)を呼び出して、シーディングコマンドで実行されるようにする
$this->call(PostsTableSeeder::class);
}
}
● 以下のコマンドを実行し、テストデータをデータベースに入れます。
$ composer dump-autoload
$ php artisan db:seed
Database seeding completed successfully.
【コントローラー作成】インデックス画面、投稿画面、コメント画面
投稿一覧画面を作成します。
● routes/web.php以下のように編集し、ルーティングを設定します。
<?php
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/
// インデックスページ用のルーティング
Route::get('/', 'PostsController@index')->name('top');
// 投稿表示用のルーティング
Route::resource('posts', 'PostsController', ['only' => ['create', 'store', 'show', 'edit', 'update', 'destroy']]);
// コメント投稿用のルーティング
Route::resource('comments', 'CommentsController', ['only' => ['store']]);
※投稿画面のルーティングも設定しています
● 以下のコマンドでコントローラーを作成します。
$ php artisan make:controller PostsController
$ php artisan make:controller CommentsController
● 作成されたコントローラファイルを以下のように編集します。
app/Http/Controllers/PostsController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Post;
class PostsController extends Controller
{
// インデックス用:投稿を作成日時の降順で取得し、posts.indexにデータを渡してビューを生成
public function index()
{
// ページネーションを追加(1ページ10件まで投稿表示)
// 投稿のリストを取得した時に、紐づくコメントを読み込む(取得した投稿数だけコメント数をカウントさせない:n+1問題)
// Laravelではwithメソッドで解決可能
$posts = Post::with(['comments'])->orderBy('created_at', 'desc')->paginate(10);
// コントローラのメソッドでビューを返す
// 第一引数にビューの名前、第二引数にビューに渡したい値(連想配列)を設定
return view('posts.index', compact('posts'));
// compact('posts') は ['posts' => $posts]);と同じ
}
// 投稿画面用:投稿を追加した後は、トップページにリダイレクト
public function create()
{
return view('posts.create');
}
public function store(Request $request)
{
$params = $request->validate([
'title' => 'required|max:50',
'body' => 'required|max:2000',
]);
Post::create($params);
return redirect()->route('top');
}
// 投稿の詳細表示
public function show($post_id)
{
// DBよりURIパラメータと同じIDを持つPostの情報を取得
$post = Post::findOrFail($post_id);
return view('posts.show', [
'post' => $post,
]);
}
// 編集用
public function edit($post_id)
{
// DBよりURIパラメータと同じIDを持つPostの情報を取得
$post = Post::findOrFail($post_id);
return view('posts.edit', [
'post' => $post,
]);
}
// 更新用
public function update($post_id, Request $request)
{
$params = $request->validate([
'title' => 'required|max:50',
'body' => 'required|max:2000',
]);
// DBよりURIパラメータと同じIDを持つPostの情報を取得
$post = Post::findOrFail($post_id);
$post->fill($params)->save();
return redirect()->route('posts.show', ['post' => $post]);
}
// 投稿削除
public function destroy($post_id)
{
// DBよりURIパラメータと同じIDを持つPostの情報を取得
$post = Post::findOrFail($post_id);
\DB::transaction(function () use ($post) {
$post->comments()->delete();
$post->delete();
});
return redirect()->route('top');
}
}
app/Http/Controllers/CommentsController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Post;
class CommentsController extends Controller
{
public function store(Request $request)
{
$params = $request->validate([
'post_id' => 'required|exists:posts,id',
'body' => 'required|max:2000',
]);
$post = Post::findOrFail($params['post_id']);
$post->comments()->create($params);
return redirect()->route('posts.show', ['post' => $post]);
}
}
【ビュー作成】インデックス画面、投稿画面、コメント画面
● layout.blade.phpを次のように編集し、共通テンプレートを作成します。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>MyBBS</title>
<!-- bootstrapをロード -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
</head>
<body>
<header class="navbar navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{{ url('') }}">
MyBBS
</a>
</div>
</header>
<div>
@yield('content')
</div>
</body>
</html>
● ビューを作成します。
投稿一覧(resources/views/posts/index.blade.php)
@extends('layout')
<!-- 投稿一覧を取得して表示 -->
@section('content')
<div class="container mt-4">
<!-- 投稿作成画面へのリンク -->
<div class="mb-4">
<a href="{{ route('posts.create') }}" class="btn btn-primary">
投稿の新規作成
</a>
</div>
@foreach ($posts as $post)
<div class="card mb-4">
<div class="card-header">
<a class="card-link" href="{{ route('posts.show', ['post' => $post]) }}">
{{ $post->title }}
</a>
</div>
<div class="card-body">
<p class="card-text">
<!-- 投稿の冒頭(200文字)を取得し改行あり表示 -->
{!! nl2br(e(Str::limit($post->body, 200))) !!}
</p>
</div>
<div class="card-footer">
<span class="mr-2">
投稿日時 {{ $post->created_at->format('Y.m.d') }}
</span>
@if ($post->comments->count())
<span class="badge badge-primary">
コメント数 {{ $post->comments->count() }}
</span>
@endif
</div>
</div>
@endforeach
<!-- ページネーション(1ページ、2ページ・・・)へのリンク -->
<div class="d-flex justify-content-center mb-5">
{{ $posts->links() }}
</div>
</div>
@endsection
投稿詳細表示(resources/views/posts/show.blade.php)
@extends('layout')
@section('content')
<div class="container mt-4">
<div class="border p-4">
<div class="mb-4 text-right">
<!-- 編集画面へのリンクを追加 -->
<a class="btn btn-primary" href="{{ route('posts.edit', ['post' => $post]) }}">
編集
</a>
<!-- 削除ボタンを追加 -->
<form style="display: inline-block;" method="POST" action="{{ route('posts.destroy', ['post' => $post]) }}">
@csrf
@method('DELETE')
<button class="btn btn-danger">削除</button>
</form>
</div>
<h1 class="h5 mb-4">
{{ $post->title }}
</h1>
<p class="mb-5">
{!! nl2br(e($post->body)) !!}
</p>
<section>
<h2 class="h5 mb-4">
コメント
</h2>
<!-- コメント追加のリクエストを送るためのフォームを追加 -->
<form class="mb-4" method="POST" action="{{ route('comments.store') }}">
@csrf
<input name="post_id" type="hidden" value="{{ $post->id }}">
<div class="form-group">
<label for="body">
本文
</label>
<textarea id="body" name="body" class="form-control {{ $errors->has('body') ? 'is-invalid' : '' }}" rows="4">{{ old('body') }}</textarea>
@if ($errors->has('body'))
<div class="invalid-feedback">
{{ $errors->first('body') }}
</div>
@endif
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">
コメントする
</button>
</div>
</form>
@forelse($post->comments as $comment)
<div class="border-top p-4">
<time class="text-secondary">
{{ $comment->created_at->format('Y.m.d H:i') }}
</time>
<p class="mt-2">
{!! nl2br(e($comment->body)) !!}
</p>
</div>
@empty
<p>コメントはまだありません。</p>
@endforelse
</section>
</div>
</div>
@endsection
編集画面(resources/views/posts/edit.blade.php)
@extends('layout')
@section('content')
<div class="container mt-4">
<div class="border p-4">
<h1 class="h5 mb-4">
投稿の編集
</h1>
<form method="POST" action="{{ route('posts.update', ['post' => $post]) }}">
@csrf
@method('PUT')
<fieldset class="mb-4">
<div class="form-group">
<label for="title">
タイトル
</label>
<input
id="title"
name="title"
class="form-control {{ $errors->has('title') ? 'is-invalid' : '' }}"
value="{{ old('title') ?: $post->title }}"
type="text"
>
@if ($errors->has('title'))
<div class="invalid-feedback">
{{ $errors->first('title') }}
</div>
@endif
</div>
<div class="form-group">
<label for="body">
本文
</label>
<textarea
id="body"
name="body"
class="form-control {{ $errors->has('body') ? 'is-invalid' : '' }}"
rows="4"
>{{ old('body') ?: $post->body }}</textarea>
@if ($errors->has('body'))
<div class="invalid-feedback">
{{ $errors->first('body') }}
</div>
@endif
</div>
<div class="mt-5">
<a class="btn btn-secondary" href="{{ route('posts.show', ['post' => $post]) }}">
キャンセル
</a>
<button type="submit" class="btn btn-primary">
更新する
</button>
</div>
</fieldset>
</form>
</div>
</div>
@endsection
【クエリ確認】表示とログファイルに保存
実行されているクエリを確認し、ログファイルに保存します。
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
//
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
// Laravelで実行されているクエリを表示
// ログファイルは、storage/logsディレクトリ以下に作成
\DB::listen(function ($query) {
$sql = $query->sql;
for ($i = 0; $i < count($query->bindings); $i++) {
$sql = preg_replace("/\?/", $query->bindings[$i], $sql, 1);
}
\Log::info($sql);
});
}
}