Блог на Laravel 7, часть 7. Панель управления — создание, публикация, удаление постов

21.12.2020

Теги: LaravelMySQLPHPWeb-разработкаБазаДанныхБлогПанельУправленияПрактикаФормаФреймворкШаблонСайта

CRUD-операции над постами

Хорошо, публичная часть блога у нас почти готова, теперь поработаем над панелью управления, где администратор сможет создавать, редактировать, публиковать и удалять посты, категории, теги и комментарии. Начнем с постов блога — создадим еще один layout-шаблон для админки, ресурсный контроллер PostController, добавим маршруты и необходимые шаблоны.

1. Шаблон для панели управления

Сперва создадим еще один layout-шаблон для панели управления resource/views/layout/admin.blade.php:

<!doctype html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>{{ $title ?? 'Панель управления' }}</title>
    <link href="{{ asset('css/app.css') }}" rel="stylesheet">
    <link rel="stylesheet" href="https://pro.fontawesome.com/releases/v5.10.0/css/all.css"/>
    <script src="{{ asset('js/app.js') }}"></script>
</head>
<body>
<div class="container">
    <nav class="navbar navbar-expand-lg navbar-dark bg-danger mb-4">
        <!-- Логотип и кнопка «Гамбургер» -->
        <a class="navbar-brand" href="#">Панель управления</a>
        <button class="navbar-toggler" type="button" data-toggle="collapse"
                data-target="#navbar-blog" aria-controls="navbar-blog"
                aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <!-- Основная часть меню (может содержать ссылки, формы и прочее) -->
        <div class="collapse navbar-collapse" id="navbar-blog">
            <!-- Этот блок расположен слева -->
            <ul class="navbar-nav mr-auto">
                <li class="nav-item">
                    <a class="nav-link" href="{{ route('admin.post.index') }}">Посты</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="#">Комментарии</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="#">Категории</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="#">Теги</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="#">Пользователи</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="#">Страницы</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="#">Корзина</a>
                </li>
            </ul>
            <!-- Этот блок расположен справа -->
            <ul class="navbar-nav ml-auto">
                <li class="nav-item">
                    <a class="nav-link" href="{{ route('auth.logout') }}">Выйти</a>
                </li>
            </ul>
        </div>
    </nav>

    <div class="row">
        <div class="col">
            @if ($message = session('success'))
            <div class="alert alert-success alert-dismissible mt-0" role="alert">
                <button type="button" class="close" data-dismiss="alert" aria-label="Закрыть">
                    <span aria-hidden="true">&times;</span>
                </button>
                {{ $message }}
            </div>
            @endif

            @if ($errors->any())
            <div class="alert alert-danger alert-dismissible mt-4" role="alert">
                <button type="button" class="close" data-dismiss="alert" aria-label="Закрыть">
                    <span aria-hidden="true">&times;</span>
                </button>
                <ul class="mb-0">
                    @foreach ($errors->all() as $error)
                    <li>{{ $error }}</li>
                    @endforeach
                </ul>
            </div>
            @endif

            @yield('content')
        </div>
    </div>
</div>
</body>
</html>

2. Добавляем маршруты

Добавим маршртуты в файл routes/web.php:

/*
 * Панель управления: CRUD-операции над постами, категориями, тегами
 */
Route::group([
    'as' => 'admin.', // имя маршрута, например admin.index
    'prefix' => 'admin', // префикс маршрута, например admin/index
    'namespace' => 'Admin', // пространство имен контроллеров
    'middleware' => ['auth'] // один или несколько посредников
], function () {
    /*
     * CRUD-операции над постами блога
     */
    Route::resource('post', 'PostController', ['except' => ['create', 'store']]);
    // доп.маршрут для показа постов категории
    Route::get('post/category/{category}', 'PostController@category')
        ->name('post.category');
    // доп.маршрут, чтобы разрешить публикацию поста
    Route::get('post/enable/{post}', 'PostController@enable')
        ->name('post.enable');
    // доп.маршрут, чтобы запретить публикацию поста
    Route::get('post/disable/{post}', 'PostController@disable')
        ->name('post.disable');
});

Давайте посмотрим, какие маршруты нам теперь доступны:

