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

25.12.2020

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

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

CRUD-операции над категориями

Новые маршруты:

/*
 * Панель управления: CRUD-операции над постами, категориями, тегами
 */
Route::group([
    'as' => 'admin.', // имя маршрута, например admin.index
    'prefix' => 'admin', // префикс маршрута, например admin/index
    'namespace' => 'Admin', // пространство имен контроллеров
    'middleware' => ['auth'] // один или несколько посредников
], function () {

    /* ... */

    /*
     * CRUD-операции над категориями блога
     */
    Route::resource('category', 'CategoryController', ['except' => 'show']);
});

Контроллер CategoryController:

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

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

class CategoryController extends Controller {

    public function __construct() {
        $this->middleware('perm:manage-categories')->only('index');
        $this->middleware('perm:create-category')->only(['create', 'store']);
        $this->middleware('perm:edit-category')->only(['edit', 'update']);
        $this->middleware('perm:delete-category')->only('destroy');
    }

    /**
     * Показывает список всех категорий
     */
    public function index() {
        $items = Category::all();
        return view('admin.category.index', compact('items'));
    }

    /**
     * Показывает форму для создания категории
     */
    public function create() {
        return view('admin.category.create');
    }

    /**
     * Сохраняет новую категорию в базу данных
     */
    public function store(Request $request) {
        Category::create($request->all());
        return redirect()
            ->route('admin.category.index')
            ->with('success', 'Новая категория успешно создана');
    }

    /**
     * Показывает форму для редактирования категории
     */
    public function edit(Category $category) {
        return view('admin.category.edit', compact('category'));
    }

    /**
     * Обновляет категорию блога в базе данных
     */
    public function update(Request $request, Category $category) {
        $category->update($request->all());
        return redirect()
            ->route('admin.category.index')
            ->with('success', 'Категория была успешно исправлена');
    }

    /**
     * Удаляет категорию блога
     */
    public function destroy(Category $category) {
        if ($category->children->count()) {
            $errors[] = 'Нельзя удалить категорию с дочерними категориями';
        }
        if ($category->posts->count()) {
            $errors[] = 'Нельзя удалить категорию, которая содержит посты';
        }
        if (!empty($errors)) {
            return back()->withErrors($errors);
        }
        $category->delete();
        return redirect()
            ->route('admin.category.index')
            ->with('success', 'Категория блога успешно удалена');
    }
}

Поскольку в контроллере мы используем «mass assignment» — добавляем свойство $fillable в модель Category:

class Category extends Model {

    protected $fillable = [
        'parent_id',
        'name',
        'slug',
        'content',
        'image',
    ];
    /* ... */
}

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

@extends('layout.admin', ['title' => 'Все категории'])

@section('content')
    <h1>Все категории</h1>
    @perm('create-category')
        <a href="{{ route('admin.category.create') }}" class="btn btn-success mb-4">
            Создать категорию
        </a>
    @endperm
    <table class="table table-bordered">
        <tr>
            <th width="45%">Наименование</th>
            <th width="45%">ЧПУ (англ.)</th>
            <th><i class="fas fa-edit"></i></th>
            <th><i class="fas fa-trash-alt"></i></th>
        </tr>
        @include('admin.part.all-ctgs', ['level' => -1, 'parent' => 0])
    </table>
@endsection

Для показа дерева категорий рекурсивно подключается шаблон resource/views/admin/part/all-ctgs.blade.php:

@if ($items->where('parent_id', $parent)->count())
    @php $level++ @endphp
    @foreach ($items->where('parent_id', $parent) as $item)
        <tr>
            <td>
                @if ($level)
                    {{ str_repeat('—', $level) }}
                @endif
                @if($level)
                    <span>{{ $item->name }}</span>
                @else
                    <strong>{{ $item->name }}</strong>
                @endif
            </td>
            <td>{{ $item->slug }}</td>
            <td>
                @perm('edit-category')
                    <a href="{{ route('admin.category.edit', ['category' => $item->id]) }}">
                        <i class="far fa-edit"></i>
                    </a>
                @endperm
            </td>
            <td>
                @perm('delete-category')
                    <form action="{{ route('admin.category.destroy', ['category' => $item->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>
        @include('admin.part.all-ctgs', ['level' => $level, 'parent' => $item->id])
    @endforeach
