Магазин на Laravel 7, часть 16. Панель управления, CRUD-операции для товаров каталога

24.10.2020

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

С категориями и брендами разобрались, настала очередь товаров каталога. Нам опять нужен ресурсный контроллер, который создадим с помощью artisan-команды. Еще потребуется семь маршртутов, отвечающих за CRUD-операции над товарами и свойство $fillable для модели, поскольку будем использовать «mass assignment». Ну и шаблоны для просмотра списка, отдельного товара, страницы добавления и редактирования товара.

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

> php artisan make:controller Admin/ProductController --resource --model=Models/Product
namespace App\Http\Controllers\Admin;

use App\Helpers\ImageSaver;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Http\Request;

class ProductController extends Controller {

    private $imageSaver;

    public function __construct(ImageSaver $imageSaver) {
        $this->imageSaver = $imageSaver;
    }

    /**
     * Показывает список всех товаров каталога
     *
     * @return \Illuminate\Http\Response
     */
    public function index() {
        $products = Product::paginate(5);
        return view('admin.product.index', compact('products'));
    }

    /**
     * Показывает форму для создания товара
     *
     * @return \Illuminate\Http\Response
     */
    public function create() {
        // все категории для возможности выбора родителя
        $items = Category::all();
        // все бренды для возмозжности выбора подходящего
        $brands = Brand::all();
        return view('admin.product.create', compact('items', 'brands'));
    }

    /**
     * Сохраняет новый товар в базу данных
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request) {
        $data = $request->all();
        $data['image'] = $this->imageSaver->upload($request, null, 'product');
        $product = Product::create($data);
        return redirect()
            ->route('admin.product.show', ['product' => $product->id])
            ->with('success', 'Новый товар успешно создан');
    }

    /**
     * Показывает страницу товара каталога
     *
     * @param  \App\Models\Product  $product
     * @return \Illuminate\Http\Response
     */
    public function show(Product $product) {
        return view('admin.product.show', compact('product'));
    }

    /**
     * Показывает форму для редактирования товара
     *
     * @param  \App\Models\Product  $product
     * @return \Illuminate\Http\Response
     */
    public function edit(Product $product) {
        // все категории для возможности выбора родителя
        $items = Category::all();
        // все бренды для возмозжности выбора подходящего
        $brands = Brand::all();
        return view('admin.product.edit', compact('product', 'items', 'brands'));
    }

    /**
     * Обновляет товар каталога в базе данных
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\Models\Product  $product
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, Product $product) {
        $data = $request->all();
        $data['image'] = $this->imageSaver->upload($request, $product, 'product');
        $product->update($data);
        return redirect()
            ->route('admin.product.show', ['product' => $product->id])
            ->with('success', 'Товар был успешно обновлен');
    }

    /**
     * Удаляет товар каталога из базы данных
     *
     * @param  \App\Models\Product  $product
     * @return \Illuminate\Http\Response
     */
    public function destroy(Product $product) {
        $this->imageSaver->remove($product, 'product');
        $product->delete();
        return redirect()
            ->route('admin.category.index')
            ->with('success', 'Товар каталога успешно удален');
    }
}

Добавляем свойство $fillable в модель:

