Магазин на Laravel 7, часть 13. Панель управления, обрезка изображения и валидация данных

17.10.2020

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

Загрузка изображения

Ну вот, теперь можно вернуться к методам store() и update() контроллера и организовать загрузку и дальнейшее хранение изображения для категории каталога. Первым делом выполняем в консоли artisan-команду, которая создаст символическую ссылку.

> php artisan storage:link

Для начала просто сохраним изображение на диск и запишем имя файла изображения в базу данных:

class CategoryController extends Controller {
    /* ... */
    public function store(Request $request) {
        /*
         * Проверяем данные формы создания категории
         */
        $this->validate($request, [
            'parent_id' => 'integer',
            'name' => 'required|max:100',
            'slug' => 'required|max:100|unique:categories,slug|regex:~^[-_a-z0-9]+$~i',
            'image' => 'mimes:jpeg,jpg,png|max:5000'
        ]);
        /*
         * Проверка пройдена, создаем категорию
         */
        $file = $request->file('image');
        if ($file) { // был загружен файл изображения
            $path = $file->store('catalog/category/source', 'public');
            $base = basename($path);
        }
        $data = $request->all();
        $data['image'] = $base ?? null;
        $category = Category::create($data);
        return redirect()
            ->route('admin.category.show', ['category' => $category->id])
            ->with('success', 'Новая категория успешно создана');
    }
    /* ... */
}
class CategoryController extends Controller {
    /* ... */
    public function update(Request $request, Category $category) {
        /*
         * Проверяем данные формы редактирования категории
         */
        $id = $category->id;
        $this->validate($request, [
            'parent_id' => 'integer',
            'name' => 'required|max:100',
            /*
             * Проверка на уникальность slug, исключая эту категорию по идентифкатору:
             * 1. categories — таблица базы данных, где пороверяется уникальность
             * 2. slug — имя колонки, уникальность значения которой проверяется
             * 3. значение, по которому из проверки исключается запись таблицы БД
             * 4. поле, по которому из проверки исключается запись таблицы БД
             * Для проверки будет использован такой SQL-запрос к базе данныхЖ
             * SELECT COUNT(*) FROM `categories` WHERE `slug` = '...' AND `id` <> 17
             */
            'slug' => 'required|max:100|unique:categories,slug,'.$id.',id|regex:~^[-_a-z0-9]+$~i',
            'image' => 'mimes:jpeg,jpg,png|max:5000'
        ]);
        /*
         * Проверка пройдена, обновляем категорию
         */
        if ($request->remove) { // если надо удалить изображение
            $old = $category->image;
            if ($old) {
                Storage::disk('public')->delete('catalog/category/source/' . $old);
            }
        }
        $file = $request->file('image');
        if ($file) { // был загружен файл изображения
            $path = $file->store('catalog/category/source', 'public');
            $base = basename($path);
            // удаляем старый файл изображения
            $old = $category->image;
            if ($old) {
                Storage::disk('public')->delete('catalog/category/source/' . $old);
            }
        }
        $data = $request->all();
        $data['image'] = $base ?? null;
        $category->update($data);
        return redirect()
            ->route('admin.category.show', ['category' => $category->id])
            ->with('success', 'Категория была успешно исправлена');
    }
    /* ... */
}

Обрезка изображения

Изображение может быть слишком большим и не подходить для использования на сайте из-за неподходящих пропорций. Мы будем создавать из исходного изображения еще два — размером 600x300px и размером 300x150px. Для этого установим пакет intervention/image с помощью composer.

> composer require intervention/image

Открываем файл config/app.php и добавляем следующие строки:

return [
    /* ... */
    'providers' => [
        /* ... */
        Intervention\Image\ImageServiceProvider::class,
    ],
    
    'aliases' => [
        /* ... */
        'Image' => Intervention\Image\Facades\Image::class,
    ]
    /* ... */
]

Поскольку обрезать и сохранять изображения нам придется еще и для брендов и товаров — создадим отдельный класс ImageSaver в директории app/Helpers.

namespace App\Helpers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Facades\Image;