> php artisan route:list --name=post 
+-----------+--------------------------------+---------------------+----------------------------------------------------+---------------------------------------------------------+
| Method    | URI                            | Name                | Action                                             | Middleware                                              |
+-----------+--------------------------------+---------------------+----------------------------------------------------+---------------------------------------------------------+
| GET|HEAD  | admin/post                     | admin.post.index    | App\Http\Controllers\Admin\PostController@index    | ..........                                              |
|           |                                |                     |                                                    | App\Http\Middleware\Authenticate                        |
|           |                                |                     |                                                    | App\Http\Middleware\CheckUserPermission:manage-posts    |
| GET|HEAD  | admin/post/category/{category} | admin.post.category | App\Http\Controllers\Admin\PostController@category | ..........                                              |
|           |                                |                     |                                                    | App\Http\Middleware\Authenticate                        |
|           |                                |                     |                                                    | App\Http\Middleware\CheckUserPermission:manage-posts    |
| GET|HEAD  | admin/post/disable/{post}      | admin.post.disable  | App\Http\Controllers\Admin\PostController@disable  | ..........                                              |
|           |                                |                     |                                                    | App\Http\Middleware\Authenticate                        |
|           |                                |                     |                                                    | App\Http\Middleware\CheckUserPermission:publish-post    |
| GET|HEAD  | admin/post/enable/{post}       | admin.post.enable   | App\Http\Controllers\Admin\PostController@enable   | ..........                                              |
|           |                                |                     |                                                    | App\Http\Middleware\Authenticate                        |
|           |                                |                     |                                                    | App\Http\Middleware\CheckUserPermission:publish-post    |
| GET|HEAD  | admin/post/{post}              | admin.post.show     | App\Http\Controllers\Admin\PostController@show     | ..........                                              |
|           |                                |                     |                                                    | App\Http\Middleware\Authenticate                        |
|           |                                |                     |                                                    | App\Http\Middleware\CheckUserPermission:manage-posts    |
| PUT|PATCH | admin/post/{post}              | admin.post.update   | App\Http\Controllers\Admin\PostController@update   | ..........                                              |
|           |                                |                     |                                                    | App\Http\Middleware\Authenticate                        |
|           |                                |                     |                                                    | App\Http\Middleware\CheckUserPermission:edit-post       |
| DELETE    | admin/post/{post}              | admin.post.destroy  | App\Http\Controllers\Admin\PostController@destroy  | ..........                                              |
|           |                                |                     |                                                    | App\Http\Middleware\Authenticate                        |
|           |                                |                     |                                                    | App\Http\Middleware\CheckUserPermission:delete-post     |
| GET|HEAD  | admin/post/{post}/edit         | admin.post.edit     | App\Http\Controllers\Admin\PostController@edit     | ..........                                              |
|           |                                |                     |                                                    | App\Http\Middleware\Authenticate                        |
|           |                                |                     |                                                    | App\Http\Middleware\CheckUserPermission:edit-post       |
| GET|HEAD  | blog/post/{post}               | blog.post           | App\Http\Controllers\BlogController@post           | ..........                                              |
+-----------+--------------------------------+---------------------+----------------------------------------------------+---------------------------------------------------------+

3. Создаем контроллер

Создаем ресурсный контроллер PostController:

> php artisan make:controller Admin/PostController --resource --model=Post
namespace App\Http\Controllers\Admin;

use App\Category;
use App\Http\Controllers\Controller;
use App\Post;
use Illuminate\Http\Request;

class PostController extends Controller {

    public function __construct() {
        $this->middleware('perm:manage-posts')->only(['index', 'category', 'show']);
        $this->middleware('perm:edit-post')->only(['edit', 'update']);
        $this->middleware('perm:publish-post')->only(['enable', 'disable']);
        $this->middleware('perm:delete-post')->only('destroy');
    }

    /**
     * Список всех постов блога
     */
    public function index() {
        $roots = Category::where('parent_id', 0)->get();
        $posts = Post::orderBy('created_at', 'desc')->paginate();
        return view('admin.post.index', compact('roots', 'posts'));
    }

    /**
     * Список постов категории блога
     */
    public function category(Category $category) {
        $posts = $category->posts()->paginate();
        return view('admin.post.category', compact('category', 'posts'));
    }

    /**
     * Страница просмотра поста блога
     */
    public function show(Post $post) {
        // сигнализирует о том, что это режим пред.просмотра
        session()->flash('preview', 'yes');
        return view('admin.post.show', compact('post'));
    }

    /**
     * Разрешить публикацию поста блога
     */
    public function enable(Post $post) {
        $post->enable();
        return back()->with('success', 'Пост блога был опубликован');
    }