class Category extends Model {

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

Прописываем маршруты в файле web.php:

Route::group([
    'as' => 'admin.', // имя маршрута, например admin.index
    'prefix' => 'admin', // префикс маршрута, например admin/index
    'namespace' => 'Admin', // пространство имен контроллера
    'middleware' => ['auth', 'admin'] // один или несколько посредников
], function () {
    // главная страница панели управления
    Route::get('index', 'IndexController')->name('index');
    // CRUD-операции над категориями каталога
    Route::resource('category', 'CategoryController');
    // CRUD-операции над брендами каталога
    Route::resource('brand', 'BrandController');
    // CRUD-операции над товарами каталога
    Route::resource('product', 'ProductController');
});

Создаем шаблон views/admin/product/index.blade.php:

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

@section('content')
    <h1>Все товары</h1>
    <a href="{{ route('admin.product.create') }}" class="btn btn-success mb-4">
        Создать товар
    </a>
    <table class="table table-bordered">
        <tr>
            <th width="30%">Наименование</th>
            <th width="65%">Описание</th>
            <th><i class="fas fa-edit"></i></th>
            <th><i class="fas fa-trash-alt"></i></th>
        </tr>
        @foreach ($products as $product)
        <tr>
            <td>
                <a href="{{ route('admin.product.show', ['product' => $product->id]) }}">
                    {{ $product->name }}
                </a>
            </td>
            <td>{{ iconv_substr($product->content, 0, 150) }}</td>
            <td>
                <a href="{{ route('admin.product.edit', ['product' => $product->id]) }}">
                    <i class="far fa-edit"></i>
                </a>
            </td>
            <td>
                <form action="{{ route('admin.product.destroy', ['product' => $product->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>
            </td>
        </tr>
        @endforeach
    </table>
    {{ $products->links() }}
@endsection

Создаем шаблон views/admin/product/show.blade.php:

@extends('layout.admin', ['title' => 'Просмотр товара'])

@section('content')
    <h1>Просмотр товара</h1>
    <div class="row">
        <div class="col-md-6">
            <p><strong>Название:</strong> {{ $product->name }}</p>
            <p><strong>ЧПУ (англ):</strong> {{ $product->slug }}</p>
            <p><strong>Бренд:</strong> {{ $product->brand->name }}</p>
            <p><strong>Категория:</strong> {{ $product->category->name }}</p>

