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);
        });
    }
}