    /**
     * Запретить публикацию поста блога
     */
    public function disable(Post $post) {
        $post->disable();
        return back()->with('success', 'Пост блога снят с публикации');
    }

    /**
     * Показывает форму редактирования поста
     */
    public function edit(Post $post) {
        // нужно сохранить flash-переменную, которая сигнализирует о том,
        // что кнопка редактирования была нажата в режиме пред.просмотра
        session()->keep('preview');
        return view('admin.post.edit', compact('post' ));
    }

    /**
     * Обновляет пост блога в базе данных
     */
    public function update(Request $request, Post $post) {
        $post->update($request->all());
        $post->tags()->sync($request->tags);
        // кнопка редактирования может быть нажата в режиме пред.просмотра
        // или в панели управления блогом, так что и редирект будет разный
        $route = 'admin.post.index';
        $param = [];
        if (session('preview')) {
            $route = 'admin.post.show';
            $param = ['post' => $post->id];
        }
        return redirect()
            ->route($route, $param)
            ->with('success', 'Пост был успешно обновлен');
    }

    /**
     * Удаляет пост блога из базы данных
     */
    public function destroy(Post $post) {
        $post->delete();
        // пост может быть удален в режиме пред.просмотра или из панели
        // управления, так что и редирект после удаления будет разным
        $route = 'admin.post.index';
        if (session('preview')) {
            $route = 'blog.index';
        }
        return redirect()
            ->route($route)
            ->with('success', 'Пост блога успешно удален');
    }
}

4. Создаем шаблоны

Шаблон resource/views/admin/post/index.blade.php для просмотра списка всех постов блога:

@extends('layout.admin', ['title' => 'Все посты блога'])

@section('content')
    <h1>Все посты блога</h1>
    @if ($roots->count())
        <ul>
        @foreach ($roots as $root)
            <li>
                <a href="{{ route('admin.post.category', ['category' => $root->id]) }}">
                    {{ $root->name }}
                </a>
            </li>
        @endforeach
        </ul>
    @endif
    @if ($posts->count())
        <table class="table table-bordered">
            <tr>
                <th width="10%">Дата</th>
                <th width="40%">Наименование</th>
                <th width="20%">Автор публикации</th>
                <th width="20%">Разрешил публикацию</th>
                <th><i class="fas fa-eye"></i></th>
                <th><i class="fas fa-toggle-on"></i></th>
                <th><i class="fas fa-edit"></i></th>
                <th><i class="fas fa-trash-alt"></i></th>
            </tr>
            @foreach ($posts as $post)
                <tr>
                    <td>{{ $post->created_at }}</td>
                    <td>{{ $post->name }}</td>
                    <td>{{ $post->user->name }}</td>
                    <td>
                        @if ($post->editor)
                            {{ $post->editor->name }}
                        @endif
                    </td>
                    <td>
                        @perm('manage-posts')
                            <a href="{{ route('admin.post.show', ['post' => $post->id]) }}"
                               title="Предварительный просмотр">
                                <i class="far fa-eye"></i>
                            </a>
                        @endperm
                    </td>
                    <td>
                        @perm('publish-post')
                            @if ($post->isVisible())
                                <a href="{{ route('admin.post.disable', ['post' => $post->id]) }}"
                                   title="Запретить публикацию">
                                    <i class="far fa-toggle-on"></i>
                                </a>
                            @else
                                <a href="{{ route('admin.post.enable', ['post' => $post->id]) }}"
                                   title="Разрешить публикацию">
                                    <i class="far fa-toggle-off"></i>
                                </a>
                            @endif
                        @endperm
                    </td>
                    <td>
                        @perm('edit-post')
                            <a href="{{ route('admin.post.edit', ['post' => $post->id]) }}">
                                <i class="far fa-edit"></i>
                            </a>
                        @endperm
                    </td>
                    <td>
                        @perm('delete-post')
                            <form action="{{ route('admin.post.destroy', ['post' => $post->id]) }}"
                                  method="post" onsubmit="return confirm('Удалить этот пост?')">
                                @csrf
                                @method('DELETE')
                                <button type="submit" class="m-0 p-0 border-0 bg-transparent">
                                    <i class="far fa-trash-alt text-danger"></i>
                                </button>
                            </form>
                        @endperm
                    </td>
                </tr>
            @endforeach
        </table>
        {{ $posts->links() }}
    @endif
@endsection

Для удобства навигации в панели управления перед списком всех постов блога добавляем ссылки на корневые категории.