        </div>
        <div class="col-md-6">
            @php
                if ($product->image) {
                    $url = url('storage/catalog/product/image/' . $product->image);
                } else {
                    $url = url('storage/catalog/product/image/default.jpg');
                }
            @endphp
            <img src="{{ $url }}" alt="" class="img-fluid">
        </div>
    </div>
    <div class="row">
        <div class="col-12">
            <p><strong>Описание</strong></p>
            @isset($product->content)
                <p>{{ $product->content }}</p>
            @else
                <p>Описание отсутствует</p>
            @endisset
            <a href="{{ route('admin.product.edit', ['product' => $product->id]) }}"
               class="btn btn-success">
                Редактировать товар
            </a>
            <form method="post" class="d-inline" onsubmit="return confirm('Удалить этот товар?')"
                  action="{{ route('admin.product.destroy', ['product' => $product->id]) }}">
                @csrf
                @method('DELETE')
                <button type="submit" class="btn btn-danger">
                    Удалить товар
                </button>
            </form>
        </div>
    </div>
@endsection

Создаем шаблон views/admin/product/create.blade.php:

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

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

Создаем шаблон views/admin/product/edit.blade.php:

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

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

Создаем шаблон views/admin/product/part/form.blade.php:

@csrf
<div class="form-group">
    <input type="text" class="form-control" name="name" placeholder="Наименование"
           required maxlength="100" value="{{ old('name') ?? $product->name ?? '' }}">
</div>
<div class="form-group">
    <input type="text" class="form-control" name="slug" placeholder="ЧПУ (на англ.)"
           required maxlength="100" value="{{ old('slug') ?? $product->slug ?? '' }}">
</div>
<div class="form-group">
    @php
        $category_id = old('category_id') ?? $product->category_id ?? 0;
    @endphp
    <select name="category_id" class="form-control" title="Категория">
        <option value="0">Выберите</option>
        @if (count($items))
            @include('admin.product.part.branch', ['level' => -1, 'parent' => 0])
        @endif
    </select>
</div>
<div class="form-group">
    @php
        $brand_id = old('brand_id') ?? $product->brand_id ?? 0;
    @endphp
    <select name="brand_id" class="form-control" title="Бренд">
        <option value="0">Выберите</option>
        @foreach($brands as $brand)
            <option value="{{ $brand->id }}" @if ($brand->id == $brand_id) selected @endif>
                {{ $brand->name }}
            </option>
        @endforeach
    </select>
</div>
<div class="form-group">
    <textarea class="form-control" name="content" placeholder="Описание"
              rows="4">{{ old('content') ?? $product->content ?? '' }}</textarea>
</div>
<div class="form-group">
    <input type="file" class="form-control-file" name="image" accept="image/png, image/jpeg">
</div>
@isset($product->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>

Создаем шаблон views/admin/product/part/branch.blade.php:

@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>
    @if (count($items->where('parent_id', $parent)))
        @include('admin.product.part.branch', ['level' => $level, 'parent' => $item->id])
    @endif
@endforeach

Работать в админке сейчас не очень удобно, потому что список товаров может содержать сотни и тысячи элементов. Так что найти нужный для редактирования товар довольно проблематично — нужна какая-то навигация. Давайте на странице списка товаров будем показывать корневые категории. И создадим еще один метод в контроллере, который будет показывать товары выбранной категории. Таким образом, у нас будет навигация, которая позволит перейти внутрь корневой категории, оттуда перейти в дочернюю категорию этой корневой и так далее.

class ProductController extends Controller {
    /* ... */
    public function index() {
        // корневые категории для возможности навигации
        $roots = Category::where('parent_id', 0)->get();
        $products = Product::paginate(5);
        return view('admin.product.index', compact('products', 'roots'));
    }

    /**
     * Показывает товары выбранной категории
     *
     * @return \Illuminate\Http\Response
     */
    public function category(Category $category) {
        $products = $category->products()->paginate(5);
        return view('admin.product.category', compact('category', 'products'));
    }
    /* ... */
}

Еще один маршрут для просмотра товаров категории:

Route::group([
    'as' => 'admin.', // имя маршрута, например admin.index
    'prefix' => 'admin', // префикс маршрута, например admin/index
    'namespace' => 'Admin', // пространство имен контроллера
    'middleware' => ['auth', 'admin'] // один или несколько посредников
], function () {
    // главная страница панели управления
    Route::get('index', 'IndexController')->name('index');
    // CRUD-операции над категориями каталога
    Route::resource('category', 'CategoryController');
    // CRUD-операции над брендами каталога
    Route::resource('brand', 'BrandController');
    // CRUD-операции над товарами каталога
    Route::resource('product', 'ProductController');
    // доп.маршрут для просмотра товаров категории
    Route::get('product/category/{category}', 'ProductController@category')
        ->name('product.category');
});

Редактируем шаблон views/admin/product/index.blade.php:

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

@section('content')
    <h1>Все товары</h1>
    <!-- Корневые категории для возможности навигации -->
    <ul>
    @foreach ($roots as $root)
        <li>
            <a href="{{ route('admin.product.category', ['category' => $root->id]) }}">
                {{ $root->name }}
            </a>
        </li>
    @endforeach
    </ul>
    <a href="{{ route('admin.product.create') }}" class="btn btn-success mb-4">
        Создать товар
    </a>
    <table class="table table-bordered">
    <!-- ..... -->
    </table>
    {{ $products->links() }}
@endsection

Создаем шаблон views/admin/product/category.blade.php:

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

@section('content')
    <h1>{{ $category->name }}</h1>
    <!-- Дочерние категории для возможности навигации -->
    <ul>
        @foreach ($category->children as $child)
            <li>
                <a href="{{ route('admin.product.category', ['category' => $child->id]) }}">
                    {{ $child->name }}
                </a>
            </li>
        @endforeach
    </ul>
    <a href="{{ route('admin.product.create') }}" class="btn btn-success mb-4">
        Создать товар
    </a>
    <!-- Список товаров выбранной категории -->
    @if (count($products))
        <table class="table table-bordered">
            <tr>
                <th width="30%">Наименование</th>
                <th width="65%">Описание</th>
                <th><i class="fas fa-edit"></i></th>
                <th><i class="fas fa-trash-alt"></i></th>
            </tr>
            @foreach ($products as $product)
            <tr>
                <td>
                    <a href="{{ route('admin.product.show', ['product' => $product->id]) }}">
                        {{ $product->name }}
                    </a>
                </td>
                <td>{{ iconv_substr($product->content, 0, 150) }}</td>
                <td>
                    <a href="{{ route('admin.product.edit', ['product' => $product->id]) }}">
                        <i class="far fa-edit"></i>
                    </a>
                </td>
                <td>
                    <form action="{{ route('admin.product.destroy', ['product' => $product->id]) }}"
                          method="post">
                        @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>
                </td>
            </tr>
            @endforeach
        </table>
        {{ $products->links() }}
    @else
        <p>Нет товаров в этой категории</p>
    @endif
@endsection

Создаем класс для валидации:

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

class ProductCatalogRequest extends CatalogRequest {

    /**
     * С какой сущностью сейчас работаем (товар каталога)
     * @var array
     */
    protected $entity = [
        'name' => 'product',
        'table' => 'products'
    ];

    public function authorize() {
        return parent::authorize();
    }

    public function rules() {
        return parent::rules();
    }

    /**
     * Объединяет дефолтные правила и правила, специфичные для товара
     * для проверки данных при добавлении нового товара
     */
    protected function createItem() {
        $rules = [
            'category_id' => [
                'required',
                'integer',
                'min:1'
            ],
            'brand_id' => [
                'required',
                'integer',
                'min:1'
            ],
        ];
        return array_merge(parent::createItem(), $rules);
    }

    /**
     * Объединяет дефолтные правила и правила, специфичные для товара
     * для проверки данных при обновлении существующего товара
     */
    protected function updateItem() {
        $rules = [
            'category_id' => [
                'required',
                'integer',
                'min:1'
            ],
            'brand_id' => [
                'required',
                'integer',
                'min:1'
            ],
        ];
        return array_merge(parent::updateItem(), $rules);
    }
}

Изменяем «type hinting» в контроллере:

namespace App\Http\Controllers\Admin;

use App\Helpers\ImageSaver;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProductCatalogRequest;
use App\Models\Brand;
use App\Models\Category;
use App\Models\Product;

class ProductController extends Controller {
    /* ... */
    public function store(ProductCatalogRequest $request) {
        /* ... */
    }
    /* ... */
    public function update(ProductCatalogRequest $request, Brand $brand) {
        /* ... */
    }
    /* ... */
}

Для товара обязательно должны быть заполнены поля «Категория» и «Бренд». Но сообщение об ошибке совершенно не информативно.

Зададим свои сообщения в файле lang/ru/validation.php, если не выбраны категория и/или бренд:

return [
    'custom' => [
        /* ... */
        'category_id' => [
            'required' => 'Поле «:attribute» обязательно для заполнения',
            'integer' => 'Поле «:attribute» должно быть целым положительным числом',
            'min' => 'Поле «:attribute» обязательно для заполнения',
        ],
        'brand_id' => [
            'required' => 'Поле «:attribute» обязательно для заполнения',
            'integer' => 'Поле «:attribute» должно быть целым положительным числом',
            'min' => 'Поле «:attribute» обязательно для заполнения',
        ],
    ],

    'attributes' => [
        'name'                  => 'Имя, Фамилия',
        'slug'                  => 'ЧПУ (англ)',
        'email'                 => 'Адрес почты',
        'password'              => 'Пароль',
        'password_confirmation' => 'Подтверждение пароля',
        'address'               => 'Адрес доставки',
        'phone'                 => 'Номер телефона',
        /* ... */
        'parent_id'             => 'Родитель',
        'category_id'           => 'Категория',
        'brand_id'              => 'Бренд',
    ],
]

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