class ImageSaver {
    /**
     * Сохраняет изображение при создании или редактировании категории,
     * бренда или товара; создает два уменьшенных изображения.
     *
     * @param \Illuminate\Http\Request $request — объект HTTP-запроса
     * @param \App\Models\Item $item — модель категории, бренда или товара
     * @param string $dir — директория, куда будем сохранять изображение
     * @return string|null — имя файла изображения для сохранения в БД
     */
    public function upload($request, $item, $dir) {
        $name = $item->image ?? null;
        if ($item && $request->remove) { // если надо удалить изображение
            $this->remove($item, $dir);
            $name = null;
        }
        $source = $request->file('image');
        if ($source) { // если было загружено изображение
            // перед загрузкой нового изображения удаляем старое
            if ($item && $item->image) {
                $this->remove($item, $dir);
            }
            $ext = $source->extension();
            // сохраняем загруженное изображение без всяких изменений
            $path = $source->store('catalog/'.$dir.'/source', 'public');
            $path = Storage::disk('public')->path($path); // абсолютный путь
            $name = basename($path); // имя файла
            // создаем уменьшенное изображение 600x300px, качество 100%
            $dst = 'catalog/'.$dir.'/image/';
            $this->resize($path, $dst, 600, 300, $ext);
            // создаем уменьшенное изображение 300x150px, качество 100%
            $dst = 'catalog/'.$dir.'/thumb/';
            $this->resize($path, $dst, 300, 150, $ext);
        }
        return $name;
    }

    /**
     * Создает уменьшенную копию изображения
     *
     * @param string $src — путь к исходному изображению
     * @param string $dst — куда сохранять уменьшенное
     * @param integer $width — ширина в пикселях
     * @param integer $height — высота в пикселях
     * @param string $ext — расширение уменьшенного
     */
    private function resize($src, $dst, $width, $height, $ext) {
        // создаем уменьшенное изображение width x height, качество 100%
        $image = Image::make($src)
            ->heighten($height)
            ->resizeCanvas($width, $height, 'center', false, 'eeeeee')
            ->encode($ext, 100);
        // сохраняем это изображение под тем же именем, что исходное
        $name = basename($src);
        Storage::disk('public')->put($dst . $name, $image);
        $image->destroy();
    }

    /**
     * Удаляет изображение при удалении категории, бренда или товара
     *
     * @param \App\Models\Item $item — модель категории, бренда или товара
     * @param string $dir — директория, в которой находится изображение
     */
    public function remove($item, $dir) {
        $old = $item->image;
        if ($old) {
            Storage::disk('public')->delete('catalog/'.$dir.'/source/' . $old);
            Storage::disk('public')->delete('catalog/'.$dir.'/image/' . $old);
            Storage::disk('public')->delete('catalog/'.$dir.'/thumb/' . $old);
        }
    }
}

Внедряем зависимость в класс контроллера, чтобы иметь доступ к созданному классу и изменяем код методов store() и update(), избавляясь от всего лишнего.

namespace App\Http\Controllers\Admin;

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

class CategoryController extends Controller {

    private $imageSaver;

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

    /**
     * Показывает список всех категорий
     *
     * @return \Illuminate\Http\Response
     * @throws \Illuminate\Contracts\Container\BindingResolutionException
     */
    public function index() {
        $roots = Category::roots();
        return view('admin.category.index', compact('roots'));
    }

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