Шаблон resource/views/admin/post/category.blade.php для просмотра списка постов категории:

@extends('layout.admin', ['title' => $category->name])

@section('content')
    <h1>{{ $category->name }}</h1>
    @if ($category->children->count())
        <ul>
            @foreach ($category->children as $child)
                <li>
                    <a href="{{ route('admin.post.category', ['category' => $child->id]) }}">
                        {{ $child->name }}
                    </a>
                </li>
            @endforeach
        </ul>
    @endif
    @if ($posts->count())
        <table class="table table-bordered">
            <tr>
                <th width="10%">Дата</th>
                <th width="40%">Наименование</th>
                <th width="20%">Автор публикации</th>
                <th width="20%">Разрешил публикацию</th>
                <th><i class="fas fa-eye"></i></th>
                <th><i class="fas fa-toggle-on"></i></th>
                <th><i class="fas fa-edit"></i></th>
                <th><i class="fas fa-trash-alt"></i></th>
            </tr>
            @foreach ($posts as $post)
                <tr>
                    <td>{{ $post->created_at }}</td>
                    <td>{{ $post->name }}</td>
                    <td>{{ $post->user->name }}</td>
                    <td>
                        @if ($post->editor)
                            {{ $post->editor->name }}
                        @endif
                    </td>
                    <td>
                        @perm('manage-posts')
                            <a href="{{ route('admin.post.show', ['post' => $post->id]) }}"
                               title="Предварительный просмотр">
                                <i class="far fa-eye"></i>
                            </a>
                        @endperm
                    </td>
                    <td>
                        @perm('publish-post')
                            @if ($post->isVisible())
                                <a href="{{ route('admin.post.disable', ['post' => $post->id]) }}"
                                   title="Запретить публикацию">
                                    <i class="far fa-toggle-on"></i>
                                </a>
                            @else
                                <a href="{{ route('admin.post.enable', ['post' => $post->id]) }}"
                                   title="Разрешить публикацию">
                                    <i class="far fa-toggle-off"></i>
                                </a>
                            @endif
                        @endperm
                    </td>
                    <td>
                        @perm('edit-post')
                            <a href="{{ route('admin.post.edit', ['post' => $post->id]) }}">
                                <i class="far fa-edit"></i>
                            </a>
                        @endperm
                    </td>
                    <td>
                        @perm('delete-post')
                            <form action="{{ route('admin.post.destroy', ['post' => $post->id]) }}"
                                  method="post" onsubmit="return confirm('Удалить этот пост?')">
                                @csrf
                                @method('DELETE')
                                <input type="hidden" name="return" value="back">
                                <button type="submit" class="m-0 p-0 border-0 bg-transparent">
                                    <i class="far fa-trash-alt text-danger"></i>
                                </button>
                            </form>
                        @endperm
                    </td>
                </tr>
            @endforeach
        </table>
        {{ $posts->links() }}
    @else
        <p>Нет постов в этой категории</p>
    @endif
@endsection

Для удобства навигации в панели управления перед списком постов категории добавляем ссылки на дочерние категории.

Шаблон resource/views/admin/post/show.blade.php для предварительного просмотра поста перед публикацией:

@extends('layout.site', ['title' => $post->name, 'admin' => true])