@endif

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

@extends('layout.admin', ['title' => 'Создание категории'])

@section('content')
    <h1>Создание категории</h1>
    <form method="post" action="{{ route('admin.category.store') }}"
          enctype="multipart/form-data">
        @csrf
        @include('admin.category.part.form')
    </form>
@endsection

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

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

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

Форма создания-редактирования категории, шаблон resource/views/admin/category/part/form.blade.php:

<div class="form-group">
    <input type="text" class="form-control" name="name" placeholder="Наименование"
           required maxlength="100" value="{{ old('name') ?? $category->name ?? '' }}">
</div>
<div class="form-group">
    <input type="text" class="form-control" name="slug" placeholder="ЧПУ (на англ.)"
           required maxlength="100" value="{{ old('slug') ?? $category->slug ?? '' }}">
</div>
<div class="form-group">
    @php
        $parent_id = old('parent_id') ?? $category->parent_id ?? 0;
    @endphp
    <select name="parent_id" class="form-control" title="Родитель">
        <option value="0">Без родителя</option>
        @include('admin.part.parents', ['level' => -1, 'parent' => 0])
    </select>
</div>
<div class="form-group">
    <textarea class="form-control" name="content" placeholder="Краткое описание" maxlength="500"
              rows="5">{{ old('content') ?? $category->content ?? '' }}</textarea>
</div>
<div class="form-group">
    <input type="file" class="form-control-file" name="image" accept="image/png, image/jpeg">
</div>
@isset($category->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/parents.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 == $parent_id) selected @endif>
            @if ($level) {!! str_repeat('&nbsp;&nbsp;&nbsp;', $level) !!}  @endif
            {{ $item->name }}
        </option>
        @include('admin.part.parents', ['level' => $level, 'parent' => $item->id])
    @endforeach
@endif

Еще одна задача — передать коллекцию всех категорий блога в шаблоны admin.part.all-ctgs и admin.part.parents:

class ComposerServiceProvider extends ServiceProvider {

    public function register() {
        $views = [
            'layout.part.categories', // меню в левой колонке в публичной части
            'admin.part.categories', // выбор категории поста при редактировании
            'admin.part.parents', // выбор родителя категории при редактировании
            'admin.part.all-ctgs', // все категории в административной части
        ];
        View::composer($views, 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()]);
        });
    }
    /* ... */
}

И последнее — валидация данных формы при создании и редактировании категории блога:

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

use Illuminate\Foundation\Http\FormRequest;

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

    /**
     * Возвращает массив правил для проверки полей формы
     *
     * @return array
     */
    public function rules() {
        $unique = 'unique:categories,slug';
        if ('admin.category.update' == $this->route()->getName()) {
            // получаем модель Category через маршрут admin/category/{category}
            $model = $this->route('category');
            /*
             * Проверка на уникальность slug, исключая этот пост по идентифкатору:
             * 1. categories — таблица базы данных, где проверяется уникальность
             * 2. slug — имя колонки, уникальность значения которой проверяется
             * 3. значение, по которому из проверки исключается запись таблицы БД
             * 4. поле, по которому из проверки исключается запись таблицы БД
             * Для проверки будет использован такой SQL-запрос к базе данныхЖ
             * SELECT COUNT(*) FROM `categories` WHERE `slug` = '...' AND `id` <> 17
             */
            $unique = 'unique:categories,slug,'.$model->id.',id';
        }
        return [
            'name' => [
                'required',
                'min:3',
                'max:100',
            ],
            'slug' => [
                'required',
                'max:100',
                $unique,
                'regex:~^[-_a-z0-9]+$~i',
            ],
            'content' => [
                'max:500',
            ],
            'image' => [
                'mimes:jpeg,jpg,png',
                'max:5000'
            ],
        ];
    }

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

    /**
     * Возвращает массив дружественных пользователю названий полей
     *
     * @return array
     */
    public function attributes() {
        return [
            'name' => 'Наименование',
            'slug' => 'ЧПУ (англ.)',
            'content' => 'Краткое описание',
            'image' => 'Изображение',
        ];
    }
}
namespace App\Http\Controllers\Admin;