    /**
     * Сохраняет новую категорию в базу данных
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request) {
        /*
         * Проверяем данные формы создания категории
         */
        $this->validate($request, [
            'parent_id' => 'integer',
            'name' => 'required|max:100',
            'slug' => 'required|max:100|unique:categories,slug|regex:~^[-_a-z0-9]+$~i',
            'image' => 'mimes:jpeg,jpg,png|max:5000'
        ]);
        /*
         * Проверка пройдена, создаем категорию
         */
        $data = $request->all();
        $data['image'] = $this->imageSaver->upload($request, null, 'category');
        $category = Category::create($data);
        return redirect()
            ->route('admin.category.show', ['category' => $category->id])
            ->with('success', 'Новая категория успешно создана');
    }

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

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

    /**
     * Обновляет категорию каталога
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\Models\Category  $category
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, Category $category) {
        /*
         * Проверяем данные формы редактирования категории
         */
        $id = $category->id;
        $this->validate($request, [
            'parent_id' => 'integer',
            'name' => 'required|max:100',
            /*
             * Проверка на уникальность slug, исключая эту категорию по идентифкатору:
             * 1. categories — таблица базы данных, где пороверяется уникальность
             * 2. slug — имя колонки, уникальность значения которой проверяется
             * 3. значение, по которому из проверки исключается запись таблицы БД
             * 4. поле, по которому из проверки исключается запись таблицы БД
             * Для проверки будет использован такой SQL-запрос к базе данныхЖ
             * SELECT COUNT(*) FROM `categories` WHERE `slug` = '...' AND `id` <> 17
             */
            'slug' => 'required|max:100|unique:categories,slug,'.$id.',id|regex:~^[-_a-z0-9]+$~i',
            'image' => 'mimes:jpeg,jpg,png|max:5000'
        ]);
        /*
         * Проверка пройдена, обновляем категорию
         */
        $data = $request->all();
        $data['image'] = $this->imageSaver->upload($request, $category, 'category');
        $category->update($data);
        return redirect()
            ->route('admin.category.show', ['category' => $category->id])
            ->with('success', 'Категория была успешно исправлена');
    }

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

Структура директорий, где мы сохраняем исходное изображение и его уменьшенные копии:

[storage]
    [app]
        [public] доступна из веб через символическую ссылку
            [catalog]
                [category]
                    [source]
                        bt8QPiw96zrwl65f7eKOXJ8yao1r8e4VOHQtpQff.jpeg
                        ..........
                    [image]
                        bt8QPiw96zrwl65f7eKOXJ8yao1r8e4VOHQtpQff.jpeg
                        ..........
                    [thumb]
                        bt8QPiw96zrwl65f7eKOXJ8yao1r8e4VOHQtpQff.jpeg
                        ..........
                [brand]
                    [source]
                        fd9QPiw96zrwl65f7eKODJ8yao1r8e4VOHQtpSda.jpeg
                        ..........
                    [image]
                        fd9QPiw96zrwl65f7eKODJ8yao1r8e4VOHQtpSda.jpeg
                        ..........
                    [thumb]
                        fd9QPiw96zrwl65f7eKODJ8yao1r8e4VOHQtpSda.jpeg
                        ..........
                [product]
                    [source]
                        ey7QPiw96zrwl65f7eKOYJ8yao1r8e4VOHQtpFvs.jpeg
                        ..........
                    [image]
                        ey7QPiw96zrwl65f7eKOYJ8yao1r8e4VOHQtpFvs.jpeg
                        ..........
                    [thumb]
                        ey7QPiw96zrwl65f7eKOYJ8yao1r8e4VOHQtpFvs.jpeg
                        ..........

И осталось только показать загруженное изображение в шаблоне show.blade.php:

@extends('layout.admin')

@section('content')
    <h1>Просмотр категории</h1>
    <div class="row">
        <div class="col-md-6">
            <p><strong>Название:</strong> {{ $category->name }}</p>
            <p><strong>ЧПУ (англ):</strong> {{ $category->slug }}</p>
            <p><strong>Краткое описание</strong></p>
            @isset($category->content)
                <p>{{ $category->content }}</p>
            @else
                <p>Описание отсутствует</p>
            @endisset
        </div>
        <div class="col-md-6">
            @php
                if ($category->image) {
                    // $url = url('storage/catalog/category/image/' . $category->image);
                    $url = Storage::disk('public')->url('catalog/category/image/' . $category->image);
                } else {
                    // $url = url('storage/catalog/category/image/default.jpg');
                    $url = Storage::disk('public')->url('catalog/category/image/default.jpg');
                }
            @endphp
            <img src="{{ $url }}" alt="" class="img-fluid">
        </div>
    </div>
    @if ($category->children->count())
        <p><strong>Дочерние категории</strong></p>
        <!-- Здесь таблица дочерних категорий -->
    @else
        <p>Нет дочерних категорий</p>
    @endif
    <a href="{{ route('admin.category.edit', ['category' => $category->id]) }}"
       class="btn btn-success">
        Редактировать категорию
    </a>
    <form method="post" class="d-inline"
          action="{{ route('admin.category.destroy', ['category' => $category->id]) }}">
        @csrf
        @method('DELETE')
        <button type="submit" class="btn btn-danger">
            Удалить категорию
        </button>
    </form>
