【Laravel】簡単な掲示板アプリの作り方を解説|初心者がアプリ作成をしながらCRUD機能を学ぶのにおすすめ
PHPフレームワークのLaravelを使い、簡単な掲示板アプリを開発する手順を解説します。投稿の一覧表示、新規作成、編集、削除といったWebアプリの基本であるCRUD機能の実装を通して、MVCモデルの役割やデータベース連携など、一連の開発の流れを実践的に学ぶことができます。
開発環境
- OS: Windows10
- Visual Studio Code: 1.73.0
- PHP: 8.3.11
- Laravel: 11.29.0
サンプルコード
/app/Http/Controllers/PostController.php
/app/Http/Controllers/PostController.php1+<?php 2+ 3+namespace App\Http\Controllers; 4+ 5+use Illuminate\Http\Request; 6+use App\Models\Post; 7+ 8+class PostController extends Controller 9+{ 10+ /** 11+ * Display a listing of the resource. 12+ */ 13+ public function index() 14+ { 15+ $posts = Post::all(); 16+ return view('posts.index', compact('posts')); 17+ } 18+ 19+ /** 20+ * Show the form for creating a new resource. 21+ */ 22+ public function create() 23+ { 24+ return view('posts.create'); 25+ } 26+ 27+ /** 28+ * Store a newly created resource in storage. 29+ */ 30+ public function store(Request $request) 31+ { 32+ $request->validate([ 33+ 'title' => 'required|max:255', 34+ 'content' => 'required' 35+ ]); 36+ 37+ $post = Post::create($request->all()); 38+ 39+ return redirect()->route('posts.show', ['post' => $post->id]); 40+ } 41+ 42+ /** 43+ * Display the specified resource. 44+ */ 45+ public function show(string $id) 46+ { 47+ $post = Post::findOrFail($id); 48+ return view('posts.show', compact('post')); 49+ } 50+ 51+ /** 52+ * Show the form for editing the specified resource. 53+ */ 54+ public function edit(string $id) 55+ { 56+ $post = Post::findOrFail($id); 57+ return view('posts.edit', compact('post')); 58+ } 59+ 60+ /** 61+ * Update the specified resource in storage. 62+ */ 63+ public function update(Request $request, string $id) 64+ { 65+ $request->validate([ 66+ 'title' => 'required|max:255', 67+ 'content' => 'required' 68+ ]); 69+ 70+ $post = Post::findOrFail($id); 71+ 72+ $post->update([ 73+ 'title' => $request->title, 74+ 'content' => $request->content 75+ ]); 76+ 77+ return redirect()->route('posts.show', ['post' => $post->id]); 78+ } 79+ 80+ /** 81+ * Remove the specified resource from storage. 82+ */ 83+ public function destroy(string $id) 84+ { 85+ $post = Post::findOrFail($id); 86+ $post->delete(); 87+ return redirect()->route('posts.index'); 88+ } 89+} 90
/app/Models/Post.php
/app/Models/Post.php1+<?php 2+ 3+namespace App\Models; 4+ 5+use Illuminate\Database\Eloquent\Model; 6+ 7+class Post extends Model 8+{ 9+ protected $fillable = [ 10+ 'title', 11+ 'content' 12+ ]; 13+} 14
/database/migrations/2024_10_26_045031_create_posts_table.php
/database/migrations/2024_10_26_045031_create_posts_table.php1+<?php 2+ 3+use Illuminate\Database\Migrations\Migration; 4+use Illuminate\Database\Schema\Blueprint; 5+use Illuminate\Support\Facades\Schema; 6+ 7+return new class extends Migration 8+{ 9+ /** 10+ * Run the migrations. 11+ */ 12+ public function up(): void 13+ { 14+ Schema::create('posts', function (Blueprint $table) { 15+ $table->id(); 16+ $table->string('title'); 17+ $table->text('content'); 18+ $table->timestamps(); 19+ }); 20+ } 21+ 22+ /** 23+ * Reverse the migrations. 24+ */ 25+ public function down(): void 26+ { 27+ Schema::dropIfExists('posts'); 28+ } 29+}; 30
/resources/views/layout.blade.php
/resources/views/layout.blade.php1+<!DOCTYPE html> 2+<html lang="ja"> 3+<head> 4+ <meta charset="UTF-8"> 5+ <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6+ <title>掲示板アプリ</title> 7+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"> 8+</head> 9+<body> 10+ @yield('content') 11+</body> 12+</html> 13
/resources/views/posts/create.blade.php
/resources/views/posts/create.blade.php1+@extends('layout') 2+ 3+@section('content') 4+<div class="container mt-5"> 5+ <h2>新規投稿</h2> 6+ <form action="{{ route('posts.store') }}" method="POST"> 7+ @csrf 8+ <div class="mb-3"> 9+ <label for="title" class="form-label">タイトル</label> 10+ <input type="text" name="title" class="form-control" required> 11+ </div> 12+ <div class="mb-3"> 13+ <label for="content" class="form-label">内容</label> 14+ <textarea name="content" class="form-control" rows="5" required></textarea> 15+ </div> 16+ <button type="submit" class="btn btn-success">投稿</button> 17+ </form> 18+ <div class="mt-3"> 19+ <a href="{{ route('posts.index') }}" class="btn btn-secondary">戻る</a> 20+ </div> 21+</div> 22+@endsection 23
/resources/views/posts/edit.blade.php
/resources/views/posts/edit.blade.php1+@extends('layout') 2+ 3+@section('content') 4+<div class="container mt-5"> 5+ <h2>投稿編集</h2> 6+ <form action="{{ route('posts.update', $post->id) }}" method="POST"> 7+ @csrf 8+ @method('PUT') 9+ <div class="mb-3"> 10+ <label for="title" class="form-label">タイトル</label> 11+ <input type="text" name="title" class="form-control" value="{{ $post->title }}" required> 12+ </div> 13+ <div class="mb-3"> 14+ <label for="content" class="form-label">内容</label> 15+ <textarea name="content" class="form-control" rows="5" required>{{ $post->content }}</textarea> 16+ </div> 17+ <button type="submit" class="btn btn-success">更新</button> 18+ <a href="{{ route('posts.show', $post->id) }}" class="btn btn-secondary">戻る</a> 19+ </form> 20+</div> 21+@endsection 22
/resources/views/posts/index.blade.php
/resources/views/posts/index.blade.php1+@extends('layout') 2+ 3+@section('content') 4+<div class="container mt-5"> 5+ <h2>掲示板</h2> 6+ <a href="{{ route('posts.create') }}" class="btn btn-primary mb-3">新規投稿</a> 7+ @foreach ($posts as $post) 8+ <div class="card mb-3"> 9+ <div class="card-body"> 10+ <h3 class="card-title">{{ $post->title }}</h3> 11+ <p class="card-text">{{ $post->content }}</p> 12+ <a href="{{ route('posts.show', $post->id) }}" class="btn btn-info">詳細</a> 13+ </div> 14+ </div> 15+ @endforeach 16+</div> 17+@endsection 18
/resources/views/posts/show.blade.php
/resources/views/posts/show.blade.php1+@extends('layout') 2+ 3+@section('content') 4+<div class="container mt-5"> 5+ <h2>投稿詳細</h2> 6+ <div class="card mb-3"> 7+ <div class="card-body"> 8+ <h3 class="card-title">{{ $post->title }}</h3> 9+ <p class="card-text">{{ $post->content }}</p> 10+ <a href="{{ route('posts.index') }}" class="btn btn-secondary">戻る</a> 11+ <a href="{{ route('posts.edit', $post->id ) }}" class="btn btn-warning">編集</a> 12+ <form action="{{ route('posts.destroy', $post->id) }}" method="POST" class="d-inline"> 13+ @csrf 14+ @method('DELETE') 15+ <button type="submit" class="btn btn-danger">削除</button> 16+ </form> 17+ </div> 18+ </div> 19+</div> 20+@endsection 21
/routes/web.php
/routes/web.php1 <?php 2 3 use Illuminate\Support\Facades\Route; 4+use App\Http\Controllers\PostController; 5 6 Route::get('/', function () { 7 return view('welcome'); 8 }); 9+ 10+Route::resource('posts', PostController::class); 11
コード解説
変更点: 投稿データを保存するテーブルの設計図を作成
/database/migrations/2024_10_26_045031_create_posts_table.php1+<?php 2+ 3+use Illuminate\Database\Migrations\Migration; 4+use Illuminate\Database\Schema\Blueprint; 5+use Illuminate\Support\Facades\Schema; 6+ 7+return new class extends Migration 8+{ 9+ /** 10+ * Run the migrations. 11+ */ 12+ public function up(): void 13+ { 14+ Schema::create('posts', function (Blueprint $table) { 15+ $table->id(); 16+ $table->string('title'); 17+ $table->text('content'); 18+ $table->timestamps(); 19+ }); 20+ } 21+... 22
これはマイグレーションファイルと呼ばれる、データベースのテーブル設計図です。upメソッドの中に、作成したいテーブルの構造を定義します。
Schema::create('posts', ...)はpostsという名前のテーブルを作成する命令です。
$table->id();は、投稿データを一意に識別するための自動連番のIDカラムを作成します。$table->string('title');は、タイトルを保存するためのtitleという名前の文字列型カラムを作成します。$table->text('content');は、本文を保存するためのcontentという名前のテキスト型カラムを作成します。$table->timestamps();は、データの作成日時と更新日時を自動で記録するためのcreated_atとupdated_atという2つのカラムを作成します。
変更点: データベースと連携するPostモデルを作成
/app/Models/Post.php1+<?php 2+ 3+namespace App\Models; 4+ 5+use Illuminate\Database\Eloquent\Model; 6+ 7+class Post extends Model 8+{ 9+ protected $fillable = [ 10+ 'title', 11+ 'content' 12+ ]; 13+}
これはモデルと呼ばれるもので、データベースのテーブルと連携し、データの操作を簡単にするためのクラスです。Postモデルは、先ほど設計したpostsテーブルに対応します。
protected $fillableは、プログラムから一括で値を登録・更新しても良いカラム名を指定する設定です。ここにtitleとcontentを指定することで、フォームから送られてきたタイトルと本文のデータを一度にデータベースへ保存できるようになります。これは意図しないデータが書き換えられるのを防ぐ、セキュリティのための重要な設定です。
変更点: 掲示板機能のURLと処理を紐付ける
/routes/web.php1+use App\Http\Controllers\PostController; 2... 3+Route::resource('posts', PostController::class);
これはルーティングを定義するファイルで、特定のURLにアクセスがあったときに、どのコントローラーのどの処理を呼び出すかを設定します。
Route::resource('posts', PostController::class);と記述するだけで、掲示板のようなCRUD(作成、読み取り、更新、削除)機能に必要な7つの基本的なURL(一覧表示、新規作成フォーム、保存処理、詳細表示、編集フォーム、更新処理、削除処理)が自動的に生成され、PostControllerの各メソッドに紐付けられます。これにより、一つ一つのURLを手で書く手間が省け、非常に効率的に開発を進めることができます。
変更点: 全ての投稿を取得し、一覧画面に渡す処理を追加
/app/Http/Controllers/PostController.php1+ public function index() 2+ { 3+ $posts = Post::all(); 4+ return view('posts.index', compact('posts')); 5+ }
PostControllerにindexメソッドを追加しました。これは投稿の一覧ページを表示するための処理です。
$posts = Post::all();では、先ほど作成したPostモデルを使って、postsテーブルに保存されている全ての投稿データを取得しています。
return view('posts.index', compact('posts'));では、取得した全ての投稿データ($posts)をposts.indexという名前のビュー(画面ファイル)に渡して、ブラウザに表示するよう指示しています。compact('posts')は、$postsという変数をビュー側で使えるようにするための簡単な書き方です。
変更点: 新規投稿フォームを表示する処理を追加
/app/Http/Controllers/PostController.php1+ public function create() 2+ { 3+ return view('posts.create'); 4+ }
これは、新しい投稿を作成するためのフォーム画面を表示するcreateメソッドです。
return view('posts.create');という一行だけで、posts.createという名前のビューファイルを表示するよう指示しています。この時点ではデータベースとのやり取りはなく、単純にHTMLフォームのページをユーザーに見せるだけの役割です。
変更点: フォームから送られたデータを保存する処理を追加
/app/Http/Controllers/PostController.php1+ public function store(Request $request) 2+ { 3+ $request->validate([ 4+ 'title' => 'required|max:255', 5+ 'content' => 'required' 6+ ]); 7+ 8+ $post = Post::create($request->all()); 9+ 10+ return redirect()->route('posts.show', ['post' => $post->id]); 11+ }
これは、新規作成フォームから送信されたデータを受け取ってデータベースに保存するstoreメソッドです。
$request->validate([...])で、入力内容のチェック(バリデーション)を行っています。titleは必須入力(required)で255文字以内(max:255)、contentも必須入力と定めています。もしルール違反があれば、Laravelが自動的にエラーメッセージ付きで前のページに戻してくれます。Post::create($request->all());で、バリデーションを通過した全てのデータをpostsテーブルに新規レコードとして保存します。return redirect()->route(...)で、保存が完了したら、今作成した投稿の詳細ページ(posts.show)にリダイレクト(自動でページを移動)させています。
変更点: 特定の投稿データを取得し、詳細画面に渡す処理を追加
/app/Http/Controllers/PostController.php1+ public function show(string $id) 2+ { 3+ $post = Post::findOrFail($id); 4+ return view('posts.show', compact('post')); 5+ }
これは、特定の1つの投稿の詳細ページを表示するためのshowメソッドです。URLに含まれるID(例: /posts/1の1の部分)を引数$idで受け取ります。
$post = Post::findOrFail($id);では、受け取ったIDを使ってpostsテーブルから該当する1件のデータを検索します。findOrFailは、もしデータが見つからなかった場合に自動的に404 Not Foundエラーページを表示してくれる便利なメソッドです。
return view('posts.show', compact('post'));で、見つかった投稿データ($post)を詳細画面のビューに渡して表示します。
変更点: 投稿編集フォームを表示する処理を追加
/app/Http/Controllers/PostController.php1+ public function edit(string $id) 2+ { 3+ $post = Post::findOrFail($id); 4+ return view('posts.edit', compact('post')); 5+ }
これは、既存の投稿を編集するためのフォーム画面を表示するeditメソッドです。
処理の流れは詳細表示のshowメソッドと非常によく似ています。URLから受け取った$idを元にPost::findOrFail($id)で編集対象の投稿データを取得し、そのデータを編集フォームのビュー(posts.edit)に渡しています。これにより、編集フォームには既に保存されているタイトルや本文が入力された状態で表示されます。
変更点: フォームから送られたデータで投稿を更新する処理を追加
/app/Http/Controllers/PostController.php1+ public function update(Request $request, string $id) 2+ { 3+ $request->validate([ 4+ 'title' => 'required|max:255', 5+ 'content' => 'required' 6+ ]); 7+ 8+ $post = Post::findOrFail($id); 9+ 10+ $post->update([ 11+ 'title' => $request->title, 12+ 'content' => $request->content 13+ ]); 14+ 15+ return redirect()->route('posts.show', ['post' => $post->id]); 16+ }
これは、編集フォームから送信されたデータで既存の投稿を更新するupdateメソッドです。
storeメソッドと同様に、まず$request->validate()で入力値のチェックを行います。$post = Post::findOrFail($id);で、更新対象のレコードをデータベースから取得します。$post->update([...])で、フォームから送信された新しいtitleとcontentでレコードの値を更新します。- 最後に
redirect()->route(...)で、更新が完了した投稿の詳細ページへリダイレクトします。
変更点: 投稿を削除する処理を追加
/app/Http/Controllers/PostController.php1+ public function destroy(string $id) 2+ { 3+ $post = Post::findOrFail($id); 4+ $post->delete(); 5+ return redirect()->route('posts.index'); 6+ }
これは、特定の投稿を削除するためのdestroyメソッドです。
$post = Post::findOrFail($id);で、URLのIDを元に削除対象の投稿データを取得します。$post->delete();で、取得したレコードをデータベースから削除します。return redirect()->route('posts.index');で、削除が完了したら、投稿の一覧ページへリダイレクトします。
変更点: 全てのページで共通のレイアウトを作成
/resources/views/layout.blade.php1+<!DOCTYPE html> 2+<html lang="ja"> 3+<head> 4+ <meta charset="UTF-8"> 5+ <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6+ <title>掲示板アプリ</title> 7+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"> 8+</head> 9+<body> 10+ @yield('content') 11+</body> 12+</html>
これは、アプリケーション全体の共通レイアウトを定義するBladeテンプレートです。
全てのページで共通となるHTMLのヘッダー部分(文字コードの指定やタイトルの設定など)を記述しています。また、CSSフレームワークであるBootstrapを読み込んで、手軽にデザインを整えられるようにしています。
@yield('content')の部分がこのレイアウトの重要なポイントです。他のビューファイルで@section('content')と定義された部分が、この@yield('content')の部分に埋め込まれて表示される仕組みになっています。これにより、各ページで共通のヘッダーやフッターを何度も書く必要がなくなります。
変更点: 投稿の一覧を表示する画面を作成
/resources/views/posts/index.blade.php1+@extends('layout') 2+ 3+@section('content') 4+<div class="container mt-5"> 5+ <h2>掲示板</h2> 6+ <a href="{{ route('posts.create') }}" class="btn btn-primary mb-3">新規投稿</a> 7+ @foreach ($posts as $post) 8+ <div class="card mb-3"> 9+ <div class="card-body"> 10+ <h3 class="card-title">{{ $post->title }}</h3> 11+ <p class="card-text">{{ $post->content }}</p> 12+ <a href="{{ route('posts.show', $post->id) }}" class="btn btn-info">詳細</a> 13+ </div> 14+ </div> 15+ @endforeach 16+</div> 17+@endsection
これは投稿の一覧を表示するビューです。
@extends('layout')で、先ほど作成した共通レイアウトファイルを読み込みます。@section('content')から@endsectionまでの間に、このページ固有のHTMLを記述します。この部分がレイアウトの@yield('content')に埋め込まれます。@foreach ($posts as $post)は、コントローラーから渡された投稿データ$postsを1件ずつ取り出して繰り返し処理を行うための構文です。- ループの中で
{{ $post->title }}や{{ $post->content }}のようにして、各投稿のタイトルと内容を表示しています。{{ }}は、クロスサイトスクリプティング(XSS)というセキュリティ攻撃を防ぎながら安全に変数の内容を表示するための記法です。 route()ヘルパーを使って、新規投稿画面や詳細画面への正しいURLを生成しています。
変更点: 新規投稿フォーム画面を作成
/resources/views/posts/create.blade.php1+@extends('layout') 2+ 3+@section('content') 4+<div class="container mt-5"> 5+ <h2>新規投稿</h2> 6+ <form action="{{ route('posts.store') }}" method="POST"> 7+ @csrf 8+ <div class="mb-3"> 9+ <label for="title" class="form-label">タイトル</label> 10+ <input type="text" name="title" class="form-control" required> 11+ </div> 12+ <div class="mb-3"> 13+ <label for="content" class="form-label">内容</label> 14+ <textarea name="content" class="form-control" rows="5" required></textarea> 15+ </div> 16+ <button type="submit" class="btn btn-success">投稿</button> 17+ </form> 18+ ... 19+</div> 20+@endsection
これは新規投稿を行うためのフォーム画面のビューです。
<form>タグのaction属性には、フォームの送信先URLを指定します。ここではroute('posts.store')を使い、データを保存する処理(PostControllerのstoreメソッド)に紐づいたURLを自動生成しています。method="POST"は、データをサーバーに送信する方法を指定しています。@csrfは、CSRF(クロスサイトリクエストフォージェリ)というセキュリティ攻撃からアプリケーションを守るために必須の記述です。これを書くだけでLaravelが自動で対策を行ってくれます。
変更点: 投稿の詳細を表示する画面を作成
/resources/views/posts/show.blade.php1+@extends('layout') 2+ 3+@section('content') 4+<div class="container mt-5"> 5+ ... 6+ <div class="card mb-3"> 7+ <div class="card-body"> 8+ <h3 class="card-title">{{ $post->title }}</h3> 9+ <p class="card-text">{{ $post->content }}</p> 10+ <a href="{{ route('posts.edit', $post->id ) }}" class="btn btn-warning">編集</a> 11+ <form action="{{ route('posts.destroy', $post->id) }}" method="POST" class="d-inline"> 12+ @csrf 13+ @method('DELETE') 14+ <button type="submit" class="btn btn-danger">削除</button> 15+ </form> 16+ </div> 17+ </div> 18+</div> 19+@endsection
これは投稿1件の詳細情報を表示するビューです。
- コントローラーから渡された
$postオブジェクトのtitleとcontentプロパティを表示しています。 - 編集ボタンや削除ボタンが設置されています。
- 削除機能は
<form>を使って実装します。actionには削除処理のURL(route('posts.destroy', ...))を指定し、methodはPOSTとします。HTMLのフォームはDELETEメソッドを直接サポートしていないため、@method('DELETE')という記述を追加することで、Laravelに「これはDELETEリクエストである」と伝えています。
変更点: 投稿編集フォーム画面を作成
/resources/views/posts/edit.blade.php1+@extends('layout') 2+ 3+@section('content') 4+<div class="container mt-5"> 5+ <h2>投稿編集</h2> 6+ <form action="{{ route('posts.update', $post->id) }}" method="POST"> 7+ @csrf 8+ @method('PUT') 9+ <div class="mb-3"> 10+ <label for="title" class="form-label">タイトル</label> 11+ <input type="text" name="title" class="form-control" value="{{ $post->title }}" required> 12+ </div> 13+ <div class="mb-3"> 14+ <label for="content" class="form-label">内容</label> 15+ <textarea name="content" class="form-control" rows="5" required>{{ $post->content }}</textarea> 16+ </div> 17+ <button type="submit" class="btn btn-success">更新</button> 18+ </form> 19+</div> 20+@endsection
これは既存の投稿を編集するためのフォーム画面のビューです。
- 新規作成フォームと似ていますが、
inputタグのvalue属性やtextareaタグ内に{{ $post->title }}のように記述することで、コントローラーから渡された既存の投稿データをフォームの初期値として表示しています。 - フォームの送信先は更新処理を行う
posts.updateルートに設定されています。 - 削除フォームと同様に、HTMLは
PUTメソッドをサポートしていないため、@method('PUT')を記述してLaravelに更新リクエストであることを伝えています。
おわりに
今回はLaravelで掲示板アプリを作りながら、Webアプリケーションの基本となるCRUD(作成、表示、更新、削除)機能を一通り実装しました。Route::resourceでURLを定義し、リクエストに応じてControllerがModelを操作、その結果をBladeビューに表示するという、MVCモデルの役割分担と一連の流れを実践的に理解できたのではないでしょうか。@csrfやvalidateといったLaravelの便利な機能にも触れましたので、ここで学んだ基礎を元に、ぜひ機能の追加や改善に挑戦してみてください。