use App\Category;
use App\Http\Controllers\Controller;
use App\Http\Requests\CategoryRequest;
use Illuminate\Http\Request;

class CategoryController extends Controller {
    /* ... */
    public function store(CategoryRequest $request) {
        $category = Category::create($request->all());
        return redirect()
            ->route('admin.category.show', ['category' => $category->id])
            ->with('success', 'Новая категория успешно создана');
    }
    /* ... */
    public function update(CategoryRequest $request, Category $category) {
        $category->update($request->all());
        return redirect()
            ->route('admin.category.index')
            ->with('success', 'Категория была успешно исправлена');
    }
    /* ... */
}

CRUD-операции над тегами блога

Новые маршруты:

/*
 * Панель управления: CRUD-операции над постами, категориями, тегами
 */
Route::group([
    'as' => 'admin.', // имя маршрута, например admin.index
    'prefix' => 'admin', // префикс маршрута, например admin/index
    'namespace' => 'Admin', // пространство имен контроллеров
    'middleware' => ['auth'] // один или несколько посредников
], function () {

    /* ... */

    /*
     * CRUD-операции над тегами блога
     */
    Route::resource('tag', 'TagController', ['except' => 'show']);
});

Контроллер TagController:

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

use App\Http\Controllers\Controller;
use App\Tag;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;

class TagController extends Controller {

    public function __construct() {
        $this->middleware('perm:manage-tags')->only('index');
        $this->middleware('perm:create-tag')->only(['create', 'store']);
        $this->middleware('perm:edit-tag')->only(['edit', 'update']);
        $this->middleware('perm:delete-tag')->only('destroy');
    }

    /**
     * Показывает список всех тегов блога
     */
    public function index() {
        $items = Tag::paginate(8);
        return view('admin.tag.index', compact('items'));
    }

    /**
     * Показывает форму для создания тега
     */
    public function create() {
        return view('admin.tag.create');
    }

    /**
     * Сохраняет новый тег в базу данных
     */
    public function store(Request $request) {
        $this->validator($request->all(), null)->validate();
        $tag = Tag::create($request->all());
        return redirect()
            ->route('admin.tag.index')
            ->with('success', 'Новый тег блога успешно создан');
    }

    /**
     * Показывает форму для редактирования тега
     */
    public function edit(Tag $tag) {
        return view('admin.tag.edit', compact('tag'));
    }

    /**
     * Обновляет тег блога в базе данных
     */
    public function update(Request $request, Tag $tag) {
        $this->validator($request->all(), $tag->id)->validate();
        $tag->update($request->all());
        return redirect()
            ->route('admin.tag.index')
            ->with('success', 'Тег блога был успешно исправлен');
    }

    /**
     * Удаляет тег блога из базы данных
     */
    public function destroy(Tag $tag) {
        $tag->delete();
        return redirect()
            ->route('admin.tag.index')
            ->with('success', 'Тег блога был успешно удален');
    }

    /**
     * Возвращает объект валидатора с нужными правилами
     */
    private function validator($data, $id) {
        $unique = 'unique:tags,slug';
        if ($id) {
            // проверка на уникальность slug тега при редактировании,
            // исключая этот тег по идентифкатору в таблице БД tags
            $unique = 'unique:tags,slug,'.$id.',id';
        }
        $rules = [
            'name' => [
                'required',
                'string',
                'max:50',
            ],
            'slug' => [
                'required',
                'max:50',
                $unique,
                'regex:~^[-_a-z0-9]+$~i',
            ]
        ];
        $messages = [
            'required' => 'Поле «:attribute» обязательно для заполнения',
            'max' => 'Поле «:attribute» должно быть не больше :max символов',
        ];
        $attributes = [
            'name' => 'Наименование',
            'slug' => 'ЧПУ (англ.)'
        ];
        return Validator::make($data, $rules, $messages, $attributes);
    }
}

