- 投稿日:2019-10-02T23:45:40+09:00
【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第6回コメントといいね機能)
Laravelで始めるTwitter風(Twitterクローン)のSNSツール開発チュートリアル
概要
スクールとかの課題だったりLaravelを初めてみたいけど何を作ろうって迷ってる人向けによくあるTwitter風のWEBサイトを作ってみます。
前回
- 第1回 【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第1回DB設計とMigration)
- 第2回 【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第2回Seeder->ログイン/新規登録)
- 第3回 【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第3回ユーザ関連とフォロー機能)
- 第4回 【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第4回ツイートのCRUD機能)
- 第5回 【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第5回ツイートのCRUD機能 編集と削除)
- 第6回 【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第6回コメントといいね機能)
いよいよ最後となった第6回はコメントといいね機能を実装していきます??
前提
- PHPをある程度理解している
- Homesteadをインストールしている
- MVC構造をある程度理解している
環境
- Mac
- Homestead
- Laravel 5.8
コメント機能
それではツイートに対してコメントを付けれる機能を実装していきます。
ルーティング
コメント機能を実装していくにあたりルーティングの定義をします。
コメント機能では保存のみの実装になるのでstore
のみ指定します。routes/web.php// ログイン状態 Route::group(['middleware' => 'auth'], function() { // 省略 // コメント関連 Route::resource('comments', 'CommentsController', ['only' => ['store']]); });Model
Controllerからバリデーションが通ってきたという前提で、データを保存していきます。
引数にはコメントしたユーザのユーザIDとなる$user_id
とコメントのデータ$data
を設定します。app/Models/Comment.phppublic function commentStore(Int $user_id, Array $data) { $this->user_id = $user_id; $this->tweet_id = $data['tweet_id']; $this->text = $data['text']; $this->save(); return; }Controller
では例によって
CommentsController.php
ファイルを--resource
で作成しましょう。php artisan make:controller CommentsController --resource
ファイルを生成したら以下の内容で書いていきます。
基本的に【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第4回ツイートのCRUD機能)でやったツイートの保存と同じ流れなので説明は省きます
app/Http/Controllers/CommentsController.php<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; use App\Models\Comment; class CommentsController extends Controller { public function store(Request $request, Comment $comment) { $user = auth()->user(); $data = $request->all(); $validator = Validator::make($data, [ 'tweet_id' =>['required', 'integer'], 'text' => ['required', 'string', 'max:140'] ]); $validator->validate(); $comment->commentStore($user->id, $data); return back(); } }View
早速Viewを作っていきたいのですが、コメントはツイートのViewを使用します。
ツイートの詳細画面からしか投稿できないという仕様なのでtweets/show.blade.php
に組み込んでいきます。
<!-- ここから下を変更してください -->
の部分を上書きしてください。resources/views/tweets/show.blade.php@extends('layouts.app') @section('content') <div class="container"> <div class="row justify-content-center mb-5"> <div class="col-md-8 mb-3"> <div class="card"> <div class="card-haeder p-3 w-100 d-flex"> <img src="{{ asset('storage/profile_image/' .$tweet->user->profile_image) }}" class="rounded-circle" width="50" height="50"> <div class="ml-2 d-flex flex-column"> <p class="mb-0">{{ $tweet->user->name }}</p> <a href="{{ url('users/' .$tweet->user->id) }}" class="text-secondary">{{ $tweet->user->screen_name }}</a> </div> <div class="d-flex justify-content-end flex-grow-1"> <p class="mb-0 text-secondary">{{ $tweet->created_at->format('Y-m-d H:i') }}</p> </div> </div> <div class="card-body"> {!! nl2br(e($tweet->text)) !!} </div> <div class="card-footer py-1 d-flex justify-content-end bg-white"> @if ($tweet->user->id === Auth::user()->id) <div class="dropdown mr-3 d-flex align-items-center"> <a href="#" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> <i class="fas fa-ellipsis-v fa-fw"></i> </a> <div class="dropdown-menu" aria-labelledby="dropdownMenuLink"> <form method="POST" action="{{ url('tweets/' .$tweet->id) }}" class="mb-0"> @csrf @method('DELETE') <a href="{{ url('tweets/' .$tweet->id .'/edit') }}" class="dropdown-item">編集</a> <button type="submit" class="dropdown-item del-btn">削除</button> </form> </div> </div> @endif <div class="mr-3 d-flex align-items-center"> <a href="{{ url('tweets/' .$tweet->id) }}"><i class="far fa-comment fa-fw"></i></a> <p class="mb-0 text-secondary">{{ count($tweet->comments) }}</p> </div> <div class="d-flex align-items-center"> <button type="" class="btn p-0 border-0 text-primary"><i class="far fa-heart fa-fw"></i></button> <p class="mb-0 text-secondary">{{ count($tweet->favorites) }}</p> </div> </div> </div> </div> </div> <!-- ここから下を変更してください --> <div class="row justify-content-center"> <div class="col-md-8 mb-3"> <ul class="list-group"> @forelse ($comments as $comment) <li class="list-group-item"> <div class="py-3 w-100 d-flex"> <img src="{{ asset('storage/profile_image/' .$comment->user->profile_image) }}" class="rounded-circle" width="50" height="50"> <div class="ml-2 d-flex flex-column"> <p class="mb-0">{{ $comment->user->name }}</p> <a href="{{ url('users/' .$comment->user->id) }}" class="text-secondary">{{ $comment->user->screen_name }}</a> </div> <div class="d-flex justify-content-end flex-grow-1"> <p class="mb-0 text-secondary">{{ $comment->created_at->format('Y-m-d H:i') }}</p> </div> </div> <div class="py-3"> {!! nl2br(e($comment->text)) !!} </div> </li> @empty <li class="list-group-item"> <p class="mb-0 text-secondary">コメントはまだありません。</p> </li> @endforelse <li class="list-group-item"> <div class="py-3"> <form method="POST" action="{{ route('comments.store') }}"> @csrf <div class="form-group row mb-0"> <div class="col-md-12 p-3 w-100 d-flex"> <img src="{{ asset('storage/profile_image/' .$user->profile_image) }}" class="rounded-circle" width="50" height="50"> <div class="ml-2 d-flex flex-column"> <p class="mb-0">{{ $user->name }}</p> <a href="{{ url('users/' .$user->id) }}" class="text-secondary">{{ $user->screen_name }}</a> </div> </div> <div class="col-md-12"> <input type="hidden" name="tweet_id" value="{{ $tweet->id }}"> <textarea class="form-control @error('text') is-invalid @enderror" name="text" required autocomplete="text" rows="4">{{ old('text') }}</textarea> @error('text') <span class="invalid-feedback" role="alert"> <strong>{{ $message }}</strong> </span> @enderror </div> </div> <div class="form-group row mb-0"> <div class="col-md-12 text-right"> <p class="mb-4 text-danger">140文字以内</p> <button type="submit" class="btn btn-primary"> ツイートする </button> </div> </div> </form> </div> </li> </ul> </div> </div> </div> @endsectionこれでコメントが付いていないときは
コメントはまだありません。
と表示されて、
ログインしてるユーザがコメント出来る様になっていると思います。投稿すると以下のようにちゃんとコメントが投稿されているのが確認できていると思います。
これでコメント機能の実装は終わりです。
いいね機能
これでとりあえず最後の機能となります。
こういうのも欲しいみたいな意見は聞かないいいね機能では一人につき1いいね。
まだいいねを付けていない投稿に対してはいいねを保存。
逆に既にいいねを付けているツイートに対して再度いいねを押すと削除するという仕様にします。ルーティング
さてお決まりのルーティングを設定していきましょう。
いいね機能では保存のstore
と削除のdestroy
を設定しておきます。routes/web.php// ログイン状態 Route::group(['middleware' => 'auth'], function() { // 省略 // いいね関連 Route::resource('favorites', 'FavoritesController', ['only' => ['store', 'destroy']]); });Model
app/Models/Favorite.php<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class Favorite extends Model { public $timestamps = false; // いいねしているかどうかの判定処理 public function isFavorite(Int $user_id, Int $tweet_id) { return (boolean) $this->where('user_id', $user_id)->where('tweet_id', $tweet_id)->first(); } public function storeFavorite(Int $user_id, Int $tweet_id) { $this->user_id = $user_id; $this->tweet_id = $tweet_id; $this->save(); return; } public function destroyFavorite(Int $favorite_id) { return $this->where('id', $favorite_id)->delete(); } }
isFavorite
という見慣れないメソッドがありますね。
これはいいねを押した際にツイートに対して既にいいね済みであればfalse
、逆に存在しなければtrue
これで正しいデータが飛んできたかどうかを判定しています。Controller
またまた
FavoritesController.php
ファイルを--resource
で作成しましょう。php artisan make:controller FavoritesController --resource
いいね機能の場合、見ている全ツイートに対していいね済かの判定があり同じViewファイルに
store
とdestroy
のaction
が異なったformが混在した形で渡されます。
そのためログインしているユーザがツイートに対していいねをしているかの判定はフロントで行い、その判定の分岐でstore
かdestroy
どちらかのデータがControllerに渡されます。app/Http/Controllers/FavoritesController.php<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use App\Models\Favorite; class FavoritesController extends Controller { public function store(Request $request, Favorite $favorite) { $user = auth()->user(); $tweet_id = $request->tweet_id; $is_favorite = $favorite->isFavorite($user->id, $tweet_id); if(!$is_favorite) { $favorite->storeFavorite($user->id, $tweet_id); return back(); } return back(); } public function destroy(Favorite $favorite) { $user_id = $favorite->user_id; $tweet_id = $favorite->tweet_id; $favorite_id = $favorite->id; $is_favorite = $favorite->isFavorite($user_id, $tweet_id); if($is_favorite) { $favorite->destroyFavorite($favorite_id); return back(); } return back(); } }View
いいね機能もコメント機能同様にViewが存在しないので、こちらも
tweets
に書いていきます。
コメントとは違ってツイート一覧画面とツイート詳細画面さらにユーザ詳細画面3つあるので、一気に3つやっていきます。全て
<!-- ここから --><!-- ここまで -->
の間の部分を上書きしてください。ユーザ詳細画面(users/show.blade.php)
先ほど実装したコメントのリンクもついでに設定しておきます。
resources/views/users/show.blade.php@extends('layouts.app') @section('content') <div class="container"> <div class="row justify-content-center"> <div class="col-md-8 mb-3"> <div class="card"> <div class="d-inline-flex"> <div class="p-3 d-flex flex-column"> <img src="{{ asset('storage/profile_image/' .$user->profile_image) }}" class="rounded-circle" width="100" height="100"> <div class="mt-3 d-flex flex-column"> <h4 class="mb-0 font-weight-bold">{{ $user->name }}</h4> <span class="text-secondary">{{ $user->screen_name }}</span> </div> </div> <div class="p-3 d-flex flex-column justify-content-between"> <div class="d-flex"> <div> @if ($user->id === Auth::user()->id) <a href="{{ url('users/' .$user->id .'/edit') }}" class="btn btn-primary">プロフィールを編集する</a> @else @if ($is_following) <form action="{{ route('unfollow', ['id' => $user->id]) }}" method="POST" class="mb-2"> {{ csrf_field() }} {{ method_field('DELETE') }} <button type="submit" class="btn btn-danger">フォロー解除</button> </form> @else <form action="{{ route('follow', ['id' => $user->id]) }}" method="POST" class="mb-2"> {{ csrf_field() }} <button type="submit" class="btn btn-primary">フォローする</button> </form> @endif @if ($is_followed) <span class="mt-2 px-1 bg-secondary text-light">フォローされています</span> @endif @endif </div> </div> <div class="d-flex justify-content-end"> <div class="p-2 d-flex flex-column align-items-center"> <p class="font-weight-bold">ツイート数</p> <span>{{ $tweet_count }}</span> </div> <div class="p-2 d-flex flex-column align-items-center"> <p class="font-weight-bold">フォロー数</p> <span>{{ $follow_count }}</span> </div> <div class="p-2 d-flex flex-column align-items-center"> <p class="font-weight-bold">フォロワー数</p> <span>{{ $follower_count }}</span> </div> </div> </div> </div> </div> </div> @if (isset($timelines)) @foreach ($timelines as $timeline) <div class="col-md-8 mb-3"> <div class="card"> <div class="card-haeder p-3 w-100 d-flex"> <img src="{{ asset('storage/profile_image/' .$user->profile_image) }}" class="rounded-circle" width="50" height="50"> <div class="ml-2 d-flex flex-column flex-grow-1"> <p class="mb-0">{{ $timeline->user->name }}</p> <a href="{{ url('users/' .$timeline->user->id) }}" class="text-secondary">{{ $timeline->user->screen_name }}</a> </div> <div class="d-flex justify-content-end flex-grow-1"> <p class="mb-0 text-secondary">{{ $timeline->created_at->format('Y-m-d H:i') }}</p> </div> </div> <div class="card-body"> {{ $timeline->text }} </div> <div class="card-footer py-1 d-flex justify-content-end bg-white"> @if ($timeline->user->id === Auth::user()->id) <div class="dropdown mr-3 d-flex align-items-center"> <a href="#" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> <i class="fas fa-ellipsis-v fa-fw"></i> </a> <div class="dropdown-menu" aria-labelledby="dropdownMenuLink"> <form method="POST" action="{{ url('tweets/' .$timeline->id) }}" class="mb-0"> @csrf @method('DELETE') <a href="{{ url('tweets/' .$timeline->id .'/edit') }}" class="dropdown-item">編集</a> <button type="submit" class="dropdown-item del-btn">削除</button> </form> </div> </div> @endif <!-- ここから --> <div class="mr-3 d-flex align-items-center"> <a href="{{ url('tweets/' .$timeline->id) }}"><i class="far fa-comment fa-fw"></i></a> <p class="mb-0 text-secondary">{{ count($timeline->comments) }}</p> </div> <div class="d-flex align-items-center"> @if (!in_array(Auth::user()->id, array_column($timeline->favorites->toArray(), 'user_id'), TRUE)) <form method="POST" action="{{ url('favorites/') }}" class="mb-0"> @csrf <input type="hidden" name="tweet_id" value="{{ $timeline->id }}"> <button type="submit" class="btn p-0 border-0 text-primary"><i class="far fa-heart fa-fw"></i></button> </form> @else <form method="POST"action="{{ url('favorites/' .array_column($timeline->favorites->toArray(), 'id', 'user_id')[Auth::user()->id]) }}" class="mb-0"> @csrf @method('DELETE') <button type="submit" class="btn p-0 border-0 text-danger"><i class="fas fa-heart fa-fw"></i></button> </form> @endif <p class="mb-0 text-secondary">{{ count($timeline->favorites) }}</p> </div> <!-- ここまで --> </div> </div> </div> @endforeach @endif </div> <div class="my-4 d-flex justify-content-center"> {{ $timelines->links() }} </div> </div> @endsectionツイート一覧画面(tweets/index.blade.php)
resources/views/tweets/index.blade.php@extends('layouts.app') @section('content') <div class="container"> <div class="row justify-content-center"> <div class="col-md-8 mb-3 text-right"> <a href="{{ url('users') }}">ユーザ一覧 <i class="fas fa-users" class="fa-fw"></i> </a> </div> @if (isset($timelines)) @foreach ($timelines as $timeline) <div class="col-md-8 mb-3"> <div class="card"> <div class="card-haeder p-3 w-100 d-flex"> <img src="{{ asset('storage/profile_image/' .$timeline->user->profile_image) }}" class="rounded-circle" width="50" height="50"> <div class="ml-2 d-flex flex-column"> <p class="mb-0">{{ $timeline->user->name }}</p> <a href="{{ url('users/' .$timeline->user->id) }}" class="text-secondary">{{ $timeline->user->screen_name }}</a> </div> <div class="d-flex justify-content-end flex-grow-1"> <p class="mb-0 text-secondary">{{ $timeline->created_at->format('Y-m-d H:i') }}</p> </div> </div> <div class="card-body"> {!! nl2br(e($timeline->text)) !!} </div> <div class="card-footer py-1 d-flex justify-content-end bg-white"> @if ($timeline->user->id === Auth::user()->id) <div class="dropdown mr-3 d-flex align-items-center"> <a href="#" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> <i class="fas fa-ellipsis-v fa-fw"></i> </a> <div class="dropdown-menu" aria-labelledby="dropdownMenuLink"> <form method="POST" action="{{ url('tweets/' .$timeline->id) }}" class="mb-0"> @csrf @method('DELETE') <a href="{{ url('tweets/' .$timeline->id .'/edit') }}" class="dropdown-item">編集</a> <button type="submit" class="dropdown-item del-btn">削除</button> </form> </div> </div> @endif <div class="mr-3 d-flex align-items-center"> <a href="{{ url('tweets/' .$timeline->id) }}"><i class="far fa-comment fa-fw"></i></a> <p class="mb-0 text-secondary">{{ count($timeline->comments) }}</p> </div> <!-- ここから --> <div class="d-flex align-items-center"> @if (!in_array($user->id, array_column($timeline->favorites->toArray(), 'user_id'), TRUE)) <form method="POST" action="{{ url('favorites/') }}" class="mb-0"> @csrf <input type="hidden" name="tweet_id" value="{{ $timeline->id }}"> <button type="submit" class="btn p-0 border-0 text-primary"><i class="far fa-heart fa-fw"></i></button> </form> @else <form method="POST" action="{{ url('favorites/' .array_column($timeline->favorites->toArray(), 'id', 'user_id')[$user->id]) }}" class="mb-0"> @csrf @method('DELETE') <button type="submit" class="btn p-0 border-0 text-danger"><i class="fas fa-heart fa-fw"></i></button> </form> @endif <p class="mb-0 text-secondary">{{ count($timeline->favorites) }}</p> </div> <!-- ここまで --> </div> </div> </div> @endforeach @endif </div> <div class="my-4 d-flex justify-content-center"> {{ $timelines->links() }} </div> </div> @endsectionツイート詳細画面(tweets/show.blade.php)
resources/views/tweets/show.blade.php@extends('layouts.app') @section('content') <div class="container"> <div class="row justify-content-center mb-5"> <div class="col-md-8 mb-3"> <div class="card"> <div class="card-haeder p-3 w-100 d-flex"> <img src="{{ asset('storage/profile_image/' .$tweet->user->profile_image) }}" class="rounded-circle" width="50" height="50"> <div class="ml-2 d-flex flex-column"> <p class="mb-0">{{ $tweet->user->name }}</p> <a href="{{ url('users/' .$tweet->user->id) }}" class="text-secondary">{{ $tweet->user->screen_name }}</a> </div> <div class="d-flex justify-content-end flex-grow-1"> <p class="mb-0 text-secondary">{{ $tweet->created_at->format('Y-m-d H:i') }}</p> </div> </div> <div class="card-body"> {!! nl2br(e($tweet->text)) !!} </div> <div class="card-footer py-1 d-flex justify-content-end bg-white"> @if ($tweet->user->id === Auth::user()->id) <div class="dropdown mr-3 d-flex align-items-center"> <a href="#" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> <i class="fas fa-ellipsis-v fa-fw"></i> </a> <div class="dropdown-menu" aria-labelledby="dropdownMenuLink"> <form method="POST" action="{{ url('tweets/' .$tweet->id) }}" class="mb-0"> @csrf @method('DELETE') <a href="{{ url('tweets/' .$tweet->id .'/edit') }}" class="dropdown-item">編集</a> <button type="submit" class="dropdown-item del-btn">削除</button> </form> </div> </div> @endif <div class="mr-3 d-flex align-items-center"> <a href="{{ url('tweets/' .$tweet->id) }}"><i class="far fa-comment fa-fw"></i></a> <p class="mb-0 text-secondary">{{ count($tweet->comments) }}</p> </div> <!-- ここから --> <div class="d-flex align-items-center"> @if (!in_array($user->id, array_column($tweet->favorites->toArray(), 'user_id'), TRUE)) <form method="POST" action="{{ url('favorites/') }}" class="mb-0"> @csrf <input type="hidden" name="tweet_id" value="{{ $tweet->id }}"> <button type="submit" class="btn p-0 border-0 text-primary"><i class="far fa-heart fa-fw"></i></button> </form> @else <form method="POST" action="{{ url('favorites/' .array_column($tweet->favorites->toArray(), 'id', 'user_id')[$user->id]) }}" class="mb-0"> @csrf @method('DELETE') <button type="submit" class="btn p-0 border-0 text-danger"><i class="fas fa-heart fa-fw"></i></button> </form> @endif <p class="mb-0 text-secondary">{{ count($tweet->favorites) }}</p> </div> <!-- ここまで --> </div> </div> </div> </div> <div class="row justify-content-center"> <div class="col-md-8 mb-3"> <ul class="list-group"> @forelse ($comments as $comment) <li class="list-group-item"> <div class="py-3 w-100 d-flex"> <img src="{{ asset('storage/profile_image/' .$comment->user->profile_image) }}" class="rounded-circle" width="50" height="50"> <div class="ml-2 d-flex flex-column"> <p class="mb-0">{{ $comment->user->name }}</p> <a href="{{ url('users/' .$comment->user->id) }}" class="text-secondary">{{ $comment->user->screen_name }}</a> </div> <div class="d-flex justify-content-end flex-grow-1"> <p class="mb-0 text-secondary">{{ $comment->created_at->format('Y-m-d H:i') }}</p> </div> </div> <div class="py-3"> {!! nl2br(e($comment->text)) !!} </div> </li> @empty <li class="list-group-item"> <p class="mb-0 text-secondary">コメントはまだありません。</p> </li> @endforelse <li class="list-group-item"> <div class="py-3"> <form method="POST" action="{{ route('comments.store') }}"> @csrf <div class="form-group row mb-0"> <div class="col-md-12 p-3 w-100 d-flex"> <img src="{{ asset('storage/profile_image/' .$user->profile_image) }}" class="rounded-circle" width="50" height="50"> <div class="ml-2 d-flex flex-column"> <p class="mb-0">{{ $user->name }}</p> <a href="{{ url('users/' .$user->id) }}" class="text-secondary">{{ $user->screen_name }}</a> </div> </div> <div class="col-md-12"> <input type="hidden" name="tweet_id" value="{{ $tweet->id }}"> <textarea class="form-control @error('text') is-invalid @enderror" name="text" required autocomplete="text" rows="4">{{ old('text') }}</textarea> @error('text') <span class="invalid-feedback" role="alert"> <strong>{{ $message }}</strong> </span> @enderror </div> </div> <div class="form-group row mb-0"> <div class="col-md-12 text-right"> <p class="mb-4 text-danger">140文字以内</p> <button type="submit" class="btn btn-primary"> ツイートする </button> </div> </div> </form> </div> </li> </ul> </div> </div> </div> @endsectionこれで各画面でいいねを押してみる
ユーザ詳細画面
ツイート詳細画面では逆にいいねを外してみる
ツイート詳細画面
一覧画面ではもう一度いいねをしてみて動いてるか確認する。
ツイート一覧画面
完成!!??????
Congratulations!
これで
【全6回】Laravel5.8でTwitterっぽいSNSツールを作る
は終了です。
なんとなくLaravelの取っ付きやすさは伝わりましたか?
これを機にLaravelを使いたくなったという方がいれば嬉しいです!!??
お疲れ様でした!!
絶対どこがで間違いがあるはずなので、都度修正していきます。
訂正箇所があれば教えていただければ幸いです?
- 投稿日:2019-10-02T22:21:23+09:00
【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第5回ツイートのCRUD機能 編集と削除)
Laravelで始めるTwitter風(Twitterクローン)のSNSツール開発チュートリアル
概要
スクールとかの課題だったりLaravelを初めてみたいけど何を作ろうって迷ってる人向けによくあるTwitter風のWEBサイトを作ってみます。
前回
- 第1回 【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第1回DB設計とMigration)
- 第2回 【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第2回Seeder->ログイン/新規登録)
- 第3回 【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第3回ユーザ関連とフォロー機能)
- 第4回 【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第4回ツイートのCRUD機能)
- 第5回 【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第5回ツイートのCRUD機能 編集と削除)
- 第6回 【全6回】Laravel5.8でTwitterっぽいSNSツールを作る(第6回コメントといいね機能)
第4回ツイートのCRUD機能の続きを追加していきます。
今回はツイートの編集(Update)と削除(Delete)をやっていきます。前提
- PHPをある程度理解している
- Homesteadをインストールしている
- MVC構造をある程度理解している
環境
- Mac
- Homestead
- Laravel 5.8
リダイレクト先
ついでに現在ログインすると/homeに飛ぶようになっているのでこちらも/tweetsにリダイレクトするように設定しましょう(忘れてた)
- 対象のファイル名
- LoginController.php
- RegisterController.php
- ResetPasswordController.php
- VerificationController.php
- RedirectIfAuthenticated.php
上記のファイルから
/home
の部分を全て/tweets
に変更してください。そうするとログイン後も
/tweets
にリダイレクトするようになります!Update(ツイート編集機能)
ではツイート内容の編集機能を実装していきましょう。
Update(ツイート編集画面)
では次はCRUDのUpdateをやっていきましょう!
まずはツイート投稿と同じく編集画面から作っていきます。Model
$user_id
と$tweet_id
に値に一致するツイートを取得します。app/Models/Tweet.phppublic function getEditTweet(Int $user_id, Int $tweet_id) { return $this->where('user_id', $user_id)->where('id', $tweet_id)->first(); }Controller
編集するツイートを先ほどの
getEditTweet
に$tweet_id
を渡してその結果をViewに渡す処理をします。app/Http/Controllers/TweetsController.phppublic function edit(Tweet $tweet) { $user = auth()->user(); $tweets = $tweet->getEditTweet($user->id, $tweet->id); if (!isset($tweets)) { return redirect('tweets'); } return view('tweets.edit', [ 'user' => $user, 'tweets' => $tweets ]); }View
resources/views/tweets/edit.blade.php@extends('layouts.app') @section('content') <div class="container"> <div class="row justify-content-center"> <div class="col-md-8"> <div class="card"> <div class="card-header">Update</div> <div class="card-body"> <form method="POST" action="{{ route('tweets.update', ['tweets' => $tweets]) }}"> @csrf @method('PUT') <div class="form-group row mb-0"> <div class="col-md-12 p-3 w-100 d-flex"> <img src="{{ asset('storage/profile_image/' .$user->profile_image) }}" class="rounded-circle" width="50" height="50"> <div class="ml-2 d-flex flex-column"> <p class="mb-0">{{ $user->name }}</p> <a href="{{ url('users/' .$user->id) }}" class="text-secondary">{{ $user->screen_name }}</a> </div> </div> <div class="col-md-12"> <textarea class="form-control @error('text') is-invalid @enderror" name="text" required autocomplete="text" rows="4">{{ old('text') ? : $tweets->text }}</textarea> @error('text') <span class="invalid-feedback" role="alert"> <strong>{{ $message }}</strong> </span> @enderror </div> </div> <div class="form-group row mb-0"> <div class="col-md-12 text-right"> <p class="mb-4 text-danger">140文字以内</p> <button type="submit" class="btn btn-primary"> ツイートする </button> </div> </div> </form> </div> </div> </div> </div> </div> @endsectionツイート編集画面
ツイート一覧ページから自身のツイートのみ表示されるボタン
「・・・←これを縦にしたアイコン 笑」
を押すと編集ボタンが表示されるのでそれを押すとこのページに行けます!Update(ツイート編集機能)
では先ほどの編集画面から実際に編集した内容で更新する処理を書いていきましょう!
Model
この辺はただ上書きしているだけなので、特に触れません。
もしわからない人がいれば第3回のUser情報を更新するときと流れは一緒なのでもう一度見てみてください。
app/Models/Tweet.phppublic function tweetUpdate(Int $tweet_id, Array $data) { $this->id = $tweet_id; $this->text = $data['text']; $this->update(); return; }Controller
こちらも先ほど作成した
store
とほとんど同じなので説明は省きます。app/Http/Controllers/TweetsController.phppublic function update(Request $request, Tweet $tweet) { $data = $request->all(); $validator = Validator::make($data, [ 'text' => ['required', 'string', 'max:140'] ]); $validator->validate(); $tweet->tweetUpdate($tweet->id, $data); return redirect('tweets'); }これで編集ができるようになっていると思います!
Delete(ツイート削除)
次はツイートの削除を行ってみようと思います??
Delete(ツイート削除機能)
Model
$user_id
と$tweet_id
に一致したツイートを削除します。app/Models/Tweet.phppublic function tweetDestroy(Int $user_id, Int $tweet_id) { return $this->where('user_id', $user_id)->where('id', $tweet_id)->delete(); }Controller
$user_id
と$tweet_id
を先ほど作成したtweetDestroy()
メソッドに渡しています。app/Http/Controllers/TweetsController.phppublic function destroy(Tweet $tweet) { $user = auth()->user(); $tweet->tweetDestroy($user->id, $tweet->id); return back(); }これで削除機能を実装できましたので、先ほどの編集画面に遷移する方法と同様に削除するボタンがあるので試してみてください。
振り返り(ModelとControllerファイルの全体)
今回第4,5回とModelとControllerの行き来が多かったのでとりあえず全体も載せておきます!
ここまで問題なく動いたよって人はスルーしてください。Model
app/Models/Tweet.php<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\softDeletes; class Tweet extends Model { use SoftDeletes; /** * The attributes that are mass assignable. * * @var array */ protected $fillable = [ 'text' ]; public function user() { return $this->belongsTo(User::class); } public function favorites() { return $this->hasMany(Favorite::class); } public function comments() { return $this->hasMany(Comment::class); } public function getUserTimeLine(Int $user_id) { return $this->where('user_id', $user_id)->orderBy('created_at', 'DESC')->paginate(50); } public function getTweetCount(Int $user_id) { return $this->where('user_id', $user_id)->count(); } // 詳細画面 public function getTweet(Int $tweet_id) { return $this->with('user')->where('id', $tweet_id)->first(); } // 一覧画面 public function getTimeLines(Int $user_id, Array $follow_ids) { $follow_ids[] = $user_id; return $this->whereIn('user_id', $follow_ids)->orderBy('created_at', 'DESC')->paginate(50); } public function tweetStore(Int $user_id, Array $data) { $this->user_id = $user_id; $this->text = $data['text']; $this->save(); return; } public function getEditTweet(Int $user_id, Int $tweet_id) { return $this->where('user_id', $user_id)->where('id', $tweet_id)->first(); } public function tweetUpdate(Int $tweet_id, Array $data) { $this->id = $tweet_id; $this->text = $data['text']; $this->update(); return; } public function tweetDestroy(Int $user_id, Int $tweet_id) { return $this->where('user_id', $user_id)->where('id', $tweet_id)->delete(); } }Controller
app/Http/Controllers/TweetsController.php<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; use App\Models\Tweet; use App\Models\Comment; use App\Models\Follower; class TweetsController extends Controller { public function index(Tweet $tweet, Follower $follower) { $user = auth()->user(); $follow_ids = $follower->followingIds($user->id); $following_ids = $follow_ids->pluck('followed_id')->toArray(); $timelines = $tweet->getTimelines($user->id, $following_ids); return view('tweets.index', [ 'user' => $user, 'timelines' => $timelines ]); } public function create() { $user = auth()->user(); return view('tweets.create', [ 'user' => $user ]); } public function store(Request $request, Tweet $tweet) { $user = auth()->user(); $data = $request->all(); $validator = Validator::make($data, [ 'text' => ['required', 'string', 'max:140'] ]); $validator->validate(); $tweet->tweetStore($user->id, $data); return redirect('tweets'); } public function show(Tweet $tweet, Comment $comment) { $user = auth()->user(); $tweet = $tweet->getTweet($tweet->id); $comments = $comment->getComments($tweet->id); return view('tweets.show', [ 'user' => $user, 'tweet' => $tweet, 'comments' => $comments ]); } public function edit(Tweet $tweet) { $user = auth()->user(); $tweets = $tweet->getEditTweet($user->id, $tweet->id); if (!isset($tweets)) { return redirect('tweets'); } return view('tweets.edit', [ 'user' => $user, 'tweets' => $tweets ]); } public function update(Request $request, Tweet $tweet) { $data = $request->all(); $validator = Validator::make($data, [ 'text' => ['required', 'string', 'max:140'] ]); $validator->validate(); $tweet->tweetUpdate($tweet->id, $data); return redirect('tweets'); } public function destroy(Tweet $tweet) { $user = auth()->user(); $tweet->tweetDestroy($user->id, $tweet->id); return back(); } }以上です。
- 投稿日:2019-10-02T20:59:47+09:00
Laravelで実装したAPIがOpenAPIで記述された仕様に準拠しているかテストする
はじめに
APIを開発する上で、多くの場合、仕様書も作成するかと思いますが、どのように作成しているでしょうか?
この記事では、OpenAPI形式で記述されたAPI仕様があって、その仕様にAPIの実装が準拠しているかテストする方法を紹介します。tl;dr
- thephpleague/openapi-psr7-validatorを使って、
Request
およびResponse
がAPI仕様に準拠しているか検証する- 実行可能なサンプルを用意したので、
git clone
して色々試してほしい
OpenAPI v3
とは(Swagger v2
との違い)詳しくは後述の参考リンクを参照してほしいのですが、誤解を恐れずに要点をまとめると以下のようになります。
- APIを定義する仕様として、2010年に
Swagger 1.0
がリリースされるSwagger
は、Open API Initiative
に寄贈され、2017-07-26にOpen API 3.0
がリリースされるOpen API 3.0
はSwagger 2.0
から大幅な変更が加えられており、互換性はない参考リンク
- https://en.wikipedia.org/wiki/OpenAPI_Specification
- https://swagger.io/docs/specification/about/
- https://news.mynavi.jp/itsearch/article/devsoft/3854
OpenAPI v3
に対応した検証ライブラリは多くない(2019年10月1日現在)
Swagger v2
に対応したPHP製の検証ライブラリはいくつかあります。
しかし、いずれも、OpenAPI v3
には未対応です。
WakeOnWeb/swagger
OpenAPI v3 support · Issue #16 · WakeOnWeb/swaggerはいまだに
Open
のままです。
nabbar/SwaggerValidator-PHP
README.mdを見ると、
swagger / openapi version 3.0 (release >= 2.0)
とあるので、v2.0以上だったらOpen API 3.0
に対応しているのかと思いきや、最新のバージョンは1.3.2
です。
対応予定だけ書いて力尽きたようです。
OpenAPI v3
形式のファイルをJSON Schema
に変換すれば、検証はできるlaravel-petstore-apiの最初のコミットでもこの方法を使っているのですが、
OpenAPI v3
形式のファイルをJSON Schema
に変換し、Request
およびResponse
の検証はできます。ポイントは以下のとおりです。
- openapi2schemaを使って、
OpenAPI v3
からJSON Schema
へ変換する- justinrainbow/json-schemaを使って、
Request
およびResponse
を検証する2019年9月
thephpleague/openapi-psr7-validator
がリリースされるこの記事を書いている途中で気付いてしまったのですが、
thephpleague/openapi-psr7-validator
という何とも期待の持てるライブラリがあったので、試してみました。
結果、期待どおり動いたので、それまでのJSON Schema
に変換してから検証する実装、関連ライブラリを全部捨ててthephpleague/openapi-psr7-validator
で実装し直しました。実装
実装は下記のようになりました。
<?php namespace Tests\Feature; use Illuminate\Foundation\Testing\TestResponse; use Nyholm\Psr7\Factory\Psr17Factory; use OpenAPIValidation\PSR7\Exception\ValidationFailed; use OpenAPIValidation\PSR7\OperationAddress; use OpenAPIValidation\PSR7\ValidatorBuilder; use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; use Tests\TestCase; /** * Trait AssertResponseCompliantForSwaggerApiSpec * @package Tests\Feature * * @mixin TestCase */ trait OpenApiSpecAssertions { /** * @param TestResponse $testResponse * @param string $method * @param string $path */ protected function assertResponseCompliantForOpenApiSpec( TestResponse $testResponse, string $method, string $path ) { $validator = (new ValidatorBuilder()) ->fromYamlFile(__DIR__. '/../ApiSpec/petstore-expanded.yaml') ->getResponseValidator() ; $operation = new OperationAddress($path, strtolower($method)) ; $psr17Factory = new Psr17Factory(); $psrHttpFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); $psrResponse = $psrHttpFactory->createResponse($testResponse->baseResponse); try { $validator->validate($operation, $psrResponse); } catch (ValidationFailed $validationFailed) { self::fail($validationFailed->getPrevious()->getMessage()); } } /** * @param array $requestBody * @param string $method * @param string $uri */ protected function assertRequestCompliantForOpenApiSpec( array $requestBody, string $method, string $uri ) { $validator = (new ValidatorBuilder()) ->fromYamlFile(__DIR__. '/../ApiSpec/petstore-expanded.yaml') ->getRequestValidator() ; $psr17Factory = new Psr17Factory(); $json = json_encode($requestBody, JSON_UNESCAPED_UNICODE); $stream = $psr17Factory->createStream($json); $stream->rewind(); $request = $psr17Factory->createRequest(strtolower($method), $uri) ->withBody($stream) ->withHeader('Content-Type', 'application/json') ; try { $validator->validate($request); } catch (ValidationFailed $validationFailed) { self::fail($validationFailed->getPrevious()->getMessage()); } } }ポイント
tests/ApiSpec/petstore-expanded.yaml
にOpenAPI v3
形式のファイルを配置OpenAPIValidation\PSR7\ValidatorBuilder
のfromYamlFile
でvalidator
を生成- PSR-7に準拠した
Request
/Response
でなければいけないので、変換する必要があるPSR-7
形式への変換については、The PSR-7 Bridge (Symfony Docs)を参照- 仕様に違反した場合は、
OpenAPIValidation\PSR7\Exception\ValidationFailed
がthrow
される- 今回はテストを失敗させるようにした
API仕様に違反すると
API仕様を変更して、テストを失敗させてみます。
任意項目だった
NewPet
およびPet
のtag
というproperty
を必須(required
)にして、テストを実行します。openapi: "3.0.0" ## 中略 ## components: schemas: Pet: allOf: - $ref: '#/components/schemas/NewPet' - type: object required: - id properties: id: type: integer format: int64 NewPet: type: object required: - name - tag ## この行を追加 properties: name: type: string tag: type: stringKeyword validation failed: Required property 'tag' must be present in the object
というメッセージとともに、テストが失敗します。
$ vendor/bin/phpunit PHPUnit 8.3.5 by Sebastian Bergmann and contributors. ..FFF... 8 / 8 (100%) Time: 405 ms, Memory: 24.00 MB There were 3 failures: 1) Tests\Feature\Pet\IndexTest::indexSuccess Keyword validation failed: Required property 'tag' must be present in the object /path/to/laravel-petstore-api/tests/Feature/OpenApiSpecAssertions.php:45 /path/to/laravel-petstore-api/tests/Feature/Pet/IndexTest.php:23 2) Tests\Feature\Pet\ShowTest::showSuccess Keyword validation failed: Required property 'tag' must be present in the object /path/to/laravel-petstore-api/tests/Feature/OpenApiSpecAssertions.php:45 /path/to/laravel-petstore-api/tests/Feature/Pet/ShowTest.php:24 3) Tests\Feature\Pet\StoreTest::storeSuccess Keyword validation failed: Required property 'tag' must be present in the object /path/to/laravel-petstore-api/tests/Feature/OpenApiSpecAssertions.php:76 /path/to/laravel-petstore-api/tests/Feature/OpenApiSpecAssertions.php:98 /path/to/laravel-petstore-api/tests/Feature/Pet/StoreTest.php:23 FAILURES! Tests: 8, Assertions: 13, Failures: 3.おわりに
API仕様に準拠しているか、実装が変わるたびに検証されていないと、しだいにAPI仕様が陳腐化し、結果、「今動いている実装が正しい」ということになりがちです。
もちろん、動いているAPIから仕様書を自動生成するというアプローチもありますが、以下のような問題があると思います。
- API仕様を変更するのに
PHP
を触らなければいけない- API仕様を
PHP
のアノテーションで定義する形式だと、検査されずに陳腐化する可能性があるなので、先にAPI仕様を
OpenAPI v3
形式で作成してから、クライアントチームと合意をとりながら開発を進めていくのが良いのかなと個人的には考えています。この記事が、日々、多くのAPIを実装、保守しているサーバーサイドエンジニアの助けになれば幸いです。
ではでは。
- 投稿日:2019-10-02T18:28:46+09:00
Laravel:Trait内で毎回呼び出す処理を実装できた
Traitってなんだ?はこちらの記事を参照。
https://qiita.com/mokkos/items/53d1d4cbf57bd6ceba5eTrait に共通処理を実装している場合、常に呼び出したい処理ってありますよね。
公式ドキュメントに載っていなかったのでまとめました。まず、laravel は Model の基盤を Eloquent というクラスで定義しています。
それにより、Model内で boot() を書けば・・・あら不思議。Eloquentが呼び出されたとき、自動的に関数が呼び出されます。class Fugafuga extends Model { public static function boot() { // __construct() が呼び出された後に実行されます } }深掘りして、Model.php をみてみると・・・・
/** * Boot all of the bootable traits on the model. * * @return void */ protected static function bootTraits() { $class = static::class; foreach (class_uses_recursive($class) as $trait) { if (method_exists($class, $method = 'boot'.class_basename($trait))) { forward_static_call([$class, $method]); } } }が実装されていました。
つまり、Traitでも boot+(Trait名)って書くと勝手に初期に呼び出してくれることになります。Trait Hogehoge { public static function bootHogehoge() { } }このように実装することで、Hogehoge 内の関数が呼び出される直前に bootHogehoge() が呼び出されました!
- 投稿日:2019-10-02T14:28:01+09:00
Laravelで複数のデータベースを跨ぐテストを実施する際のトランザクションの設定
背景
LaravelのPHPUnitで
use DatabaseTransactions;を使用すると1つのデータベースへのトランザクションしか受け付けてくれない。
複数のデータベースを跨ぐトランザクションの設定を実施するには
connectionsToTransact
を使用する。
具体例
use Illuminate\Foundation\Testing\DatabaseTransactions; use Tests\TestCase; class HogeTest extends TestCase { use DatabaseTransactions; protected $connectionsToTransact = ['mysql1', 'mysql2'];参考
- 投稿日:2019-10-02T11:11:17+09:00
Laravel6でtymon/jwt-auth
パッケージインストールでエラー
composer require tymon/jwt-auth
で以下のようにエラーが発生しました。Your requirements could not be resolved to an installable set of packages. Problem 1 - Installation request for tymon/jwt-auth ^0.5.12 -> satisfiable by tymon/jwt-auth[0.5.12]. - Conclusion: remove laravel/framework v6.1.0 - Conclusion: don't install laravel/framework v6.1.0 - tymon/jwt-auth 0.5.12 requires illuminate/http ~5.0 -> satisfiable by illuminate/http[5.0.x-dev, 5.1.x-dev, 5.2.x-dev, 5.3.x-dev, 5.4.x-dev, 5.5.x-dev, 5.6.x-dev, 5.7.17, 5.7.18, 5.7.19, 5.7.x-dev, 5.8.x-dev, v5.0.0, v5.0.22, v5.0.25, v5.0.26, v5.0.28, v5.0.33, v5.0.4, v5.1.1, v5.1.13, v5.1.16, v5.1.2, v5.1.20, v5.1.22, v5.1.25, v5.1.28, v5.1.30, v5.1.31, v5.1.41, v5.1.6, v5.1.8, v5.2.0, v5.2.19, v5.2.21, v5.2.24, v5.2.25, v5.2.26, v5.2.27, v5.2.28, v5.2.31, v5.2.32, v5.2.37, v5.2.43, v5.2.45, v5.2.6, v5.2.7, v5.3.0, v5.3.16, v5.3.23, v5.3.4, v5.4.0, v5.4.13, v5.4.17, v5.4.19, v5.4.27, v5.4.36, v5.4.9, v5.5.0, v5.5.16, v5.5.17, v5.5.2, v5.5.28, v5.5.33, v5.5.34, v5.5.35, v5.5.36, v5.5.37, v5.5.39, v5.5.40, v5.5.41, v5.5.43, v5.5.44, v5.6.0, v5.6.1, v5.6.10, v5.6.11, v5.6.12, v5.6.13, v5.6.14, v5.6.15, v5.6.16, v5.6.17, v5.6.19, v5.6.2, v5.6.20, v5.6.21, v5.6.22, v5.6.23, v5.6.24, v5.6.25, v5.6.26, v5.6.27, v5.6.28, v5.6.29, v5.6.3, v5.6.30, v5.6.31, v5.6.32, v5.6.33, v5.6.34, v5.6.35, v5.6.36, v5.6.37, v5.6.38, v5.6.39, v5.6.4, v5.6.5, v5.6.6, v5.6.7, v5.6.8, v5.6.9, v5.7.0, v5.7.1, v5.7.10, v5.7.11, v5.7.15, v5.7.2, v5.7.20, v5.7.21, v5.7.22, v5.7.23, v5.7.26, v5.7.27, v5.7.28, v5.7.3, v5.7.4, v5.7.5, v5.7.6, v5.7.7, v5.7.8, v5.7.9, v5.8.0, v5.8.11, v5.8.12, v5.8.14, v5.8.15, v5.8.17, v5.8.18, v5.8.19, v5.8.2, v5.8.20, v5.8.22, v5.8.24, v5.8.27, v5.8.28, v5.8.29, v5.8.3, v5.8.30, v5.8.31, v5.8.32, v5.8.33, v5.8.34, v5.8.35, v5.8.4, v5.8.8, v5.8.9]. - don't install illuminate/http 5.0.x-dev|don't install laravel/framework v6.1.0 - don't install illuminate/http 5.1.x-dev|don't install laravel/framework v6.1.0 - don't install illuminate/http 5.2.x-dev|don't install laravel/framework v6.1.0 - don't install illuminate/http 5.3.x-dev|don't install laravel/framework v6.1.0 - don't install illuminate/http 5.4.x-dev|don't install laravel/framework v6.1.0 - don't install illuminate/http 5.5.x-dev|don't install laravel/framework v6.1.0 - don't install illuminate/http 5.6.x-dev|don't install laravel/framework v6.1.0 - don't install illuminate/http 5.7.17|don't install laravel/framework v6.1.0 - don't install illuminate/http 5.7.18|don't install laravel/framework v6.1.0 ..........解決
composer require tymon/jwt-auth ^1.0.0
参考