0.背景
本記事は、記事「実務未経験状態でLaravelを使用した共同開発」で取り組んだタスクのうち、管理者画面作成についてまとめたもの
1.仕様内容
・管理者専用画面
・usersテーブルにis_adminカラムを追加、tureであればログイン及び操作可能
・管理者ログイン画面は直性URLを入力(一般ユーザが使用する画面からの遷移×)
・①ダッシュボード②ユーザ情報画面③投稿編集画面④リプライ変更画面 の計4画面作成
・ダッシュボード:ユーザ情報画面、投稿編集画面、リプライ変更画面へ遷移
・ユーザ情報編集:権限変更、削除、検索
・投稿編集:削除、検索
・リプライ編集:削除、検索
UI(デプロイしたもの)
・ログイン画面
・ダッシュボード
・ユーザ編集
・投稿編集
・リプライ編集
2.タスクの見積もり
期間
・10日
理由
・データベース修正に伴うデグレ発生等エラー修正期間を想定
・ミドルウェア編集によるエラー修正期間を想定
・Controller修正に伴う上記同様の修正期間を想定
・ログイン処理の設定に関する処理に時間を要することを想定
・その他想定外のエラーを想定
・PRレビュー、修正期間を想定
実績
・仕様作成完了 4日
・PRまで 4日
・PR修正完了 5日
・マージ完了 5日
3.実装
⓪Middleware
・AdminMiddleware を作成
・権限者以外のリクエストで処理中断
public function handle($request, Closure $next)
{
if (auth()->check() && auth()->user()->is_admin) {
return $next($request);
}
abort(403, '管理者以外はアクセスできません');
}
・Kernel.php に追記
・【 protected $routeMiddleware 】下に記載
'admin' => \App\Http\Middleware\AdminMiddleware::class,
①Rouring
・Controller内のディレクトリ構成の編集
Controller
|-admin-【login, users, posts, replies】
|-user -【users, posts, replies, raning, likes, follows】
・admin処理の追加に伴い上記のようにディレクトリ構成変更
・web.php内に記載
・上記に伴いroutingのコントローラ前にadminかusrを記載
admin処理
・【Route::group(['middleware' => 'auth'], function () {】下に記載
Route::get('/admin/login', 'Admin\LoginController@showLoginForm')->name('admin.show.login'); // 管理者ログイン画面表示
Route::post('/admin/login', 'Admin\LoginController@login')->name('admin.login.post'); // 管理者ログイン処理
・【Route::group(['middleware' => 'admin'], function(){】下に記載
Route::prefix('/admin')->group(function(){
Route::get('/', 'Admin\LoginController@showDashboard')->name('admin.show.dashboard'); // 管理者画面表示
Route::get('/users', 'Admin\UsersController@showUsers')->name('admin.show.users'); // ユーザ編集画面表示 + 検索機能
Route::get('/posts', 'Admin\PostsController@showPosts')->name('admin.show.posts'); // 投稿編集画面表示 + 検索機能
Route::get('/replies', 'Admin\RepliesController@showReply')->name('admin.show.replies'); // リプライ編集画面 + 検索機能
Route::post('/users/{id}/toggle', 'Admin\UsersController@updateUser')->name('admin.users.update'); // ユーザ権限変更
Route::delete('/users/{id}', 'Admin\UsersController@destroyUser')->name('admin.users.destroy'); // ユーザ削除
Route::delete('/posts/{id}', 'Admin\PostsController@destroyPost')->name('admin.posts.destroy'); // 投稿削除
Route::delete('/replies/{id}', 'Admin\RepliesController@destroyReply')->name('admin.replies.destroy'); // リプライ削除
});
user処理
・既存のコードにuser\を追加記載
例:
Route::get('{user}/edit', 'User\UsersController@edit')->name('users.edit'); // ユーザ編集
②Migration
・create_users_table.php に追記
・管理者権限のカラム
$table->boolean('is_admin')->default(false);
③Seeder
・UsersTableSeeder.php に追加記載
・管理者権限を持つユーザデータを追加
$users[] = [
'name' => 'admin',
'email' => 'admin@sample.com',
'password' => Hash::make('password'),
'is_admin' => true,
];
④Controller
1.LoginController.php
・ログイン画面表示
・ダッシュボード画面表示
→ユーザ、投稿、リプライのデータを取得しているがviewで仕様しておらず
・ログイン処理
// 管理者ログイン画面
public function showLoginForm()
{
return view('admin.login');
}
// 管理者画面
public function showDashboard()
{
$users = User::orderBy('created_at', 'desc')->paginate(10);
$posts = Post::orderBy('created_at', 'desc')->paginate(10);
$replies = Reply::orderBy('created_at', 'desc')->paginate(10);
return view('admin.dashboard', compact('users', 'posts', 'replies'));
}
// 管理者ログイン処理
public function login(AdminLoginRequest $request)
{
$authority = $request->only('email', 'password');
if (Auth::attempt($authority)) {
$user = Auth::user();
if ($user->is_admin) {
return redirect()->route('admin.show.dashboard');
}
Auth::logout();
abort(403, '認証情報が正しくありません');
}
}
2.UsersController.php
・ユーザ編集画面表示 + 検索機能
・管理者権限切替
・ユーザ削除
//ユーザ編集画面表示 + 検索機能
public function showUsers(Request $request)
{
$query = User::query();
if ($request->filled('name')) {
$query->where('name', 'like', '%' . $request->input('name') . '%');
}
if ($request->filled('is_admin')) {
$query->where('is_admin', $request->input('is_admin'));
}
$users = $query->orderBy('created_at', 'desc')->paginate(10);
return view('admin.users', compact('users'));
}
// 管理者権限切替
public function updateUser($id)
{
$user = User::findOrFail($id);
$user->is_admin = !$user->is_admin;
$user->save();
return back();
}
// ユーザ削除
public function destroyUser($id)
{
$user = User::findOrFail($id);
$posts = $user->posts;
foreach ($posts as $post) {
$post->replies()->delete();
$post->delete();
}
$user->replies()->delete();
$user->delete();
return back();
}
3.PostsController.php
・投稿編集画面表示 + 検索機能
・投稿削除
//投稿編集画面表示 + 検索機能
public function showPosts(Request $request)
{
$keyword = $request->input('keyword');
$query = Post::with('user');
if (!empty($keyword)) {
$query->where(function ($q) use ($keyword) {
$q->where('content', 'like', '%' . $keyword . '%')
->orWhereHas('user', function ($q2) use ($keyword) {
$q2->where('name', 'like', '%' . $keyword . '%');
});
});
}
$posts = $query->orderBy('created_at', 'desc')->paginate(10);
$users = User::all();
return view('admin.posts', compact('posts', 'users', 'keyword'));
}
//投稿削除
public function destroyPost($id)
{
$post = Post::with('replies')->findOrFail($id);
$post->replies()->delete();
$post->delete();
return back();
}
4.RepliesController.php
・リプライ編集画面表示 + 検索機能
・リプライ削除
//リプライ編集画面表示 + 検索機能
public function showReply(Request $request)
{
$keyword = $request->input('keyword');
$query = Reply::with(['user', 'post']);
if (!empty($keyword)) {
$query->where(function ($q) use ($keyword) {
$q->where('content', 'like', '%' . $keyword . '%')
->orWhereHas('user', function ($q2) use ($keyword) {
$q2->where('name', 'like', '%' . $keyword . '%');
});
});
}
$replies = $query->orderBy('created_at', 'desc')->paginate(10);
$posts = Post::with('user')->get();
return view('admin.replies', compact('replies', 'posts', 'keyword'));
}
// リプライ削除
public function destroyReply($id)
{
Reply::destroy($id);
return back();
}
⑤Model
・新規作成、編集等無
⑥View
1.dashboard.blade.php
・各編集ページへの遷移リンクボタンを実装
@extends('layouts.app')
@section('content')
<div class="center">
<img class="w-50 mb-5 mx-auto d-block" src="{{ asset('images/admin_top.png') }}" alt="トップ画像">
</div>
<div class="d-flex justify-content-center">
<a href="{{ route('admin.show.users') }}" class="btn btn-secondary">ユーザ編集</a>
<a href="{{ route('admin.show.posts') }}" class="btn btn-secondary mx-3">投稿編集</a>
<a href="{{ route('admin.show.replies') }}" class="btn btn-secondary">リプライ編集</a>
</div>
@endsection
2.login.blade.php
@extends('layouts.app')
@section('content')
<div class="text-center">
<img class="w-50 mb-3 mx-auto d-block" src="{{ asset('images/admin_top.png') }}" alt="トップ画像">
</div>
<div class="text-center mt-3">
<h2 class="text-left d-inline-block">管理者ログインページです。</h2>
</div>
<div class="text-center">
<h3 class="login_title text-left d-inline-block mt-5">ログイン</h3>
</div>
<div class="row mt-5 mb-5">
<div class="col-sm-6 offset-sm-3">
<form method="POST" action="{{ route('admin.login.post') }}">
@csrf
<div class="form-group">
<label for="email">メールアドレス</label>
<input id="email" type="text" class="form-control" name="email" value="{{ old('email') }}">
@error('email')
<p class="text-danger">{{ $message }}</p>
@enderror
</div>
<div class="form-group">
<label for="password">パスワード</label>
<input id="password" type="password" class="form-control" name="password">
@error('password')
<p class="text-danger">{{ $message }}</p>
@enderror
</div>
<button type="submit" class="btn btn-primary mt-2">ログイン</button>
</form>
</div>
</div>
@endsection
3.Users.blade.php
@extends('layouts.app')
@section('content')
<div class="center">
<img class="w-50 mb-5 mx-auto d-block" src="{{ asset('images/admin_top.png') }}" alt="トップ画像">
</div>
<form method="GET" action="{{ route('admin.show.users') }}" class="mb-4 d-flex gap-2 flex-wrap align-items-end">
<div>
<input type="text" name="name" id="name" value="{{ request('name') }}" class="form-control" placeholder="名前を入力">
</div>
<div>
<label for="is_admin" class="form-label ml-3">権限</label>
<select name="is_admin" id="is_admin" class="form-select">
<option value="">all</option>
<option value="1" {{ request('is_admin') === '1' ? 'selected' : '' }}>admin</option>
<option value="0" {{ request('is_admin') === '0' ? 'selected' : '' }}>general</option>
</select>
</div>
<div>
<button type="submit" class="btn btn-primary mx-1">検索</button>
<a href="{{ route('admin.show.users') }}" class="btn btn-secondary">リセット</a>
</div>
</form>
<ul class="list-group">
@foreach($users as $user)
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap">
<div class="me-auto">
<strong>{{ $user->name }}</strong>
<span class="badge bg-{{ $user->is_admin ? 'danger' : 'secondary' }} ms-2 text-white">
{{ $user->is_admin ? 'admin' : 'general' }}
</span>
</div>
<div class="d-flex">
<form method="POST" action="{{ route('admin.users.update', $user->id) }}">
@csrf
@method('POST')
<button type="submit" class="btn btn-outline-success btn-sm">Change</button>
</form>
<form method="POST" action="{{ route('admin.users.destroy', $user->id) }}" class="mx-2">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-outline-danger btn-sm">Delete</button>
</form>
</div>
</li>
@endforeach
</ul>
<div class="m-auto" style="width: fit-content">
{{ $users->appends(request()->query())->links() }}
</div>
@endsection
4.Posts.blade.php
@extends('layouts.app')
@section('content')
<div class="center">
<img class="w-50 mb-5 mx-auto d-block" src="{{ asset('images/admin_top.png') }}" alt="トップ画像">
</div>
<form method="GET" action="{{ route('admin.show.posts') }}" class="mb-4 d-flex gap-2 flex-wrap align-items-end">
<div>
<label for="keyword" class="form-label">検索(ユーザー名・投稿内容)</label>
<input type="text" name="keyword" id="keyword" value="{{ request('keyword') }}" class="form-control" placeholder="キーワードを入力">
</div>
<div>
<button type="submit" class="btn btn-primary mx-2">検索</button>
<a href="{{ route('admin.show.posts') }}" class="btn btn-secondary">リセット</a>
</div>
</form>
<div class="list-group">
@foreach($users as $user)
@php
$userPosts = $posts->where('user_id', $user->id);
@endphp
@if ($userPosts->isNotEmpty())
<div class="mb-5 border rounded p-4 bg-light shadow-sm">
<h5 class="mb-3 fw-bold">{{ $user->name }}</h5>
@foreach($userPosts as $post)
<div class="mb-4 p-3 border rounded bg-white d-flex flex-column flex-md-row align-items-start gap-3 shadow-sm">
<div class="flex-shrink-0">
@if ($post->image_path)
<img src="{{ asset('storage/' . $post->image_path) }}"
class="img-thumbnail clickable-image"
style="width: 150px; height: auto; cursor: pointer;"
alt="投稿画像"
data-image="{{ asset('storage/' . $post->image_path) }}">
@else
<img src="{{ asset('images/top.png') }}"
class="img-thumbnail clickable-image"
style="width: 150px; height: auto; cursor: pointer;"
alt="デフォルト画像"
data-image="{{ asset('images/top.png') }}">
@endif
</div>
<div class="flex-grow-1 mx-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">{{ $post->created_at->format('Y-m-d H:i') }}</small>
</div>
<p class="mb-2">{{ $post->content }}</p>
<form method="POST" action="{{ route('admin.posts.destroy', $post->id) }}">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</div>
</div>
@endforeach
</div>
@endif
@endforeach
<div class="m-auto" style="width: fit-content">
{{ $posts->appends(request()->query())->links() }}
</div>
</div>
@endsection
5.Replies.blade.php
@extends('layouts.app')
@section('content')
<div class="center">
<img class="w-50 mb-5 mx-auto d-block" src="{{ asset('images/admin_top.png') }}" alt="トップ画像">
</div>
<form method="GET" action="{{ route('admin.show.replies') }}" class="mb-4 d-flex gap-2 flex-wrap align-items-end">
<div>
<label for="keyword" class="form-label">検索(ユーザー名・リプライ内容)</label>
<input type="text" name="keyword" id="keyword" value="{{ request('keyword') }}" class="form-control" placeholder="キーワードを入力">
</div>
<div>
<button type="submit" class="btn btn-primary mx-2">検索</button>
<a href="{{ route('admin.show.replies') }}" class="btn btn-secondary">リセット</a>
</div>
</form>
<div class="list-group">
@foreach($posts as $post)
@php
$postReplies = $replies->where('post_id', $post->id);
@endphp
@if ($postReplies->isNotEmpty())
<div class="mb-5 border rounded p-4 bg-light shadow-sm">
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<small class="text-muted">{{ $post->created_at->format('Y-m-d H:i') }}</small>
<strong class="mx-2">{{ optional($post->user)->name }}</strong>
</div>
</div>
<div class="d-flex gap-4 align-items-start mb-3">
<div class="flex-shrink-0">
@if ($post->image_path)
<img src="{{ asset('storage/' . $post->image_path) }}"
class="img-thumbnail clickable-image"
style="width: 150px; height: auto; cursor: pointer;"
alt="投稿画像"
data-image="{{ asset('storage/' . $post->image_path) }}">
@else
<img src="{{ asset('images/top.png') }}"
class="img-thumbnail clickable-image"
style="width: 150px; height: auto; cursor: pointer;"
alt="デフォルト画像"
data-image="{{ asset('images/top.png') }}">
@endif
</div>
<div class="flex-grow-1 mx-2">
<h5 class="mb-0">{{ $post->content }}</h5>
</div>
</div>
@foreach ($postReplies as $reply)
<div class="mb-3 p-3 border rounded bg-white shadow-sm">
<div class="d-flex justify-content-between align-items-center mb-1">
<div>
<small class="text-muted">{{ $reply->created_at->format('Y-m-d H:i') }}</small>
<span class="fw-bold mx-2">{{ optional($reply->user)->name }}</span>
</div>
</div>
<p class="mb-2">{{ $reply->content }}</p>
<form method="POST" action="{{ route('admin.replies.destroy', $reply->id) }}">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</div>
@endforeach
</div>
@endif
@endforeach
</div>
<div class="m-auto mt-4" style="width: fit-content">
{{ $replies->appends(['keyword' => request()->query('keyword', '')])->links() }}
</div>
@endsection
4.テスト実施
5.PRレビュー
・ディレクトリ構成(Controller)
Controller
|-postControllr
|-adminPostController
その他
で当初作成していたが、【admin】【user】フォルダに分岐するべきとレビューをいただき修正
6.本実装に関して
・ミドルウェア、権限処理等の実装が困難であった。複雑な処理だったが細かく処理の流れを確認することでデグレ等を起こすことなく実装することが出来た。