Поскольку в контроллере мы используем «mass assignment» — добавляем свойство $fillable в модель Tag:

class Tag extends Model {

    protected $fillable = [
        'name',
        'slug',
    ];
    /* ... */
}

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

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

@section('content')
    <h1>Все теги блога</h1>
    @perm('create-tag')
        <a href="{{ route('admin.tag.create') }}" class="btn btn-success mb-4">
            Создать тег
        </a>
    @endperm
    @if ($items->count())
        <table class="table table-bordered">
            <tr>
                <th>#</th>
                <th width="45%">Наименование</th>
                <th width="45%">ЧПУ (англ.)</th>
                <th><i class="fas fa-edit"></i></th>
                <th><i class="fas fa-trash-alt"></i></th>
            </tr>
            @foreach ($items as $item)
            <tr>
                <td>{{ $item->id }}</td>
                <td>{{ $item->name }}</td>
                <td>{{ $item->slug }}</td>
                <td>
                    @perm('edit-tag')
                        <a href="{{ route('admin.tag.edit', ['tag' => $item->id]) }}">
                            <i class="far fa-edit"></i>
                        </a>
                    @endperm
                </td>
                <td>
                    @perm('delete-tag')
                        <form action="{{ route('admin.tag.destroy', ['tag' => $item->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>
        {{ $items->links() }}
    @endif
@endsection

Шаблон resource/views/admin/tag/create.tag.php для создания нового тега:

@extends('layout.admin', ['title' => 'Создание тега'])

@section('content')
    <h1>Создание тега</h1>
    <form method="post" action="{{ route('admin.tag.store') }}">
        @csrf
        <div class="form-group">
            <input type="text" class="form-control" name="name" placeholder="Наименование"
                   required maxlength="50" value="{{ old('name') ?? '' }}">
        </div>
        <div class="form-group">
            <input type="text" class="form-control" name="slug" placeholder="ЧПУ (на англ.)"
                   required maxlength="50" value="{{ old('slug') ?? '' }}">
        </div>
        <div class="form-group">
            <button type="submit" class="btn btn-primary">Сохранить</button>
        </div>
    </form>
@endsection

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

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

@section('content')
    <h1>Редактирование тега</h1>
    <form method="post" action="{{ route('admin.tag.update', ['tag' => $tag->id]) }}">
        @csrf
        @method('PUT')
        <div class="form-group">
            <input type="text" class="form-control" name="name" placeholder="Наименование"
                   required maxlength="50" value="{{ old('name') ?? $tag->name ?? '' }}">
        </div>
        <div class="form-group">
            <input type="text" class="form-control" name="slug" placeholder="ЧПУ (на англ.)"
                   required maxlength="50" value="{{ old('slug') ?? $tag->slug ?? '' }}">
        </div>
        <div class="form-group">
            <button type="submit" class="btn btn-primary">Сохранить</button>
        </div>
    </form>
@endsection

CRUD-операции над пользователями

Новые маршруты:

/*
 * Панель управления: CRUD-операции над постами, категориями, тегами
 */
Route::group([
    'as' => 'admin.', // имя маршрута, например admin.index
    'prefix' => 'admin', // префикс маршрута, например admin/index
    'namespace' => 'Admin', // пространство имен контроллеров
    'middleware' => ['auth'] // один или несколько посредников
], function () {

    /* ... */

    /*
     * Просмотр и редактирование пользователей
     */
    Route::resource('user', 'UserController', ['except' => [
        'create', 'store', 'show', 'destroy'
    ]]);
});

Контроллер UserController:

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

use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;

class UserController extends Controller {

    public function __construct() {
        $this->middleware('perm:manage-users')->only('index');
        $this->middleware('perm:edit-user')->only(['edit', 'update']);
    }