@section('content')
    <div class="card mb-4">
        <div class="card-header">
            <h1>
                @if ( ! $post->isVisible())
                    <i class="far fa-eye-slash text-danger" title="Предварительный просмотр"></i>
                @else
                    <i class="far fa-eye text-success" title="Этот пост опубликован"></i>
                @endif
                {{ $post->name }}
            </h1>
        </div>
        <div class="card-body">
            <img src="http://via.placeholder.com/1000x300" alt="" class="img-fluid">
            @perm('manage-posts')
                {!! $post->content !!}
            @else
                <p>{{ $post->excerpt }}</p>
            @endperm
        </div>
        <div class="card-footer d-flex justify-content-between">
            <span>
                Автор:
                <a href="{{ route('blog.author', ['user' => $post->user->id]) }}">
                    {{ $post->user->name }}
                </a>
                <br>
                Дата: {{ $post->created_at }}
            </span>
            <span>
                @perm('publish-post')
                    @if ($post->isVisible())
                        <a href="{{ route('admin.post.disable', ['post' => $post->id]) }}"
                           class="btn btn-dark" title="Запретить публикацию">
                                <i class="fas fa-toggle-on text-success"></i>
                            </a>
                    @else
                        <a href="{{ route('admin.post.enable', ['post' => $post->id]) }}"
                           class="btn btn-dark" title="Разрешить публикацию">
                                <i class="fas fa-toggle-off text-white"></i>
                            </a>
                    @endif
                @endperm
                @perm('edit-post')
                    <a href="{{ route('admin.post.edit', ['post' => $post->id]) }}"
                       class="btn btn-primary" title="Редактировать пост">
                        <i class="far fa-edit"></i>
                    </a>
                @endperm
                @perm('delete-post')
                    <form action="{{ route('admin.post.destroy', ['post' => $post->id]) }}"
                          method="post" class="d-inline" onsubmit="return confirm('Удалить этот пост?')">
                        @csrf
                        @method('DELETE')
                        <button type="submit" class="btn btn-danger" title="Удалить пост">
                            <i class="far fa-trash-alt"></i>
                        </button>
                    </form>
                @endperm
            </span>
        </div>
        @if ($post->tags->count())
            <div class="card-footer">
                Теги:
                @foreach($post->tags as $tag)
                    @php $comma = $loop->last ? '' : ' • ' @endphp
                    <a href="{{ route('blog.tag', ['tag' => $tag->slug]) }}">
                        {{ $tag->name }}</a>
                    {{ $comma }}
                @endforeach
            </div>
        @endif
    </div>
    @isset($comments)
        @include('admin.post.comments', ['comments' => $comments])
    @endisset
@endsection

Об этом шаблоне надо поговорить подробнее. Во-первых, он расширяет layout-шаблон layout.site публичной части — так что в режиме предварительного просмотра пост будет выглядеть практически так же, как в публичной части. Во-вторых, он подключает шаблон admin.post.comments для показа комментариев к посту. Здесь это не нужно, но мы используем этот шаблон для предвартительного просмотра комментария. В-третьих, мы проверяем, может ли текущий администратор управлять постами — и тогда показываем пост. А если не может — показываем только ананс. Это тоже задел на будущее, для предварительного просмотра комментария — у администратора может быть право управлять комментариями, но может не быть права управлять постами.

С предварительным просмотром довольно много хлопот, потому что требуется всегда помнить, где была нажата кнопка редактирования. Если в режиме пред.просмотра — то после редактирования надо вернуться в пред.просмотр, если в панели управления — надо вернуться в панель управления. В этом нам помогает flash-переменная сессии preview — если она установлена, значит кнопка редактирования нажата в режиме пред.просмотра.

Шаблон resource/views/admin/post/edit.blade.php для редактирования поста блога:

@extends('layout.admin', ['title' => 'Редактирование поста'])

@section('content')
    <h1>Редактирование поста</h1>
    <form method="post" enctype="multipart/form-data"
          action="{{ route('admin.post.update', ['post' => $post->id]) }}">
        @method('PUT')
        @include('admin.post.part.form')
    </form>
@endsection

Шаблон с формой редактирования resource/views/admin/post/part/form.blade.php:

@csrf
<div class="form-group">
    <input type="text" class="form-control" name="name" placeholder="Наименование"
           required maxlength="100" value="{{ old('name') ?? $post->name ?? '' }}">
</div>
<div class="form-group">
    <input type="text" class="form-control" name="slug" placeholder="ЧПУ (на англ.)"
           required maxlength="100" value="{{ old('slug') ?? $post->slug ?? '' }}">
</div>
<div class="form-group">
    @php
        $category_id = old('category_id') ?? $post->category_id ?? 0;
    @endphp
    <select name="category_id" class="form-control" title="Категория">
        <option value="0">Выберите</option>
        @include('admin.part.categories', ['level' => -1, 'parent' => 0])
    </select>
</div>
<div class="form-group">
    <textarea class="form-control" name="excerpt" placeholder="Анонс поста"
              required maxlength="500">{{ old('excerpt') ?? $post->excerpt ?? '' }}</textarea>
</div>
<div class="form-group">
    <textarea class="form-control" name="content" placeholder="Текст поста"
              required rows="4">{{ old('content') ?? $post->content ?? '' }}</textarea>
</div>
<div class="form-group">
    <input type="file" class="form-control-file" name="image" accept="image/png, image/jpeg">
</div>
@isset($post->image)
    <div class="form-group form-check">
        <input type="checkbox" class="form-check-input" name="remove" id="remove">
        <label class="form-check-label" for="remove">
            Удалить загруженное изображение
        </label>
    </div>