@endsection

Валидация в отдельном классе

Сейчас валидация данных формы добавления и редактирования категории у нас внутри методов store() и update(). Давайте создадим отдельный класс и всю логику валидации переместим в него.

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

use Illuminate\Foundation\Http\FormRequest;

class CategoryCatalogRequest extends FormRequest {
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize() {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules() {
        switch ($this->method()) {
            case 'POST':
                return [
                    'parent_id' => 'integer',
                    'name' => 'required|max:100',
                    'slug' => 'required|max:100|unique:categories,slug|regex:~^[-_a-z0-9]+$~i',
                    'image' => 'mimes:jpeg,jpg,png|max:5000'
                ];
            case 'PUT':
            case 'PATCH':
                // получаем объект модели категории из маршрута: admin/category/{category}
                $model = $this->route('category');
                // из объекта модели получаем уникальный идентификатор для валидации
                $id = $model->id;
                return [
                    'parent_id' => 'integer',
                    'name' => 'required|max:100',
                    /*
                     * Проверка на уникальность slug, исключая эту категорию по идентифкатору:
                     * 1. categories — таблица базы данных, где пороверяется уникальность
                     * 2. slug — имя колонки, уникальность значения которой проверяется
                     * 3. значение, по которому из проверки исключается запись таблицы БД
                     * 4. поле, по которому из проверки исключается запись таблицы БД
                     * Для проверки будет использован такой SQL-запрос к базе данныхЖ
                     * SELECT COUNT(*) FROM `categories` WHERE `slug` = '...' AND `id` <> 17
                     */
                    'slug' => 'required|max:100|unique:categories,slug,'.$id.',id|regex:~^[-_a-z0-9]+$~i',
                    'image' => 'mimes:jpeg,jpg,png|max:5000'
                ];
        }
    }
}

И будем использовать этот класс в контроллере для проверки данных формы:

namespace App\Http\Controllers\Admin;

use App\Helpers\ImageSaver;
use App\Http\Controllers\Controller;
use App\Http\Requests\CategoryCatalogRequest;
use App\Models\Category;

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

Сообщения об ошибках

Давайте приведем в порядок сообщения об ошибках — для этого открываем на редактирование файл validation.php в директории resources/lang/ru.

return [
    /* ... */
    'custom' => [
        'name' => [
            'required' => 'Поле «:attribute» обязательно для заполнения',
            'max' => 'Поле «:attribute» должно быть не больше :max символов',
        ],
        'email' => [
            'required' => 'Поле «:attribute» обязательно для заполнения',
            'max' => 'Поле «:attribute» должно быть не больше :max символов',
        ],
        'phone' => [
            'required' => 'Поле «:attribute» обязательно для заполнения',
            'max' => 'Поле «:attribute» должно быть не больше :max символов',
        ],
        'address' => [
            'required' => 'Поле «:attribute» обязательно для заполнения',
            'max' => 'Поле «:attribute» должно быть не больше :max символов',
        ],
        'slug' => [
            'required' => 'Поле «:attribute» обязательно для заполнения',
            'unique' => 'Поле «:attribute» должно быть уникальным значением',
            'regex' => 'Поле «:attribute» допускает только буквы, цифры, «-» и «_»',
            'max' => 'Поле «:attribute» должно быть не больше :max символов',
        ],
    ],
    
    'attributes' => [
        'name'                  => 'Имя, Фамилия',
        'slug'                  => 'ЧПУ (англ)',
        'email'                 => 'Адрес почты',
        'password'              => 'Пароль',
        'password_confirmation' => 'Подтверждение пароля',
        'address'               => 'Адрес доставки',
        'phone'                 => 'Номер телефона',
        /* ... */
    ],
];

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