    /**
     * Показывает список всех пользователей
     */
    public function index() {
        $users = User::paginate(8);
        return view('admin.user.index', compact('users'));
    }

    /**
     * Показывает форму для редактирования пользователя
     */
    public function edit(User $user) {
        return view('admin.user.edit', compact('user'));
    }

    /**
     * Обновляет данные пользователя в базе данных
     */
    public function update(Request $request, User $user) {
        /*
         * Проверяем данные формы
         */
        $this->validator($request->all(), $user->id)->validate();
        /*
         * Обновляем пользователя
         */
        if ($request->change_password) { // если надо изменить пароль
            $request->merge(['password' => Hash::make($request->password)]);
            $user->update($request->all());
        } else {
            $user->update($request->except('password'));
        }
        /*
         * Возвращаемся к списку
         */
        return redirect()
            ->route('admin.user.index')
            ->with('success', 'Данные пользователя успешно обновлены');
    }

    /**
     * Возвращает объект валидатора с нужными нам правилами
     */
    private function validator(array $data, int $id) {
        $rules = [
            'name' => [
                'required',
                'string',
                'max:255'
            ],
            'email' => [
                'required',
                'string',
                'email',
                'max:255',
                // проверка на уникальность email, исключая
                // этого пользователя по идентифкатору
                'unique:users,email,'.$id.',id',
            ],
        ];
        if (isset($data['change_password'])) {
            $rules['password'] = ['required', 'string', 'min:8', 'confirmed'];
        }
        return Validator::make($data, $rules);
    }
}

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

@extends('layout.admin', ['title' => 'Все пользователи'])

@section('content')
    <h1 class="mb-4">Все пользователи</h1>

    <table class="table table-bordered">
        <tr>
            <th>#</th>
            <th width="20%">Дата регистрации</th>
            <th width="25%">Имя, фамилия</th>
            <th width="20%">Адрес почты</th>
            <th width="15%">Публикаций</th>
            <th width="15%">Комментариев</th>
            <th><i class="fas fa-edit"></i></th>
        </tr>
        @foreach($users as $user)
            <tr>
                <td>{{ $user->id }}</td>
                <td>{{ $user->created_at }}</td>
                <td>{{ $user->name }}</td>
                <td><a href="mailto:{{ $user->email }}">{{ $user->email }}</a></td>
                <td>{{ $user->posts->count() }}</td>
                <td>{{ $user->comments->count() }}</td>
                <td>
                    @perm('edit-user')
                        <a href="{{ route('admin.user.edit', ['user' => $user->id]) }}">
                            <i class="far fa-edit"></i>
                        </a>
                    @endperm
                </td>
            </tr>
        @endforeach
    </table>
    {{ $users->links() }}
@endsection

Шаблон resource/views/admin/user/index.blade.php для редактирования данных пользователя:

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

@section('content')
    <h1 class="mb-4">Редактирование пользователя</h1>
    <form method="post" action="{{ route('admin.user.update', ['user' => $user->id]) }}">
        @csrf
        @method('PUT')
        <div class="form-group">
            <input type="text" class="form-control" name="name" placeholder="Имя, Фамилия"
                   required maxlength="255" value="{{ old('name') ?? $user->name }}">
        </div>
        <div class="form-group">
            <input type="email" class="form-control" name="email" placeholder="Адрес почты"
                   required maxlength="255" value="{{ old('email') ?? $user->email }}">
        </div>
        <div class="form-group form-check">
            <input type="checkbox" class="form-check-input" name="change_password"
                   id="change_password">
            <label class="form-check-label" for="change_password">
                Изменить пароль пользователя
            </label>
        </div>
        <div class="form-group">
            <input type="text" class="form-control" name="password" maxlength="255"
                   placeholder="Новый пароль" value="">
        </div>
        <div class="form-group">
            <input type="text" class="form-control" name="password_confirmation" maxlength="255"
                   placeholder="Пароль еще раз" value="">
        </div>
        <div class="form-group">
            <button type="submit" class="btn btn-success">Сохранить</button>
        </div>
    </form>
@endsection

Поиск: 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.