@endisset
<div class="form-group">
    <button type="submit" class="btn btn-primary">Сохранить</button>
</div>

Шаблон с формой редактирования поста подключает еще один шаблон resource/views/admin/part/parent.blade.php:

@if ($items->where('parent_id', $parent)->count())
    @php $level++ @endphp
    @foreach ($items->where('parent_id', $parent) as $item)
        <option value="{{ $item->id }}" @if ($item->id == $category_id) selected @endif>
            @if ($level) {!! str_repeat('&nbsp;&nbsp;&nbsp;', $level) !!}  @endif {{ $item->name }}
        </option>
        @include('admin.part.categories', ['level' => $level, 'parent' => $item->id])
    @endforeach
@endif

Этот шаблон подключается рекурсивно и создает выпадающий список всех категорий блога — чтобы можно было выбрать родительскую категорию. В этот шаблон надо передать переменную $items — которая представляет из себя коллекцию всех категорий блога. У нас уже есть готовый механизм — View Composer, который сейчас передает данные в два шаблона для построения двух меню в левой колонке в публичной части. Давайте немного его доработаем.

class ComposerServiceProvider extends ServiceProvider {
    /**
     * Register services.
     *
     * @return void
     */
    public function register() {
        View::composer(['layout.part.categories', 'admin.part.categories'], function($view) {
            static $items = null;
            if (is_null($items)) {
                $items = Category::all();
            }
            $view->with(['items' => $items]);
        });
        View::composer('layout.part.popular-tags', function($view) {
            $view->with(['items' => Tag::popular()]);
        });
    }
    /* ... */
}

5. Доработка модели

В шаблонах мы используем виртуальное свойство editor модели Post. Пока пост не опубликован — это свойство имеет значение null, после публикации поста это свойство содержит объект модели User — это пользователь (админ), который разрешил публикацию. Кроме метода editor() добавляем в модель свойство $fillable — поскольку в контроллере PostController используем «mass assignment».

namespace App;

use Illuminate\Database\Eloquent\Model;

class Post extends Model {

    protected $fillable = [
        'user_id',
        'category_id',
        'name',
        'slug',
        'excerpt',
        'content',
        'image',
    ];

    /**
     * Количество постов на странице при пагинации
     */
    protected $perPage = 5;

    /**
     * Связь модели Post с моделью Tag, позволяет получить
     * все теги поста
     */
    public function tags() {
        return $this->belongsToMany(Tag::class)->withTimestamps();
    }

    /**
     * Связь модели Post с моделью Category, позволяет получить
     * родительскую категорию поста
     */
    public function category() {
        return $this->belongsTo(Category::class);
    }

    /**
     * Связь модели Post с моделью User, позволяет получить
     * автора поста
     */
    public function user() {
        return $this->belongsTo(User::class);
    }

    /**
     * Связь модели Post с моделью User, позволяет получить
     * администратора, который разрешил публикацию поста
     */
    public function editor() {
        return $this->belongsTo(User::class, 'published_by');
    }

    /**
     * Связь модели Post с моделью Comment, позволяет получить
     * все комментарии к посту
     */
    public function comments() {
        return $this->hasMany(Comment::class);
    }

    /**
     * Разрешить публикацию поста блога
     */
    public function enable() {
        $this->published_by = auth()->user()->id;
        $this->update();
    }

    /**
     * Запретить публикацию поста блога
     */
    public function disable() {
        $this->published_by = null;
        $this->update();
    }

    /**
     * Возвращает true, если публикация разрешена
     */
    public function isVisible() {
        return ! is_null($this->published_by);
    }

    /**
     * Выбирать из БД только опубликованные посты
     */
    public function scopePublished($builder) {
        return $builder->whereNotNull('published_by');
    }
}
Для того, чтобы разрешить или запретить публикацию поста надо было использовать форму и метод PUT или PATCH — потому что мы изменяем состояние базы данных. А метод GET, который был использован вместо них, должен только получать данные из БД, но ничего не изменять. Это неправильно, но лень было делать еще одну форму.

6. Валидация данных

Создаем отдельный класс для валидации данных формы при создании-редактировании поста блога:

> php artisan make:request PostRequest
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class PostRequest extends FormRequest {
    /**
     * Определяет, есть ли права у пользователя на этот запрос
     *
     * @return bool
     */
    public function authorize() {
        return true;
    }

    /**
     * Возвращает массив правил для проверки полей формы
     *
     * @return array
     */
    public function rules() {
        return [
            'name' => [
                'required',
                'string',
                'min:3',
                'max:100',
            ],
            'slug' => [
                'required',
                'string',
                'max:100',
                'unique:posts,slug',
                'regex:~^[-_a-z0-9]+$~i',
            ],
            'category_id' => [
                'required',
                'numeric',
                'min:1'
            ],
            'excerpt' => [
                'required',
                'min:100',
                'max:500',
            ],
            'content' => [
                'required',
                'min:500',
            ],
            'image' => [
                'mimes:jpeg,jpg,png',
                'max:5000'
            ],
        ];
    }

    /**
     * Возвращает массив сообщений об ошибках для заданных правил
     *
     * @return array
     */
    public function messages() {
        return [
            'required' => 'Поле «:attribute» обязательно для заполнения',
            'unique' => 'Такое значение поля «:attribute» уже используется',
            'min' => [
                'string' => 'Поле «:attribute» должно быть не меньше :min символов',
                'numeric' => 'Нужно выбрать категорию нового поста блога',
                'file' => 'Файл «:attribute» должен быть не меньше :min Кбайт'
            ],
            'max' => [
                'string' => 'Поле «:attribute» должно быть не больше :max символов',
                'file' => 'Файл «:attribute» должен быть не больше :max Кбайт'
            ],
            'mimes' => 'Файл «:attribute» должен иметь формат :values',
        ];
    }

    /**
     * Возвращает массив дружественных пользователю названий полей
     *
     * @return array
     */
    public function attributes() {
        return [
            'name' => 'Наименование',
            'slug' => 'ЧПУ (англ.)',
            'category_id' => 'Категория',
            'excerpt' => 'Анонс поста',
            'content' => 'Текст поста',
            'image' => 'Изображение',
        ];
    }
}
class PostController extends Controller {
    /**
     * Сохраняет новый пост в базу данных
     */
    public function store(PostRequest $request) {
        /* ... */
    }
    /**
     * Обновляет пост блога в базе данных
     */
    public function update(PostRequest $request, Post $post) {
        /* ... */
    }
}

Но есть проблема со slug — правило валидации предписывает уникальность — и это правильно при создании поста. Но при обновлении поста проверка на уникальность должна исключать slug этого поста блога.

class PostRequest extends FormRequest {
    /* ... */
    public function rules() {
        $unique = 'unique:posts,slug';
        if ('admin.post.update' == $this->route()->getName()) {
            // получаем модель Post через маршрут admin/post/{post}
            $model = $this->route('post');
            /*
             * Проверка на уникальность slug, исключая этот пост по идентифкатору:
             * 1. posts — таблица базы данных, где проверяется уникальность
             * 2. slug — имя колонки, уникальность значения которой проверяется
             * 3. значение, по которому из проверки исключается запись таблицы БД
             * 4. поле, по которому из проверки исключается запись таблицы БД
             * Для проверки будет использован такой SQL-запрос к базе данных:
             * SELECT COUNT(*) FROM `posts` WHERE `slug` = '...' AND `id` <> 17
             */
            $unique = 'unique:posts,slug,'.$model->id.',id';
        }
        return [
            /* ... */
            'slug' => [
                'required',
                'max:100',
                $unique,
                'regex:~^[-_a-z0-9]+$~i',
            ],
            /* ... */
        ];
    }
    /* ... */
}

7. Теги для поста

Надо доработать форму создания-редактирования поста, чтобы была возможность указать теги, связанные с постом. Для этого создадим еще один шаблон resources/views/admin/part/all-tags.blade.php.

@if ($items->count())
    @php
        /*
         * Тут возможны такие варианты:
         * 1. Форма создания нового поста еще не была отправлена, привязок еще нет
         * 2. Форма редактирования еще не была отправлена, привязанные теги берем
         *    из связи модели Post с моделью Tag через сводную таблицу post_tag
         * 3. Форма создания или редактирования уже была отправлена, но были ошибки
         *    при заполнении, поэтому отмеченные админом checkbox-ы берем из old()
         */
        $tags = []; // это идентификаторы тегов, привязанных к посту
        if (isset($post)) $tags = $post->tags->keyBy('id')->keys()->toArray();
        if (old('tags')) $tags = old('tags');
    @endphp
    <div class="form-group d-flex flex-wrap">
    @foreach ($items as $item)
        @php $checked = in_array($item->id, $tags) @endphp
        <div class="form-check-inline w-25 mr-0">
            <input class="form-check-input" type="checkbox" name="tags[]" id="tag-id-{{ $item->id }}"
                   value="{{ $item->id }}" @if($checked) checked @endif>
            <label class="form-check-label" for="tag-id-{{ $item->id }}">
                {{ $item->name }}
            </label>
        </div>
    @endforeach
    </div>
@endif

И подключим его внутри формы создания-редактирования поста:

@csrf
<!-- Все прочие поля формы -->

@include('admin.part.all-tags')

<!-- Кнопка отправки формы -->

Коллекцию всех тегов блога передадим в шаблон admin.part.all-tags с помощью View Composer:

class ComposerServiceProvider extends ServiceProvider {
    /**
     * Register services.
     *
     * @return void
     */
    public function register() {
        View::composer(['layout.part.categories', 'admin.part.categories'], function($view) {
            static $items = null;
            if (is_null($items)) {
                $items = Category::all();
            }
            $view->with(['items' => $items]);
        });
        View::composer('layout.part.popular-tags', function($view) {
            $view->with(['items' => Tag::popular()]);
        });
        View::composer('admin.part.all-tags', function($view) {
            $view->with(['items' => Tag::all()]);
        });
    }
    /* ... */
}

Почти все готово, осталось только привязать теги к посту при обновлении:

class PostController extends Controller {
    /**
     * Обновляет пост блога в базе данных
     */
    public function update(PostRequest $request, Post $post) {
        $post->update($request->all());
        $post->tags()->sync($request->tags);
        // кнопка редактирования может быть нажата в режиме пред.просмотра
        // или в панели управления блогом, так что и редирект будет разный
        $route = 'admin.post.index';
        $param = [];
        if (session('preview')) {
            $route = 'admin.post.show';
            $param = ['post' => $post->id];
        }
        return redirect()
            ->route($route, $param)
            ->with('success', 'Пост был успешно обновлен');
    }
}

8. Переменная preview

Когда все было готово, когда родная планета приняла сравнительно благоустроенный вид, неожиданно появилась проблема с переменной сессии preview. Эта переменная должна сигнализировать, что кнопка редактирования или удаления поста была нажата в режиме предварительного просмотра. И в этом случае после редактирования или удаления поста должен быть редирект обратно на страницу предварительного просмотра.

Это flash-переменная, то есть сохраняется только до момента следующего http-запроса. В методе edit() контроллера PostController она сохраняется еще раз, потому что первый запрос — переход на страницу редактирования, а сохранение отредактированного поста — это уже второй запрос. Проблема в том, что если при редактировании поста были допущены ошибки, то переменная теряется. После отправки формы, если были ошибки, выполянется редирект — это уже второй запрос. Значит, нам надо вмешаться в процесс формирования ответа при ошибке валидации.

Переопределяем метод invalid() класса App\Exceptions\Handler:

class Handler extends ExceptionHandler {
    /**
     * Convert a validation exception into a response.
     *
     * @param \Illuminate\Http\Request $request
     * @param \Illuminate\Validation\ValidationException $exception
     * @return \Illuminate\Http\Response
     */
    protected function invalid($request, $exception) {
        $redirect = parent::invalid($request, $exception);
        if (session('preview')) {
            return $redirect->with('preview', 'yes');
        }
        return $redirect;
    }
    /* ... */
}

Столкнулся еще с ошибкой «Undefined index: numeric» при использовании правила валидации integer для category_id:

class PostRequest extends FormRequest {
    /* ... */
    public function rules() {
        /* ... */
         return [
            /* ... */
            'category_id' => [
                'required',
                'integer',
                'min:1'
            ],
            /* ... */
        ];
    }
    /* ... */
    public function messages() {
        return [
            /* ... */
            'min' => [
                'string' => 'Поле «:attribute» должно быть не меньше :min символов',
                'integer' => 'Поле «:attribute» должно быть :min или больше',
                'file' => 'Файл «:attribute» должен быть не меньше :min Кбайт'
            ],
            /* ... */
        ];
    }
    /* ... */
}

Где-то в недрах Laravel происходит обращение к элементу массива min.numeric вместо min.integer (этот массив определен в методе messages()). Видимо, integer не является самостоятельным правилом, а всего лишь синоним numeric. Так что использовать integer надо осторожно, либо не использовать вовсе.

Поиск: Laravel • MySQL • PHP • Web-разработка • База данных • Блог • Панель управления • Практика • Форма • Фреймворк • Шаблон сайта

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.