Академический Документы
Профессиональный Документы
Культура Документы
/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::dropIfExists('categories');
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::dropIfExists('brands');
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::dropIfExists('products');
}
}
Перед тем, как создавать таблицы базы данных, надо задать параметры подключения к серверу БД в
файле .env:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=larablog
DB_USERNAME=root
DB_PASSWORD=qwerty
Теперь все готово к миграции, создаем таблицы базы данных с помощью команды:
--
-- Индексы таблицы `users`
--
ALTER TABLE `users`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `users_email_unique` (`email`);
--
-- AUTO_INCREMENT для таблицы `users`
--
ALTER TABLE `users`
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
COMMIT;
--
-- Структура таблицы `categories`
--
CREATE TABLE `categories` (
`id` bigint(20) UNSIGNED NOT NULL,
`parent_id` bigint(20) UNSIGNED NOT NULL DEFAULT '0',
`name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`content` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`slug` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`image` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--
-- Индексы таблицы `categories`
--
ALTER TABLE `categories`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `categories_slug_unique` (`slug`);
--
-- AUTO_INCREMENT для таблицы `categories`
--
ALTER TABLE `categories`
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
COMMIT;
--
-- Структура таблицы `brands`
--
CREATE TABLE `brands` (
`id` bigint(20) UNSIGNED NOT NULL,
`name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`content` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`slug` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`image` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--
-- Индексы таблицы `brands`
--
ALTER TABLE `brands`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `brands_slug_unique` (`slug`);
--
-- AUTO_INCREMENT для таблицы `brands`
--
ALTER TABLE `brands`
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
COMMIT;
--
-- Структура таблицы `products`
--
CREATE TABLE `products` (
`id` bigint(20) UNSIGNED NOT NULL,
`category_id` bigint(20) UNSIGNED DEFAULT NULL,
`brand_id` bigint(20) UNSIGNED DEFAULT NULL,
`name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`content` text COLLATE utf8mb4_unicode_ci,
`slug` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`image` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`price` decimal(10,2) UNSIGNED NOT NULL DEFAULT '0.00',
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--
-- Индексы таблицы `products`
--
ALTER TABLE `products`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `products_slug_unique` (`slug`),
ADD KEY `products_category_id_foreign` (`category_id`),
ADD KEY `products_brand_id_foreign` (`brand_id`);
--
-- AUTO_INCREMENT для таблицы `products`
--
ALTER TABLE `products`
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
--
-- Ограничения внешнего ключа таблицы `products`
--
ALTER TABLE `products`
ADD CONSTRAINT `products_brand_id_foreign` FOREIGN KEY (`brand_id`) REFERENCES
`brands` (`id`) ON DELETE SET NULL,
ADD CONSTRAINT `products_category_id_foreign` FOREIGN KEY (`category_id`)
REFERENCES `categories` (`id`) ON DELETE SET NULL;
COMMIT;
Заполнение таблиц БД
Laravel включает в себя механизм наполнения базы данных начальными данными (seeding) с помощью
специальных классов. Все такие классы хранятся в директории database/seeds. Для создания заготовок
классов CategoryTableSeeder, BrandTableSeeder и ProductTableSeeder используем команду:
> php artisan make:seeder CategoryTableSeeder
> php artisan make:seeder BrandTableSeeder
> php artisan make:seeder ProductTableSeeder
По умолчанию в Laravel уже определён класс DatabaseSeeder. Из этого класса можно вызывать
метод call() для подключения других классов с данными, что позволит контролировать порядок их
выполнения.
<?php
use Illuminate\Database\Seeder;
$this->call(BrandTableSeeder::class);
$this->command->info('Таблица брендов загружена данными!');
$this->call(ProductTableSeeder::class);
$this->command->info('Таблица товаров загружена данными!');
}
}
Файлы фабрик моделей хранятся в директории database/factories, и там уже есть один готовый
файл UserFactory.php — это фабрика для модели User. Чтобы создать фабрику для
моделей Category, Brand и Product — используем artisan-команду:
> php artisan make:factory CategoryFactory --model=Category
> php artisan make:factory BrandFactory --model=Brand
> php artisan make:factory ProductFactory --model=Product
<?php
/** @var \Illuminate\Database\Eloquent\Factory $factory */
use App\Category;
use Illuminate\Support\Str;
use Faker\Generator as Faker;
use App\Brand;
use Illuminate\Support\Str;
use Faker\Generator as Faker;
use App\Product;
use Illuminate\Support\Str;
use Faker\Generator as Faker;
Добавляем маршртуры
Добавляем необходимые маршруты:
<?php
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/catalog/index', 'CatalogController@index')->name('catalog.index');
Route::get('/catalog/category/{slug}', 'CatalogController@category')-
>name('catalog.category');
Route::get('/catalog/brand/{slug}', 'CatalogController@brand')-
>name('catalog.brand');
Route::get('/catalog/product/{slug}', 'CatalogController@product')-
>name('catalog.product');
Создаем контроллер
Создаем заготовку контроллера CatalogController
> php artisan make:controller CatalogController
Controller created successfully.
<?php
namespace App\Http\Controllers;
use App\Brand;
use App\Category;
use App\Product;
use Illuminate\Http\Request;
Создаем шаблоны
Шаблон resources/views/catalog/index.blade.php:
<h1>Каталог товаров</h1>
<ul>
@foreach ($roots as $root)
<li>
<a href="{{ route('catalog.category', ['slug' => $root->slug]) }}">
{{ $root->name }}
</a>
</li>
@endforeach
</ul>
Шаблон resources/views/catalog/category.blade.php:
<h1>Категория: {{ $category->name }}</h1>
<ul>
@foreach ($products as $product)
<li>
<a href="{{ route('catalog.product', ['slug' => $product->slug]) }}">
{{ $product->name }}
</a>
</li>
@endforeach
</ul>
Шаблон resources/views/catalog/brand.blade.php:
<h1>Бренд: {{ $brand->name }}</h1>
<ul>
@foreach ($products as $product)
<li>
<a href="{{ route('catalog.product', ['slug' => $product->slug]) }}">
{{ $product->name }}
</a>
</li>
@endforeach
</ul>
Шаблон resources/views/catalog/product.blade.php:
<h1>Товар: {{ $product->name }}</h1>
<p>Цена: {{ number_format($product->price, 2, '.', '') }}</p>
<p>
Категория:
<a href="{{ route('catalog.category', ['slug' => $product->category_slug]) }}">
{{ $product->category_name }}
</a>
</p>
<p>
Бренд:
<a href="{{ route('catalog.brand', ['slug' => $product->brand_slug]) }}">
{{ $product->brand_name }}
</a>
</p>
<p>{{ $product->content }}</p>
Layout шаблон
Теперь нам нужен layout-шаблон, в котором будет шапка и подвал. Для этого нам потребуется CSS-
фреймворк Bootstrap4. В консоли последовательно запускаем три команды:
</body>
</html>
<!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>
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
<script src="{{ asset('js/app.js') }}"></script>
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<!-- Бренд и кнопка «Гамбургер» -->
<a class="navbar-brand" href="/">Магазин</a>
<button class="navbar-toggler" type="button" data-toggle="collapse"
data-target="#navbar-example" aria-controls="navbar-example"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<!-- Основная часть меню (может содержать ссылки, формы и прочее) -->
<div class="collapse navbar-collapse" id="navbar-example">
<!-- Этот блок расположен слева -->
<ul class="navbar-nav mr-auto">
<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>
<!-- Этот блок расположен справа -->
<form class="form-inline my-2 my-lg-0">
<input class="form-control mr-sm-2" type="search"
placeholder="Поиск по каталогу" aria-label="Search">
<button class="btn btn-outline-info my-2 my-sm-0"
type="submit">Искать</button>
</form>
</div>
</nav>
<div class="row">
<div class="col-md-3">
<h4>Разделы каталога</h4>
<p>Здесь будут корневые разделы</p>
<h4>Популярные бренды</h4>
<p>Здесь будут популярные бренды</p>
</div>
<div class="col-md-9">
@yield('content')
</div>
</div>
</div>
</body>
</html>
@extends('layout.site')
@section('content')
<h1>Каталог товаров</h1>
<ul>
@foreach ($roots as $root)
<li>
<a href="{{ route('catalog.category', ['slug' => $root->slug]) }}">
{{ $root->name }}
</a>
</li>
@endforeach
</ul>
@endsection
@extends('layout.site')
@section('content')
<h1>Категория: {{ $category->name }}</h1>
<ul>
@foreach ($products as $product)
<li>
<a href="{{ route('catalog.product', ['slug' => $product->slug])
}}">
{{ $product->name }}
</a>
</li>
@endforeach
</ul>
@endsection
@extends('layout.site')
@section('content')
<h1>Бренд: {{ $brand->name }}</h1>
<ul>
@foreach ($products as $product)
<li>
<a href="{{ route('catalog.product', ['slug' => $product->slug])
}}">
{{ $product->name }}
</a>
</li>
@endforeach
</ul>
@endsection
@extends('layout.site')
@section('content')
<h1>Товар: {{ $product->name }}</h1>
<p>Цена: {{ number_format($product->price, 2, '.', '') }}</p>
<p>
Категория:
<a href="{{ route('catalog.category', ['slug' => $product->category_slug])
}}">
{{ $product->category_name }}
</a>
</p>
<p>
Бренд:
<a href="{{ route('catalog.brand', ['slug' => $product->brand_slug]) }}">
{{ $product->brand_name }}
</a>
</p>
<p>{{ $product->content }}</p>
@endsection
Магазин на Laravel 7, часть 3. Создание главной страницы
сайта, работа над шаблонами
use Illuminate\Http\Request;
<?php
use Illuminate\Support\Facades\Route;
Route::get('/catalog/index', 'CatalogController@index')->name('catalog.index');
Route::get('/catalog/category/{slug}', 'CatalogController@category')-
>name('catalog.category');
Route::get('/catalog/brand/{slug}', 'CatalogController@brand')-
>name('catalog.brand');
Route::get('/catalog/product/{slug}', 'CatalogController@product')-
>name('catalog.product');
Шаблон resources/views/index.blade.php:
@extends('layout.site')
@section('content')
<h1>Интернет-магазин</h1>
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Blanditiis ducimus
eveniet...</p>
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Amet asperiores
corporis...</p>
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ab aspernatur
assumenda...</p>
@endsection
Приводим в порядок шаблоны
Не то, чтобы очень тщательно, потому как все равно еще будем исправлять. Но так, чтобы можно было
удобно работать.
@extends('layout.site')
@section('content')
<h1>Каталог товаров</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque ducimus,
eligendi
exercitationem expedita, iure iusto laborum magnam qui quidem repellat
similique
tempora tempore ullam! Deserunt doloremque impedit quis repudiandae voluptas?
</p>
<h2>Разделы каталога</h2>
<div class="row">
@foreach ($roots as $root)
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">
<h4>{{ $root->name }}</h4>
</div>
<div class="card-body p-0">
<img src="https://via.placeholder.com/400x120" alt=""
class="img-fluid">
</div>
<div class="card-footer">
<a href="{{ route('catalog.category', ['slug' => $root-
>slug]) }}"
class="btn btn-dark">Перейти в раздел</a>
</div>
</div>
</div>
@endforeach
</div>
@endsection
@extends('layout.site')
@section('content')
<h1>{{ $category->name }}</h1>
<div class="row">
@foreach ($products as $product)
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">
<h4>{{ $product->name }}</h4>
</div>
<div class="card-body p-0">
<img src="https://via.placeholder.com/400x120" alt=""
class="img-fluid">
</div>
<div class="card-footer">
<a href="{{ route('catalog.product', ['slug' => $product-
>slug]) }}"
class="btn btn-dark">Перейти к товару</a>
</div>
</div>
</div>
@endforeach
</div>
@endsection
@extends('layout.site')
@section('content')
<h1>{{ $brand->name }}</h1>
<div class="row">
@foreach ($products as $product)
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">
<h4>{{ $product->name }}</h4>
</div>
<div class="card-body p-0">
<img src="https://via.placeholder.com/400x120" alt=""
class="img-fluid">
</div>
<div class="card-footer">
<a href="{{ route('catalog.product', ['slug' => $product-
>slug]) }}"
class="btn btn-dark">Перейти к товару</a>
</div>
</div>
</div>
@endforeach
</div>
@endsection
@extends('layout.site')
@section('content')
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h1>{{ $product->name }}</h1>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<img src="https://via.placeholder.com/400x400"
alt="" class="img-fluid">
</div>
<div class="col-md-6">
<p>Цена: {{ number_format($product->price, 2, '.', '')
}}</p>
</div>
</div>
<div class="row">
<div class="col-12">
<p class="mt-4 mb-0">{{ $product->content }}</p>
</div>
</div>
</div>
<div class="card-footer">
<div class="row">
<div class="col-md-6">
Категория:
<a href="{{ route('catalog.category', ['slug' => $product-
>category_slug]) }}">
{{ $product->category_name }}
</a>
</div>
<div class="col-md-6 text-right">
Бренд:
<a href="{{ route('catalog.brand', ['slug' => $product-
>brand_slug]) }}">
{{ $product->brand_name }}
</a>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@extends('layout.site')
@section('content')
<h1>{{ $category->name }}</h1>
<p>{{ $category->content }}</p>
<div class="row">
@foreach ($products as $product)
@include('catalog.part.product', ['product' => $product])
@endforeach
</div>
@endsection
@extends('layout.site')
@section('content')
<h1>{{ $brand->name }}</h1>
<div class="row">
@foreach ($products as $product)
@include('catalog.part.product', ['product' => $product])
@endforeach
</div>
@endsection
На странице каталога мы показываем список корневых категорий. Давайте проделаем аналогичную
операцию и с категориями — создадим
шаблон resources/views/catalog/part/category.blade.php и будем подключать его в
шаблоне resources/views/catalog/index.blade.php.
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">
<h3>{{ $category->name }}</h3>
</div>
<div class="card-body p-0">
<img src="https://via.placeholder.com/400x120" alt="" class="img-
fluid">
</div>
<div class="card-footer">
<a href="{{ route('catalog.category', ['slug' => $category->slug]) }}"
class="btn btn-dark">Перейти в раздел</a>
</div>
</div>
</div>
@extends('layout.site')
@section('content')
<h1>Каталог товаров</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque ducimus,
eligendi
exercitationem expedita, iure iusto laborum magnam qui quidem repellat
similique
tempora tempore ullam! Deserunt doloremque impedit quis repudiandae voluptas?
</p>
<h2>Разделы каталога</h2>
<div class="row">
@foreach ($roots as $root)
@include('catalog.part.category', ['category' => $root])
@endforeach
</div>
@endsection
Магазин на Laravel 7, часть 4. Работа с моделями,
создание связывающих методов моделей
Используем модели
Мы сейчас совсем не используем модели, а все необходимые данные получаем в контроллере.
Особенно некрасиво выглядит метод product() контроллера, где у нас большой и сложный запрос к
базе данных. Давайте упростим методы контроллера, и будем использовать модели по их прямому
назначению — для получения данных.
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Model;
namespace App\Http\Controllers;
use App\Brand;
use App\Category;
use App\Product;
use Illuminate\Http\Request;
class CatalogController extends Controller {
public function index() {
$roots = Category::where('parent_id', 0)->get();
return view('catalog.index', compact('roots', 'products'));
}
Связи моделей
Но мы пойдем дальше и будем использовать связи моделей:
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Model;
class Product extends Model {
/**
* Связь «товар принадлежит» таблицы `products` с таблицей `categories`
*/
public function category() {
return $this->belongsTo(Category::class);
}
/**
* Связь «товар принадлежит» таблицы `products` с таблицей `brands`
*/
public function brand() {
return $this->belongsTo(Brand::class);
}
}
namespace App\Http\Controllers;
use App\Brand;
use App\Category;
use App\Product;
use Illuminate\Http\Request;
@section('content')
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h1>{{ $product->name }}</h1>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<img src="https://via.placeholder.com/400x400"
alt="" class="img-fluid">
</div>
<div class="col-md-6">
<p>Цена: {{ number_format($product->price, 2, '.', '')
}}</p>
</div>
</div>
<div class="row">
<div class="col-12">
<p class="mt-4 mb-0">{{ $product->content }}</p>
</div>
</div>
</div>
<div class="card-footer">
<div class="row">
<div class="col-md-6">
@isset($product->category)
Категория:
<a href="{{ route('catalog.category', ['slug' => $product-
>category->slug]) }}">
{{ $product->category->name }}
</a>
@endisset
</div>
<div class="col-md-6 text-right">
@isset($product->brand)
Бренд:
<a href="{{ route('catalog.brand', ['slug' => $product-
>brand->slug]) }}">
{{ $product->brand->name }}
</a>
@endisset
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@extends('layout.site')
@section('content')
<h1>{{ $category->name }}</h1>
<p>{{ $category->content }}</p>
<div class="row">
@foreach ($category->products as $product)
@include('part.product')
@endforeach
</div>
@endsection
@extends('layout.site')
@section('content')
<h1>{{ $brand->name }}</h1>
<p>{{ $brand->content }}</p>
<div class="row">
@foreach ($brand->products as $product)
@include('part.product')
@endforeach
</div>
@endsection
Страница 404
Разместим картинку 404.jpg в директории public/img, где она будет доступна из веб. Создадим
директорию resources/views/errors и внутри нее — шаблон 404.blade.php.
@extends('layout.site', ['title' => 'Страница не найдена'])
@section('content')
<div class="row">
<div class="col-12">
<div class="card mt-4 mb-4">
<div class="card-header">
<h1>Страница не найдена</h1>
</div>
<div class="card-body">
<img src="{{ asset('img/404.jpg') }}" alt="" class="img-fluid">
</div>
<div class="card-footer">
<p>Запрошенная страница не найдена.</p>
</div>
</div>
</div>
</div>
@endsection
Контроллер BasketController
Создаем контроллер BasketController:
> php artisan make:controller BasketController
namespace App\Http\Controllers;
use Illuminate\Http\Request;
@extends('layout.site')
@section('content')
<h1>Ваша корзина</h1>
<p>Здесь будет содержимое корзины</p>
@endsection
@extends('layout.site')
@section('content')
<h1>Оформление заказа</h1>
<p>Здесь будет форма оформления</p>
@endsection
Route::get('/basket/index', 'BasketController@index')->name('basket.index');
Route::get('/basket/checkout', 'BasketController@checkout')-
>name('basket.checkout');
Модель Basket
Создаем модель Basket вместе с миграцией:
> php artisan make:model -m Basket
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
$table->foreign('basket_id')
->references('id')
->on('baskets')
->cascadeOnDelete();
$table->foreign('product_id')
->references('id')
->on('products')
->cascadeOnDelete();
});
}
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Model;
Добавление в корзину
Во-первых, нам нужен новый маршрут в файле web.php:
Route::post('/basket/add/{id}', 'BasketController@add')
->where('id', '[0-9]+')
->name('basket.add');
<div class="col-md-6">
<p>Цена: {{ number_format($product->price, 2, '.', '') }}</p>
<!-- Форма для добавления товара в корзину -->
<form action="{{ route('basket.add', ['id' => $product->id]) }}"
method="post" class="form-inline">
@csrf
<label for="input-quantity">Количество</label>
<input type="text" name="quantity" id="input-quantity" value="1"
class="form-control mx-2 w-25">
<button type="submit" class="btn btn-success">Добавить в корзину</button>
</form>
</div>
@endsection
После этого добавляем новый метод add() в контроллер:
namespace App\Http\Controllers;
use Illuminate\Http\Request;
/**
* Добавляет товар с идентификатором $id в корзину
*/
public function add(Request $request, $id) {
$basket_id = $request->cookie('basket_id');
$quantity = $request->input('quantity') ?? 1;
if (empty($basket_id)) {
// если корзина еще не существует — создаем объект
$basket = Basket::create();
// получаем идентификатор, чтобы записать в cookie
$basket_id = $basket->id;
} else {
// корзина уже существует, получаем объект корзины
$basket = Basket::findOrFail($basket_id);
// обновляем поле `updated_at` таблицы `baskets`
$basket->touch();
}
if ($basket->products->contains($id)) {
// если такой товар есть в корзине — изменяем кол-во
$pivotRow = $basket->products()->where('product_id', $id)->first()-
>pivot;
$quantity = $pivotRow->quantity + $quantity;
$pivotRow->update(['quantity' => $quantity]);
} else {
// если такого товара нет в корзине — добавляем его
$basket->products()->attach($id, ['quantity' => $quantity]);
}
// выполняем редирект обратно на страницу, где была нажата кнопка «В
корзину»
return back()->withCookie(cookie('basket_id', $basket_id, 525600));
}
}
Обновить значение pivot-поля quantity в промежуточной таблице basket_product можно с помощью
метода
$basket->products()->updateExistingPivot(
$product_id,
['quantity', $quantity]
);
Для удобства временно отключим шифрование cookie, чтобы иметь возможность видеть значение,
которые мы сохраняем в basket_id:
namespace App\Http\Middleware;
Но не забываем вернуть все обратно, после того, как закончим работать надо корзиной покупателя.
Содержимое корзины
Надо доработать метод index() контроллера и внести изменения в шаблон, который отвечает за показ
корзины:
namespace App\Http\Controllers;
use App\Basket;
use Illuminate\Http\Request;
@section('content')
<h1>Ваша корзина</h1>
@if (count($products))
@php
$basketCost = 0;
@endphp
<table class="table table-bordered">
<tr>
<th>№</th>
<th>Наименование</th>
<th>Цена</th>
<th>Кол-во</th>
<th>Стоимость</th>
</tr>
@foreach($products as $product)
@php
$itemPrice = $product->price;
$itemQuantity = $product->pivot->quantity;
$itemCost = $itemPrice * $itemQuantity;
$basketCost = $basketCost + $itemCost;
@endphp
<tr>
<td>{{ $loop->iteration }}</td>
<td>
<a href="{{ route('catalog.product', [$product->slug])
}}">{{ $product->name }}</a>
</td>
<td>{{ number_format($itemPrice, 2, '.', '') }}</td>
<td>
<i class="fas fa-minus-square"></i>
<span class="mx-1">{{ $itemQuantity }}</span>
<i class="fas fa-plus-square"></i>
</td>
<td>{{ number_format($itemCost, 2, '.', '') }}</td>
</tr>
@endforeach
<tr>
<th colspan="4" class="text-right">Итого</th>
<th>{{ number_format($basketCost, 2, '.', '') }}</th>
</tr>
</table>
@else
<p>Ваша корзина пуста</p>
@endif
@endsection
Магазин на Laravel 7, часть 6. Изменение количества
товара, удаление товара из корзины
Обновление корзины
Для каждого товара в корзине есть две кнопки — «Плюс» и «Минус», которые увеличивают или
уменьшают количество. Давайте добавим два маршрута, создадим две формы в шаблоне и реализуем
два метода в контроллере — plus() и minus().
Route::post('/basket/plus/{id}', 'BasketController@plus')
->where('id', '[0-9]+')
->name('basket.plus');
Route::post('/basket/minus/{id}', 'BasketController@minus')
->where('id', '[0-9]+')
->name('basket.minus');
<td>
<form action="{{ route('basket.minus', ['id' => $product->id]) }}"
method="post" class="d-inline">
@csrf
<button type="submit" class="m-0 p-0 border-0 bg-transparent">
<i class="fas fa-minus-square"></i>
</button>
</form>
<span class="mx-1">{{ $itemQuantity }}</span>
<form action="{{ route('basket.plus', ['id' => $product->id]) }}"
method="post" class="d-inline">
@csrf
<button type="submit" class="m-0 p-0 border-0 bg-transparent">
<i class="fas fa-plus-square"></i>
</button>
</form>
</td>
class BasketController extends Controller {
/**
* Увеличивает кол-во товара $id в корзине на единицу
*/
public function plus(Request $request, $id) {
$basket_id = $request->cookie('basket_id');
if (empty($basket_id)) {
abort(404);
}
$this->change($basket_id, $id, 1);
// выполняем редирект обратно на страницу корзины
return redirect()
->route('basket.index')
->withCookie(cookie('basket_id', $basket_id, 525600));
}
/**
* Уменьшает кол-во товара $id в корзине на единицу
*/
public function minus(Request $request, $id) {
$basket_id = $request->cookie('basket_id');
if (empty($basket_id)) {
abort(404);
}
$this->change($basket_id, $id, -1);
// выполняем редирект обратно на страницу корзины
return redirect()
->route('basket.index')
->withCookie(cookie('basket_id', $basket_id, 525600));
}
/**
* Изменяет кол-во товара $product_id на величину $count
*/
private function change($basket_id, $product_id, $count = 0) {
if ($count == 0) {
return;
}
$basket = Basket::findOrFail($basket_id);
// если товар есть в корзине — изменяем кол-во
if ($basket->products->contains($product_id)) {
$pivotRow = $basket->products()->where('product_id', $product_id)-
>first()->pivot;
$quantity = $pivotRow->quantity + $count;
if ($quantity > 0) {
// обновляем кол-во товара $product_id в корзине
$pivotRow->update(['quantity' => $quantity]);
// обновляем поле `updated_at` таблицы `baskets`
$basket->touch();
} else {
// кол-во равно нулю — удаляем товар из корзины
$pivotRow->delete();
}
}
}
}
Временна́я зона
Немного неудобно, что в базе данных поля created_at и updated_at сохраняются в UTC. Это можно
изменить в настройках приложения, в файле config/app.php. Или в методе boot() сервис-
провайдера App\Providers\AppServiceProvide.
return [
/* ... */
'timezone' => 'Europe/Moscow',
/* ... */
];
class AppServiceProvider extends ServiceProvider {
/* ... */
public function boot(){
date_default_timezone_set('Europe/Moscow');
}
/* ... */
}
Но мы этого делать не будем. В базе данных дату и время лучше хранить именно в UTC. А вот
пользователю можно показывать дату и время с учетом временной зоны. Для этого надо либо спросить у
пользователя временную зону (если он зарегистрирован) и сохранить в базу данных. Либо определить
зону без участия пользователя, по ip-адресу. И при показе даты и времени на сайте, преобразовывать
дату-время из БД с помощью аксессоров и мутаторов. Как это сделать с помощью пакета laravel-
geoip — хорошо описано здесь.
Задействуем модель
У меня опять получился большой и запутанный контроллер, а модель не используется вовсе. Давайте
это исправим и реализуем методы модели, которые позволят добавлять и удалять товар из корзины.
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
/**
* Увеличивает кол-во товара $id в корзине на величину $count
*/
public function increase($id, $count = 1) {
$this->change($id, $count);
}
/**
* Уменьшает кол-во товара $id в корзине на величину $count
*/
public function decrease($id, $count = 1) {
$this->change($id, -1 * $count);
}
/**
* Изменяет количество товара $id в корзине на величину $count;
* если товара еще нет в корзине — добавляет этот товар; $count
* может быть как положительным, так и отрицательным числом
*/
private function change($id, $count = 0) {
if ($count == 0) {
return;
}
// если товар есть в корзине — изменяем кол-во
if ($this->products->contains($id)) {
// получаем объект строки таблицы `basket_product`
$pivotRow = $this->products()->where('product_id', $id)->first()-
>pivot;
$quantity = $pivotRow->quantity + $count;
if ($quantity > 0) {
// обновляем количество товара $id в корзине
$pivotRow->update(['quantity' => $quantity]);
} else {
// кол-во равно нулю — удаляем товар из корзины
$pivotRow->delete();
}
} elseif ($count > 0) { // иначе — добавляем этот товар
$this->products()->attach($id, ['quantity' => $count]);
}
// обновляем поле `updated_at` таблицы `baskets`
$this->touch();
}
/**
* Удаляет товар с идентификатором $id из корзины покупателя
*/
public function remove($id) {
// удаляем товар из корзины (разрушаем связь)
$this->products()->detach($id);
// обновляем поле `updated_at` таблицы `baskets`
$this->touch();
}
}
<?php
namespace App\Http\Controllers;
use App\Basket;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\ModelNotFoundException;
private $basket;
/**
* Показывает корзину покупателя
*/
public function index() {
$products = $this->basket->products;
return view('basket.index', compact('products'));
}
/**
* Форма оформления заказа
*/
public function checkout() {
return view('basket.checkout');
}
/**
* Добавляет товар с идентификатором $id в корзину
*/
public function add(Request $request, $id) {
$quantity = $request->input('quantity') ?? 1;
$this->basket->increase($id, $quantity);
// выполняем редирект обратно на ту страницу,
// где была нажата кнопка «В корзину»
return back();
}
/**
* Увеличивает кол-во товара $id в корзине на единицу
*/
public function plus($id) {
$this->basket->increase($id);
// выполняем редирект обратно на страницу корзины
return redirect()->route('basket.index');
}
/**
* Уменьшает кол-во товара $id в корзине на единицу
*/
public function minus($id) {
$this->basket->decrease($id);
// выполняем редирект обратно на страницу корзины
return redirect()->route('basket.index');
}
/**
* Возвращает объект корзины; если не найден — создает новый
*/
private function getBasket() {
$basket_id = request()->cookie('basket_id');
if (!empty($basket_id)) {
try {
$this->basket = Basket::findOrFail($basket_id);
} catch (ModelNotFoundException $e) {
$this->basket = Basket::create();
}
} else {
$this->basket = Basket::create();
}
Cookie::queue('basket_id', $this->basket->id, 525600);
}
}
Удаление из корзины
Теперь в модели есть метод remove(), который позволяет удалить товар из корзины. Добавим два
новых маршрута, несколько форм в шаблон страницы корзины и реализуем два метода в контроллере
— remove() и clear().
Route::post('/basket/remove/{id}', 'BasketController@remove')
->where('id', '[0-9]+')
->name('basket.remove');
Route::post('/basket/clear', 'BasketController@clear')->name('basket.clear');
@extends('layout.site')
@section('content')
<h1>Ваша корзина</h1>
@if (count($products))
@php
$basketCost = 0;
@endphp
<form action="{{ route('basket.clear') }}" method="post" class="text-
right">
@csrf
<button type="submit" class="btn btn-outline-danger mb-4 mt-0">
Очистить корзину
</button>
</form>
<table class="table table-bordered">
<tr>
<th>№</th>
<th>Наименование</th>
<th>Цена</th>
<th>Кол-во</th>
<th>Стоимость</th>
<th></th>
</tr>
@foreach($products as $product)
@php
$itemPrice = $product->price;
$itemQuantity = $product->pivot->quantity;
$itemCost = $itemPrice * $itemQuantity;
$basketCost = $basketCost + $itemCost;
@endphp
<tr>
<td>{{ $loop->iteration }}</td>
<td>
<a href="{{ route('catalog.product', [$product->slug]) }}">
{{ $product->name }}
</a>
</td>
<td>{{ number_format($itemPrice, 2, '.', '') }}</td>
<td>
<form action="{{ route('basket.minus', ['id' => $product-
>id]) }}"
method="post" class="d-inline">
@csrf
<button type="submit" class="m-0 p-0 border-0 bg-
transparent">
<i class="fas fa-minus-square"></i>
</button>
</form>
<span class="mx-1">{{ $itemQuantity }}</span>
<form action="{{ route('basket.plus', ['id' => $product-
>id]) }}"
method="post" class="d-inline">
@csrf
<button type="submit" class="m-0 p-0 border-0 bg-
transparent">
<i class="fas fa-plus-square"></i>
</button>
</form>
</td>
<td>{{ number_format($itemCost, 2, '.', '') }}</td>
<td>
<form action="{{ route('basket.remove', ['id' => $product-
>id]) }}"
method="post">
@csrf
<button type="submit" class="m-0 p-0 border-0 bg-
transparent">
<i class="fas fa-trash-alt text-danger"></i>
</button>
</form>
</td>
</tr>
@endforeach
<tr>
<th colspan="4" class="text-right">Итого</th>
<th>{{ number_format($basketCost, 2, '.', '') }}</th>
<th></th>
</tr>
</table>
@else
<p>Ваша корзина пуста</p>
@endif
@endsection
class BasketController extends Controller {
/**
* Удаляет товар с идентификаторм $id из корзины
*/
public function remove($id) {
$this->basket->remove($id);
// выполняем редирект обратно на страницу корзины
return redirect()->route('basket.index');
}
/**
* Полностью очищает содержимое корзины покупателя
*/
public function clear() {
$this->basket->delete();
// выполняем редирект обратно на страницу корзины
return redirect()->route('basket.index');
}
}
Исправляем шаблоны
В layout-шаблоне надо добавить ссылку на страницу корзины. И нужна кнопка «Добавить в корзину» для
списка товаров.
use App\Brand;
use App\Category;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
/**
* Bootstrap services.
*
* @return void
*/
public function boot() {
View::composer('layout.part.roots', function($view) {
$view->with(['items' => Category::roots()]);
});
View::composer('layout.part.brands', function($view) {
$view->with(['items' => Brand::popular()]);
});
}
}
В модель Category добавляем метод roots(), а в модель Brand — метод popular():
namespace App;
use Illuminate\Database\Eloquent\Model;
/**
* Возвращает список корневых категорий каталога товаров
*/
public static function roots() {
return self::where('parent_id', 0)->get();
}
}
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
/**
* Возвращает список популярных брендов каталога товаров.
* Следовало бы отобрать бренды, товары которых продаются
* чаще всего. Но поскольку таких данных у нас еще нет,
* просто получаем 5 брендов с наибольшим кол-вом товаров
*/
public static function popular() {
return self::withCount('products')->orderByDesc('products_count')-
>limit(5)->get();
}
}
При желании, мы можем показывать в меню каталога не только корневые категории, но и дочерние. Для
этого добавим в модель Category новую связь таблицы categories с таблицей categories.
namespace App;
use Illuminate\Database\Eloquent\Model;
class Category extends Model {
/* ... */
/**
* Связь «один ко многим» таблицы `categories` с таблицей `categories`
*/
public function children() {
return $this->hasMany(Category::class, 'parent_id');
}
/**
* Возвращает список корневых категорий каталога товаров
*/
public static function roots() {
return self::where('parent_id', 0)->get();
}
}
Теперь в шаблоне roots.blade.php мы можем обратиться к виртуальному свойству children, чтобы
получить список дочерних категорий:
<h4>Разделы каталога</h4>
<div id="catalog-sidebar">
<ul>
@foreach($items as $item)
<li>
<a href="{{ route('catalog.category', ['slug' => $item->slug]) }}">{{
$item->name }}</a>
@if ($item->children->count())
<ul>
@foreach($item->children as $child)
<li>
<a href="{{ route('catalog.category', ['slug' => $child-
>slug]) }}">
{{ $child->name }}
</a>
</li>
@endforeach
</ul>
@endif
</li>
@endforeach
</ul>
</div>
Добавляем javascript
Давайте добавим возможность сворачивать и разворачивать меню каталога в сайдбаре. При загрузке
страницы видны будут только корневые разделы каталога. При клике по иконке с плюсом будут показаны
дочерние категории. При повторном клике по иконке (но уже с минусом) дочерние категории будут
скрыты.
<h4>Разделы каталога</h4>
<div id="catalog-sidebar">
<ul>
@foreach($items as $item)
<li>
<a href="{{ route('catalog.category', ['slug' => $item->slug]) }}">{{
$item->name }}</a>
@isset($item->children)
<span class="badge badge-dark">
<i class="fa fa-plus"></i> <!-- бейдж с плюсом или минусом -->
</span>
<ul>
@foreach($item->children as $child)
<li>
<a href="{{ route('catalog.category', ['slug' => $child-
>slug]) }}">
{{ $child->name }}
</a>
</li>
@endforeach
</ul>
@endisset
</li>
@endforeach
</ul>
</div>
Создадим файл site.js в директории public/js и добавим в него следующий код:
jQuery(document).ready(function($) {
$('#catalog-sidebar > ul ul').hide();
$('#catalog-sidebar .badge').on('click', function () {
var $badge = $(this);
var closed = $badge.siblings('ul') &&
!$badge.siblings('ul').is(':visible');
if (closed) {
$badge.siblings('ul').slideDown('normal', function () {
$badge.children('i').removeClass('fa-plus').addClass('fa-minus');
});
} else {
$badge.siblings('ul').slideUp('normal', function () {
$badge.children('i').removeClass('fa-minus').addClass('fa-plus');
});
}
});
});
Жадная загрузка
Сейчас для построения меню у нас выполняется пять запросов к базе данных, которые получают
корневые категории + дочерние категории для каждой корневой.
Но в данном случае вместо отложенной загрузки нужно использовать другой вариант загрузки связанных
данных — жадную загрузку. Этот вариант выбирает данные всегда, вне зависимости от того,
потребуются ли они в дальнейшем. Но мы точно знаем, что дочерние категории нам потребуются, так что
будем их выбирать из базы данных сразу.
/**
* Возвращает список корневых категорий каталога товаров
*/
public static function roots() {
return self::where('parent_id', 0)->with('children')->get();
}
}
Теперь вместо пяти запросов к базе данных будет выполнено только два:
Auth::routes();
Route::get('/home', 'HomeController@index')->name('home');
Вызов Auth::routes() добавляет сразу около дюжины маршрутов, посмотреть эти маршруты можно в
классе Laravel\Ui\AuthRouteMethods. Но мы изменим все имена маршрутов и добавим префикс,
чтобы они начинались с user. По умолчанию страница регистрации доступна по адресу /register, а
будет доступна по адресу /user/register. По умолчанию имя маршрута регистрации register, а у нас
имя будет user.register.
Route::name('user.')->prefix('user')->group(function () {
Auth::routes();
});
Route::get('/home', 'HomeController@index')->name('home');
Кроме контроллеров, перечисленных выше, будет добавлен контроллер HomeController, отвечающий
за показ страницы /home. После регистрации и аутентификации пользователи перенаправляются на эту
страницу. Нам этот контроллер не нужен, так что можно его удалить. Также можно удалить
шаблон home.blade.php и layout-шаблон app.blade.php — но пока не будем этого делать, нам нужно
кое-что забрать оттуда.
Сейчас пользователи уже могут регистрироваться и аутентифицироваться. Если открыть в браузере
страницу /user/register, то будет показана форма регистрации нового пользователя.
Но не хватает перевода на русский язык. Чтобы получить языковые файлы, установим пакет
/**
* Сразу после регистрации выполняем редирект и устанавливаем flash-сообщение
*/
protected function registered(Request $request, $user) {
return redirect()->route('user.index')
->with('success', 'Регистрация на сайте прошла успешно');
}
}
class LoginController extends Controller {
/* ... */
/**
* Сразу после входа выполняем редирект и устанавливаем flash-сообщение
*/
protected function authenticated(Request $request, $user) {
return redirect()->route('user.index')
->with('success', 'Вы успешно вошли в личный кабинет');
}
/**
* Сразу после выхода выполняем редирект и устанавливаем flash-сообщение
*/
protected function loggedOut(Request $request) {
return redirect()->route('user.login')
->with('success', 'Вы успешно вышли из личного кабинета');
}
}
<div class="row">
<div class="col-md-3">
@include('layout.part.roots')
@include('layout.part.brands')
</div>
<div class="col-md-9">
@if ($message = Session::get('success'))
<div class="alert alert-success alert-dismissible mt-4" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-
label="Закрыть">
<span aria-hidden="true">×</span>
</button>
{{ $message }}
</div>
@endif
@yield('content')
</div>
</div>
Теперь давайте исправим шаблоны страниц регистрации, аутентификации и восстановления пароля,
чтобы шапка сайта была как на всех прочих страницах. Для этого достаточно изменить
директиву @extends() в шаблонах директории views/auth, чтобы подключался наш layout-шаблон.
@extends('layout.site')
@section('content')
<div class="row">
<div class="col-12">
<!-- здесь форма регистрации -->
</div>
</div>
@endsection
@extends('layout.site')
@section('content')
<div class="row">
<div class="col-12">
<!-- здесь форма аутентификации -->
</div>
</div>
@endsection
@extends('layout.site')
@section('content')
<div class="row">
<div class="col-12">
<!-- здесь форма ввода адреса почты (восстановление пароля) -->
</div>
</div>
@endsection
@extends('layout.site')
@section('content')
<div class="row">
<div class="col-12">
<!-- здесь форма ввода нового пароля (восстановление пароля) -->
</div>
</div>
@endsection
Кроме того, надо везде в шаблонах заменить имена роутов в хелпере route(), потому что мы изменили
ранее имена register, login, logout на user.register, user.login, user.logout.
И последнее, что осталось сделать — создать страницу, куда пользователь будет попадать сразу после
регистрации или аутентификации. Добавим маршрут user.index, создадим
контроллер UserController и шаблон index.blade.php в директории views/user.
Route::name('user.')->prefix('user')->group(function () {
Route::get('index', 'UserController@index')->name('index');
Auth::routes();
});
Контроллер UserController удобнее всего сделать из HomeController — все экшены уже
защищены auth-посредником.
namespace App\Http\Controllers;
use Illuminate\Http\Request;
/**
* Show the application dashboard.
*
* @return \Illuminate\Contracts\Support\Renderable
*/
public function index() {
return view('user.index');
}
}
@extends('layout.site')
@section('content')
<h1>Личный кабинет</h1>
<p>Добро пожаловать, {{ auth()->user()->name }}</p>
<p>Это личный кабинет постоянного покупателя нашего интернет-магазина.</p>
<form action="{{ route('user.logout') }}" method="post">
@csrf
<button type="submit" class="btn btn-primary">Выйти</button>
</form>
@endsection
На страницу /user/index пользователь может попасть только в том случае, если он вошел в личный
кабинет. Если же нет — мы его отправляем на страницу аутентификации. Это можно сделать в классе
посредника Authenticate.
namespace App\Http\Middleware;
Есть еще один момент, о котором забыл упомянуть в предыдущей части. Если аутентифицированный
пользователь попробует перейти на страницу регистрации или на страницу восстановления пароля — он
будет перенаправлен на страницу /home. Это логично, потому что на странице регистрации или
восстановления пароля ему делать нечего. Страница /home нам не нужна, поэтому мы удалили
контроллер HomeController, шаблон views/home.blade.php и маршрут до этой страницы — но
редирект остался. Изменить это можно в посреднике (middleware) RedirectIfAuthenticated.
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Support\Facades\Auth;
class RedirectIfAuthenticated {
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null $guard
* @return mixed
*/
public function handle($request, Closure $next, $guard = null) {
if (Auth::guard($guard)->check()) {
// return redirect(RouteServiceProvider::HOME);
return redirect()->route('user.index');
}
return $next($request);
}
}
Администратор магазина
Хорошо, с этим разобрались, двигаемся дальше. Теперь нам нужна панель администратора магазина,
где можно будет управлять каталогом и обрабатывать заказы. Администратор — тоже пользователь
сайта и должен быть зарегистрирован и аутентифицирован. Чтобы различать обычных пользователй и
администратора, добавим еще одно поле admin в таблицу базы данных users.
> php artisan make:migration alter_users_table --table=users
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('admin');
});
}
}
> php artisan migrate
Теперь создадим контроллер, который будет отвечать за показ главной страницы панели управления:
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
/**
* Handle the incoming request.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function __invoke(Request $request) {
return view('admin.index');
}
}
Обратите внимание, что контроллер мы создали в директории app/Http/Controllers/Admin — это
значит, что при добавлении новых маршртутов, мы должны указать пространство имен (относительно
дефолтного App\Http\Controllers).
// это первый вариант указания пространства имен
Route::name('admin.')->prefix('admin')->group(function () {
Route::get('index', 'Admin\IndexController')->name('index');
});
// это второй вариант указания пространства имен
Route::namespace('Admin')->name('admin.')->prefix('admin')->group(function () {
Route::get('index', 'IndexController')->name('index');
});
Теперь нам нужны два шаблона — layout-шаблон admin.blade.php создадим в
директории views/layout, а шаблон главной страницы панели управления index.blade.php — в
директории views/admin.
<!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>
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
<link rel="stylesheet"
href="https://pro.fontawesome.com/releases/v5.10.0/css/all.css"
integrity="sha384-
AYmEC3Yw5cVb3ZcuHtOA93w35dYTsvhLPVnYs9eStHfGJvOvKxVfELGroGkvsg+p"
crossorigin="anonymous"/>
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
<script src="{{ asset('js/app.js') }}"></script>
<script src="{{ asset('js/admin.js') }}"></script>
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<!-- Бренд и кнопка «Гамбургер» -->
<a class="navbar-brand" href="{{ route('admin.index') }}">Панель
управления</a>
<button class="navbar-toggler" type="button" data-toggle="collapse"
data-target="#navbar-example" aria-controls="navbar-example"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<!-- Основная часть меню (может содержать ссылки, формы и прочее) -->
<div class="collapse navbar-collapse" id="navbar-example">
<!-- Этот блок расположен слева -->
<ul class="navbar-nav mr-auto">
<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>
<div class="row">
<div class="col-12">
@if ($message = Session::get('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">×</span>
</button>
{{ $message }}
</div>
@endif
@yield('content')
</div>
</div>
</div>
</body>
</html>
@extends('layout.admin')
@section('content')
<h1>Панель управления</h1>
<p>Добро пожаловать, {{ auth()->user()->name }}</p>
<p>Это панель управления для администратора интернет-магазина.</p>
<form action="{{ route('user.logout') }}" method="post">
@csrf
<button type="submit" class="btn btn-primary">Выйти</button>
</form>
@endsection
В конструкторе контроллера мы используем два посредника — auth и admin. Первый проверяет, что
пользователь аутентифицирован, а второй — что пользователь является администратором. Эти имена
должны быть определены в классе Kernel (файл app/Http/Kernel.php). Имя auth уже есть, так что
добавим admin.
namespace App\Http;
use Closure;
class Administrator {
public function handle($request, Closure $next, $guard = null) {
// если это не администратор — показываем 404 Not Found
if ( ! auth()->user()->admin) {
abort(404);
}
return $next($request);
}
}
Обычный пользователь после входа направляется на страницу /user/index. Но администратора мы
должны отправить на страницу /admin/index. Мы можем это сделать в контроллере LoginController.
class LoginController extends Controller {
/**
* Сразу после входа выполняем редирект и устанавливаем flash-сообщение
*/
protected function authenticated(Request $request, $user) {
$route = 'user.index';
$message = 'Вы успешно вошли в личный кабинет';
if ($user->admin) {
$route = 'admin.index';
$message = 'Вы успешно вошли в панель управления';
}
return redirect()->route($route)
->with('success', $message);
}
}
Для панели управления нам потребуется несколько контроллеров. Неудобно в конструкторе каждого из
них прописывать два посредника auth и admin. Давайте уберем конструктор в
контроллере Admin\IndexController, а посредники пропишем в маршруте.
// первый способ добавления посредников
Route::namespace('Admin')->name('admin.')->prefix('admin')->middleware('auth',
'admin')->group(function () {
Route::get('index', 'IndexController')->name('index');
});
// второй способ добавления посредников
Route::group([
'as' => 'admin.', // имя маршрута, например admin.index
'prefix' => 'admin', // префикс маршрута, например admin/index
'namespace' => 'Admin', // пространство имен контроллера
'middleware' => ['auth', 'admin'] // один или несколько посредников
], function () {
Route::get('index', 'IndexController')->name('index');
});
Товар в корзине
Неудобно, что корзина в правом верхнем углу всегда одинаковая — когда в ней есть товар(ы) и когда она
пустая. Давайте это исправим, чтобы корзина с товаром была другого цвета. Для этого в
классе ComposerServiceProvider будем передавать в layout-
шаблон site.blade.php переменную $positions.
class ComposerServiceProvider extends ServiceProvider {
/* ... */
public function boot() {
View::composer('layout.part.roots', function($view) {
$view->with(['items' => Category::roots()]);
});
View::composer('layout.part.brands', function($view) {
$view->with(['items' => Brand::popular()]);
});
View::composer('layout.site', function($view) {
$view->with(['positions' => Basket::getBasket()->products->count()]);
});
}
}
Для этого в модели Basket нам потребуется метод getBasket(), который будет возвращать объект
корзины:
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Cookie;
use App\Basket;
use Illuminate\Http\Request;
private $basket;
Теперь осталось только в layout-шаблоне показать кол-во позиций в корзине и выделить ее цветом:
Как-то не слишком удачно у меня получилось с корзиной. Каждый раз, когда новый посетитель приходит на
сайт — создается новая запись в таблице БД baskets. Хотя в реальности 90% посетителей не станут
покупателями. Так что добавил еще один метод getCount() в модель Basket.
class Basket extends Model {
/**
* Возвращает количество позиций в корзине
*/
public static function getCount() {
$basket_id = request()->cookie('basket_id');
if (empty($basket_id)) {
return 0;
}
return self::getBasket()->products->count();
}
}
class ComposerServiceProvider extends ServiceProvider {
/* ... */
public function boot() {
/* ... */
View::composer('layout.site', function($view) {
$view->with(['positions' => Basket::getCount()]);
});
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::dropIfExists('orders');
}
}
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::dropIfExists('order_items');
}
}
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Models\Product;
use Illuminate\Database\Eloquent\Model;
@section('content')
<h1 class="mb-4">Оформить заказ</h1>
<form method="post" action="{{ route('basket.saveorder') }}">
@csrf
<div class="form-group">
<input type="text" class="form-control" name="name" placeholder="Имя,
Фамилия"
required maxlength="255" value="{{ old('name') ?? '' }}">
</div>
<div class="form-group">
<input type="email" class="form-control" name="email"
placeholder="Адрес почты"
required maxlength="255" value="{{ old('email') ?? '' }}">
</div>
<div class="form-group">
<input type="text" class="form-control" name="phone" placeholder="Номер
телефона"
required maxlength="255" value="{{ old('phone') ?? '' }}">
</div>
<div class="form-group">
<input type="text" class="form-control" name="address"
placeholder="Адрес доставки"
required maxlength="255" value="{{ old('address') ?? '' }}">
</div>
<div class="form-group">
<textarea class="form-control" name="comment" placeholder="Комментарий"
maxlength="255" rows="2">{{ old('comment') ?? ''
}}</textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-success">Оформить</button>
</div>
</form>
@endsection
Добавим маршрут, куда будем отправлять данные формы, чтобы сохранить заказ:
Route::post('/basket/saveorder', 'BasketController@saveOrder')-
>name('basket.saveorder');
Теперь добавим в контроллер BasketController метод saveOrder():
class BasketController extends Controller {
/**
* Сохранение заказа в БД
*/
public function saveOrder(Request $request) {
// проверяем данные формы оформления
$this->validate($request, [
'name' => 'required|max:255',
'email' => 'required|email|max:255',
'phone' => 'required|max:255',
'address' => 'required|max:255',
]);
// уничтожаем корзину
$basket->delete();
return redirect()
->route('basket.success')
->with('success', 'Ваш заказ успешно размещен');
}
}
Мы здесь используем «Mass Assignment», так что в моделях Order и OrderItem должны указать, какие
поля допускается передавать в метод create() — иначе мы вообще не сможем сохранить данные
заказа в базу данных.
class Order extends Model {
protected $fillable = [
'user_id',
'name',
'email',
'phone',
'address',
'comment',
'amount',
];
/* ... */
}
class OrderItem extends Model {
protected $fillable = [
'product_id',
'name',
'price',
'quantity',
'cost',
];
/* ... */
}
Кроме того, нам потребуется метод getAmount() в модели Basket. Этот метод следовало бы создать
еще раньше, чтобы не рассчитывать стоимость корзины в шаблоне. Но лучше поздно, чем никогда.
class Basket extends Model {
/* ... */
public function getAmount() {
$amount = 0.0;
foreach ($this->products as $product) {
$amount = $amount + $product->price * $product->pivot->quantity;
}
return $amount;
}
/* ... */
}
Illuminate\Database\QueryException
SQLSTATE[42S22]: Column not found: 1054 Unknown column 'updated_at' in 'field list'
(SQL: insert into `order_items` (`name`, `price`, `quantity`, `cost`, `order_id`,
`updated_at`, `created_at`)
values ('Какое-то название товара каталога', 1412.00, 2, 2824, 1, 2020-10-11
08:14:21, 2020-10-11 08:14:21))
По умолчанию Laravel ожидает столбцы created_at и updated_at в таблице order_items. Давайте
сообщим фреймворку, что таких полей нет — для этого добавим в
модель OrderItem свойство $timestamps.
class OrderItem extends Model {
public $timestamps = false;
/* ... */
}
Если при заполнении формы покупатель допустил ошибки — ему снова будет показана форма. Причем,
форма уже будет заполнена введенными данными — потому что мы использовали хелпер old(). Но мы
не показываем сообщения об ошибках, которые были допущены — непонятно, что исправлять. Так что
отредактируем layout-шаблон site.blade.php.
<div class="col-md-9">
@if ($message = Session::get('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">×</span>
</button>
{{ $message }}
</div>
@endif
@if ($errors->any())
<div class="alert alert-danger alert-dismissible mt-0" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-
label="Закрыть">
<span aria-hidden="true">×</span>
</button>
<ul class="mb-0">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@yield('content')
</div>
Не слишком удачно, что после успешного размещения заказа покупатель попадает на страницу пустой
корзины. В принципе, flash-сообщение «Ваш заказ успешно размещен» сообщает, что все прошло
успешно. Но лучше, если мы отправим покупателя на отдельную страницу, где покажем состав и данные
заказа.
Route::get('/basket/success', 'BasketController@success')
->name('basket.success');
class BasketController extends Controller {
/**
* Сохранение заказа в БД
*/
public function saveOrder(Request $request) {
/* ... */
return redirect()
->route('basket.success')
->with('order_id', $order->id);
}
/**
* Сообщение об успешном оформлении заказа
*/
public function success(Request $request) {
if ($request->session()->exists('order_id')) {
// сюда покупатель попадает сразу после успешного оформления заказа
$order_id = $request->session()->pull('order_id');
$order = Order::findOrFail($order_id);
return view('basket.success', compact('order'));
} else {
// если покупатель попал сюда случайно, не после оформления заказа,
// ему здесь делать нечего — отправляем на страницу корзины
return redirect()->route('basket.index');
}
}
}
Шаблон view/basket/success.blade.php страницы, куда попадает покупатель сразу после
размещения заказа:
@extends('layout.site')
@section('content')
<h1>Заказ размещен</h1>
<p>Ваш заказ успешно размещен. Наш менеджер скоро свяжется с Вами для уточнения
деталей.</p>
<h2>Ваш заказ</h2>
<table class="table table-bordered">
<tr>
<th>№</th>
<th>Наименование</th>
<th>Цена</th>
<th>Кол-во</th>
<th>Стоимость</th>
</tr>
@foreach($order->items as $item)
<tr>
<td>{{ $loop->iteration }}</td>
<td>{{ $item->name }}</td>
<td>{{ number_format($item->price, 2, '.', '') }}</td>
<td>{{ $item->quantity }}</td>
<td>{{ number_format($item->cost, 2, '.', '') }}</td>
</tr>
@endforeach
<tr>
<th colspan="4" class="text-right">Итого</th>
<th>{{ number_format($order->amount, 2, '.', '') }}</th>
</tr>
</table>
<h2>Ваши данные</h2>
<p>Имя, фамилия: {{ $order->name }}</p>
<p>Адрес почты: <a href="mailto:{{ $order->email }}">{{ $order->email
}}</a></p>
<p>Номер телефона: {{ $order->phone }}</p>
<p>Адрес доставки: {{ $order->address }}</p>
@isset ($order->comment)
<p>Комментарий: {{ $order->comment }}</p>
@endisset
@endsection
Магазин на Laravel 7, часть 11. Панель управления,
просмотр и удаление категорий
Контроллер CategoryController
Теперь поработаем над панелью управления магазином. И начнем с категорий каталога товаров. Нам
нужен ресурсный контроллер, т.е. контроллер, который позволяет выполянить CRUD-операции над
категориями каталога. Создать такой контроллер можно с помощью artisan-команды.
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
Но можно поступить лучше — сразу указать модель, с которой будет работать контроллер:
use App\Http\Controllers\Controller;
use App\Models\Category;
use Illuminate\Http\Request;
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create() {
// ...
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request) {
// ...
}
/**
* Display the specified resource.
*
* @param \App\Models\Category $category
* @return \Illuminate\Http\Response
*/
public function show(Category $category) {
// ...
}
/**
* Show the form for editing the specified resource.
*
* @param \App\Models\Category $category
* @return \Illuminate\Http\Response
*/
public function edit(Category $category) {
// ...
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Category $category
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Category $category) {
// ...
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\Category $category
* @return \Illuminate\Http\Response
*/
public function destroy(Category $category) {
// ...
}
}
/*
Тип URI Действие Имя маршрута
--------------------------------------------------
GET /item index item.index
GET /item/create create item.create
POST /item store item.store
GET /item/{id} show item.show
GET /item/{id}/edit edit item.edit
PUT/PATCH /item/{id} update item.update
DELETE /item/{id} destroy item.destroy
*/
Route::resource('item', 'ItemController');
В нашем случае будет немного сложнее — потому что нужна группировка + защита маршртутов с
помощью middleware.
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');
});
@section('content')
<h1>Все категории</h1>
<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>
@include('admin.category.part.tree', ['items' => $roots, 'level' => -1])
</table>
@endsection
Шаблон tree.blade.php в директории views/admin/category/part подключает сам себя для
рекурсивного вывода всех категорий.
@if (count($items))
@php
$level++;
@endphp
@foreach ($items as $item)
<tr>
<td>
@if ($level)
{{ str_repeat('—', $level) }}
@endif
<a href="{{ route('admin.category.show', ['category' => $item->id])
}}"
style="font-weight:@if($level) normal @else bold @endif">
{{ $item->name }}
</a>
</td>
<td>{{ iconv_substr($item->content, 0, 150) }}</td>
<td>
<a href="{{ route('admin.category.edit', ['category' => $item->id])
}}">
<i class="far fa-edit"></i>
</a>
</td>
<td>
<form action="{{ route('admin.category.destroy', ['category' =>
$item->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>
@if ($item->children->count())
@include('admin.category.part.tree', ['items' => $item->children,
'level' => $level])
@endif
@endforeach
@endif
@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">
<img src="https://via.placeholder.com/600x200" alt="" class="img-
fluid">
</div>
</div>
@if ($category->children->count())
<p><strong>Дочерние категории</strong></p>
<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 ($category->children as $child)
<tr>
<td>{{ $loop->iteration }}</td>
<td>
<a href="{{ route('admin.category.show', ['category' =>
$child->id]) }}">
{{ $child->name }}
</a>
</td>
<td>{{ $child->slug }}</td>
<td>
<a href="{{ route('admin.category.edit', ['category' =>
$child->id]) }}">
<i class="far fa-edit"></i>
</a>
</td>
<td>
<form action="{{ route('admin.category.destroy',
['category' => $child->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>
@else
<p>Нет дочерних категорий</p>
@endif
<form method="post"
action="{{ route('admin.category.destroy', ['category' => $category->id])
}}">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">
Удалить категорию
</button>
</form>
@endsection
Реализуем метод destroy()
Перед удалением категории мы должны проверить, что категория не имеет дочерних категорий и не
содержит товаров.
@section('content')
<h1>Создание новой категории</h1>
<form method="post" action="{{ route('admin.category.store') }}"
enctype="multipart/form-data">
@csrf
<div class="form-group">
<input type="text" class="form-control" name="name"
placeholder="Наименование"
required maxlength="100" value="{{ old('name') ?? '' }}">
</div>
<div class="form-group">
<input type="text" class="form-control" name="slug" placeholder="ЧПУ
(на англ.)"
required maxlength="100" value="{{ old('slug') ?? '' }}">
</div>
<div class="form-group">
<select name="parent_id" class="form-control" title="Родитель">
<option value="0">Без родителя</option>
@foreach($parents as $parent)
<option value="{{ $parent->id }}">{{ $parent->name }}</option>
@if ($parent->children->count())
@foreach($parent->children as $child)
<option value="{{ $child->id }}">— {{ $child->name
}}</option>
@endforeach
@endif
@endforeach
</select>
</div>
<div class="form-group">
<textarea class="form-control" name="content" placeholder="Краткое
описание"
maxlength="200" rows="3">{{ old('content') ?? ''
}}</textarea>
</div>
<div class="form-group">
<input type="file" class="form-control-file" name="image"
accept="image/png, image/jpeg">
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
@endsection
@extends('layout.admin')
@section('content')
<h1>Редактирование категории</h1>
<form method="post" enctype="multipart/form-data"
action="{{ route('admin.category.update', ['category' => $category->id])
}}">
@method('PUT')
@csrf
<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="title" placeholder="ЧПУ
(на англ.)"
required maxlength="100" value="{{ old('slug') ?? $category-
>slug }}">
</div>
<div class="form-group">
<select name="parent_id" class="form-control" title="Родитель">
<option value="0">Без родителя</option>
@if (count($parents))
@include('admin.category.part.branch', ['items' => $parents,
'level' => -1])
@endif
</select>
</div>
<div class="form-group">
<textarea class="form-control" name="content" placeholder="Краткое
описание"
maxlength="200" rows="3">{{ 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>
</form>
@endsection
Для показа списка выбора родителя, мы рекурсивно подключаем
шаблон iews/admin/catgory/part/branch.blade.php:
@php($level++)
@foreach($items as $item)
<option value="{{ $item->id }}">
@if ($level) {!! str_repeat(' ', $level) !!} @endif {{
$item->name }}
</option>
@if ($item->children->count())
@include('admin.category.part.branch', ['items' => $item->children, 'level'
=> $level])
@endif
@endforeach
Оптимизация шаблонов
Не слишком удачно, что у нас две почти одинаковые формы в двух шаблонах. Давайте создадим еще
один шаблон form.blade.php в директории view/admin/category/part.
@csrf
<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>
@if (count($parents))
@include('admin.category.part.branch', ['items' => $parents, 'level' =>
-1])
@endif
</select>
</div>
<div class="form-group">
<textarea class="form-control" name="content" placeholder="Краткое описание"
maxlength="200" rows="3">{{ 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>
@php($level++)
@foreach($items as $item)
<option value="{{ $item->id }}" @if ($item->id == $parent_id) selected @endif>
@if ($level) {!! str_repeat(' ', $level) !!} @endif {{
$item->name }}
</option>
@if ($item->children->count())
@include('admin.category.part.branch', ['items' => $item->children, 'level'
=> $level])
@endif
@endforeach
Тогда два шаблона для создания новой категории и редактирования существующей будут совсем
маленькими:
@extends('layout.admin')
@section('content')
<h1>Создание новой категории</h1>
<form method="post" action="{{ route('admin.category.store') }}"
enctype="multipart/form-data">
@include('admin.category.part.form')
</form>
@endsection
@extends('layout.admin')
@section('content')
<h1>Редактирование категории</h1>
<form method="post" enctype="multipart/form-data"
action="{{ route('admin.category.update', ['category' => $category->id])
}}">
@method('PUT')
@include('admin.category.part.form')
</form>
@endsection
Методы store() и update()
Поскольку мы планируем использовать «mass assignment», нужно добавить свойство $fillable в
модель Category:
class Category extends Model {
protected $fillable = [
'parent_id',
'name',
'slug',
'content',
'image',
];
/* ... */
}
А методы store() и update() будут достаточно простыми, пока не займемся загрузкой и обрезкой
изображений.
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|alpha_dash',
'image' => 'mimes:jpeg,jpg,png|max:5000'
]);
// проверка пройдена, сохраняем категорию
$category = Category::create($request->all());
return redirect()
->route('admin.category.show', ['category' => $category->id])
->with('success', 'Новая категория успешно создана');
}
/* ... */
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|alpha_dash',
'image' => 'mimes:jpeg,jpg,png|max:5000'
]);
// проверка пройдена, обновляем категорию
$category->update($request->all());
return redirect()
->route('admin.category.show', ['category' => $category->id])
->with('success', 'Категория была успешно исправлена');
}
/* ... */
}
Нехорошо заставлять администратора заполнчть поле «ЧПУ (англ)» — давайте сами заполнять это поле
на основе названия категории. Для этого в файл public/js/admin.js добавляем следующий код.
jQuery(document).ready(function($) {
$('input[name="name"]').on('input', function() {
var map = {
'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo',
'Ж': 'Zh',
'З': 'Z', 'И': 'I', 'Й': 'J', 'К': 'K', 'Л': 'L', 'М': 'M', 'Н': 'N',
'О': 'O',
'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', 'Ф': 'F', 'Х': 'H',
'Ц': 'C',
'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sh', 'Ъ': '', 'Ы': 'Y', 'Ь': '', 'Э': 'E',
'Ю': 'Yu',
'Я': 'Ya',
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
'ж': 'zh',
'з': 'z', 'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n',
'о': 'o',
'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'h',
'ц': 'c',
'ч': 'ch', 'ш': 'sh', 'щ': 'sh', 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e',
'ю': 'yu',
'я': 'ya',
};
var text = $(this).val();
for (var k in map) {
text = text.replace(RegExp(k, 'g'), map[k]);
}
text = text.replace(/[^- _a-zA-Z0-9]/g, '');
text = text.replace(/\s+/g, '-');
text = text.replace(/-+/g, '-');
$('input[name="slug"]').val(text);
});
});
Небольшое отступление
Прежде, чем двигаться дальше, давайте посмотрим, как в Laravel организовано хранение и загрузка
фйалов. Это нам потребуется, чтобы организовать загрузку и хранение изображений для категорий,
брендов и товаров.
Файловое хранилище
Сразу после установки Laravel доступны диски local и public, использующие драйвер local. Для
диска local место хранения — директория storage/app, для диска public место хранения —
директория storage/app/public. Диск local является диском по умолчанию, это задается в файле
настроек config/filesystems.php.
return [
/* ... */
'default' => env('FILESYSTEM_DRIVER', 'local'),
/* ... */
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
],
],
/* ... */
'links' => [
public_path('storage') => storage_path('app/public'),
],
/* ... */
];
Чтобы сделать файлы диска public доступными через веб, надо создать символьную ссылку
из public/storage на storage/app/public. Директория public проекта Laravel — является корневой
директорией сервера, поэтому файл storage/app/public/image.jpg будет доступен через веб
как http://server.com/storage/image.jpg.
> php artisan storage:link
Какие символьные ссылки создавать — задается в файле конфигурации, см. выше. Когда файл сохранён
на диске и создана символьная ссылка, можно создать URL к файлу с помощью хелпера asset() или
метода url() фасада Storage:
<img src="{{ asset('storage/images/image.jpg') }}" alt="" />
<img src="{{ Storage::disk('public')->url('images/image.jpg') }}" alt="" />
При использовании диска local при вызове метода Storage::url() будет возвращен URL
вида /storage/images/image.jpg.
При использовании драйвера local все файловые операции выполняются относительно
директории root, определенной в конфигурационном файле. Для диска local директория root —
это storage/app, для диска public директория root — это storage/app/public.
// файл будет сохранен в storage/app/data/file.txt
Storage::disk('local')->put('data/file.txt', 'Some file content');
Загрузка файлов
В Laravel очень просто сохранять загружаемые файлы методом store() на экземпляре загружаемого
файла:
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
Для начала просто сохраним изображение на диск и запишем имя файла изображения в базу данных:
Обрезка изображения
Изображение может быть слишком большим и не подходить для использования на сайте из-за
неподходящих пропорций. Мы будем создавать из исходного изображения еще два — размером
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;
private $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]
@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
use Illuminate\Foundation\Http\FormRequest;
/**
* 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;
Сообщения об ошибках
Давайте приведем в порядок сообщения об ошибках — для этого открываем на редактирование
файл 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 7, часть 14. Панель управления,
доп.проверка для родителя категории
Осталась еще одна проблема — нет проверки, что при редактировании категории в качестве родителя не
будет выбана эта же категория или один из ее потомков. Здесь простыми правилами валидации не
обойтись — потребуется класс, который реализует
интерфейс Illuminate\Contracts\Validation\Rule. Давайте создадим такой класс с помощью
artisan-команды.
> php artisan make:rule CategoryParent
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param mixed $value
* @return bool
*/
public function passes($attribute, $value) {
// ...
}
/**
* Get the validation error message.
*
* @return string
*/
public function message() {
return 'The validation error message.';
}
}
Кроме того, нам потребуются два метода в модели Category, которые позволят определить, какие
категории не могут быть родителями для текущей категории.
class Category extends Model {
/**
* Проверяет, что переданный идентификатор id может быть родителем
* этой категории; что категорию не пытаются поместить внутрь себя
*/
public function validParent($id) {
$id = (integer)$id;
// получаем идентификаторы всех потомков текущей категории
$ids = $this->getAllChildren($this->id);
$ids[] = $this->id;
return ! in_array($id, $ids);
}
/**
* Возвращает всех потомков категории с идентификатором $id
*/
public function getAllChildren($id) {
// получаем прямых потомков категории с идентификатором $id
$children = self::where('parent_id', $id)->with('children')->get();
$ids = [];
foreach ($children as $child) {
$ids[] = $child->id;
// для каждого прямого потомка получаем его прямых потомков
if ($child->children->count()) {
$ids = array_merge($ids, $this->getAllChildren($child->id));
}
}
return $ids;
}
}
Теперь в классе CategoryCatalogRequest, где мы задавали правила валидации, добавим еще одно
правило:
namespace App\Http\Requests;
use App\Rules\CategoryParent;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Rule;
use App\Models\Category;
private $category;
/**
* Create a new rule instance.
*
* @return void
*/
public function __construct(Category $category) {
$this->category = $category;
}
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param mixed $value
* @return bool
*/
public function passes($attribute, $value) {
return $this->category->validParent($value);
}
/**
* Get the validation error message.
*
* @return string
*/
public function message() {
return trans('validation.custom.parent_id.invalid');
}
}
В файл переводе validation.php в директории resources/lang/ru добавим строки перевода для
сообщения об ошибке.
return [
/* ... */
'uuid' => 'Поле :attribute должно быть корректным UUID.',
'parent_id' => 'Поле «:attribute» имеет недопустимое значение',
'custom' => [
/* ... */
'parent_id' => [
'required' => 'Поле «:attribute» обязательно для заполнения',
'regex' => 'Поле «:attribute» должно быть целым положительным числом',
'invalid' => 'Категорию нелья поместить внутрь самой себя',
]
],
'attributes' => [
'name' => 'Имя, Фамилия',
'slug' => 'ЧПУ (англ)',
'email' => 'Адрес почты',
'address' => 'Адрес доставки',
'phone' => 'Номер телефона',
/* ... */
'parent_id' => 'Родитель',
],
];
@section('content')
<h1>Все категории</h1>
<!-- ..... -->
@endsection
@extends('layout.admin', ['title' => 'Просмотр категории'])
@section('content')
<h1>Просмотр категории</h1>
<!-- ..... -->
@endsection
@extends('layout.admin', ['title' => 'Создание категории'])
@section('content')
<h1>Создание категории</h1>
<!-- ..... -->
@endsection
@extends('layout.admin', ['title' => 'Редактирование категории'])
@section('content')
<h1>Редактирование категории</h1>
<!-- ..... -->
@endsection
Создание, редактирование и удаление бренда
Нам опять нужен ресурсный контроллер, который позволит выполянить CRUD-операции над брендами.
use App\Helpers\ImageSaver;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use Illuminate\Http\Request;
private $imageSaver;
/**
* Показывает список всех брендов
*
* @return \Illuminate\Http\Response
*/
public function index() {
$brands = Brand::all();
return view('admin.brand.index', compact('brands'));
}
/**
* Показывает форму для создания бренда
*
* @return \Illuminate\Http\Response
*/
public function create() {
return view('admin.brand.create');
}
/**
* Сохраняет новый бренд в базу данных
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request) {
$data = $request->all();
$data['image'] = $this->imageSaver->upload($request, null, 'brand');
$brand = Brand::create($data);
return redirect()
->route('admin.brand.show', ['brand' => $brand->id])
->with('success', 'Новый бренд успешно создан');
}
/**
* Показывает страницу бренда
*
* @param \App\Models\Brand $brand
* @return \Illuminate\Http\Response
*/
public function show(Brand $brand) {
return view('admin.brand.show', compact('brand'));
}
/**
* Показывает форму для редактирования бренда
*
* @param \App\Models\Brand $brand
* @return \Illuminate\Http\Response
*/
public function edit(Brand $brand) {
return view('admin.brand.edit',compact('brand'));
}
/**
* Обновляет бренд (запись в таблице БД)
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Brand $brand
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Brand $brand) {
$data = $request->all();
$data['image'] = $this->imageSaver->upload($request, $brand, 'brand');
$brand->update($data);
return redirect()
->route('admin.brand.show', ['brand' => $brand->id])
->with('success', 'Бренд был успешно отредактирован');
}
/**
* Удаляет бренд (запись в таблице БД)
*
* @param \App\Models\Brand $brand
* @return \Illuminate\Http\Response
*/
public function destroy(Brand $brand) {
if ($brand->products->count()) {
return back()->withErrors('Нельзя удалить бренд, у которого есть
товары');
}
$this->imageSaver->remove($brand, 'brand');
$brand->delete();
return redirect()
->route('admin.brand.index')
->with('success', 'Бренд каталога успешно удален');
}
}
Добавляем маршруты в файл routes/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');
});
Создаем шаблон для просмотра списка брендов views/admin/brand/index.blade.php:
@extends('layout.admin', ['title' => 'Все бренды каталога'])
@section('content')
<h1>Все бренды каталога</h1>
<a href="{{ route('admin.brand.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($brands as $brand)
<tr>
<td>
<a href="{{ route('admin.brand.show', ['brand' => $brand->id])
}}">
{{ $brand->name }}
</a>
</td>
<td>{{ iconv_substr($brand->content, 0, 150) }}</td>
<td>
<a href="{{ route('admin.brand.edit', ['brand' => $brand->id])
}}">
<i class="far fa-edit"></i>
</a>
</td>
<td>
<form action="{{ route('admin.brand.destroy', ['brand' =>
$brand->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>
@endsection
Создаем шаблон для просмотра отдельного бренда views/admin/brand/show.blade.php:
@extends('layout.admin', ['title' => 'Просмотр бренда'])
@section('content')
<h1>Просмотр бренда</h1>
<div class="row">
<div class="col-md-6">
<p><strong>Название:</strong> {{ $brand->name }}</p>
<p><strong>ЧПУ (англ):</strong> {{ $brand->slug }}</p>
<p><strong>Краткое описание</strong></p>
@isset($brand->content)
<p>{{ $brand->content }}</p>
@else
<p>Описание отсутствует</p>
@endisset
</div>
<div class="col-md-6">
@php
if ($brand->image) {
// $url = url('storage/catalog/brand/source/' . $brand->image);
$url = Storage::disk('public')->url('catalog/brand/image/' .
$brand->image);
} else {
// $url = Storage::disk('public')->url('catalog/brand/image/' .
$brand->image);
$url = Storage::disk('public')-
>url('catalog/brand/image/default.jpg');
}
@endphp
<img src="{{ $url }}" alt="" class="img-fluid">
</div>
</div>
<a href="{{ route('admin.brand.edit', ['brand' => $brand->id]) }}"
class="btn btn-success">
Редактировать бренд
</a>
<form method="post" class="d-inline"
action="{{ route('admin.brand.destroy', ['brand' => $brand->id]) }}">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">
Удалить бренд
</button>
</form>
@endsection
protected $fillable = [
'name',
'slug',
'content',
'image',
];
/* ... */
}
@extends('layout.admin', ['title' => 'Создание нового бренда'])
@section('content')
<h1>Создание нового бренда</h1>
<form method="post" action="{{ route('admin.brand.store') }}"
enctype="multipart/form-data">
@include('admin.brand.part.form')
</form>
@endsection
@extends('layout.admin', ['title' => 'Редактирование бренда'])
@section('content')
<h1>Редактирование бренда</h1>
<form method="post" enctype="multipart/form-data"
action="{{ route('admin.brand.update', ['brand' => $brand->id]) }}">
@method('PUT')
@include('admin.brand.part.form')
</form>
@endsection
@csrf
<div class="form-group">
<input type="text" class="form-control" name="name" placeholder="Наименование"
required maxlength="100" value="{{ old('name') ?? $brand->name ?? ''
}}">
</div>
<div class="form-group">
<input type="text" class="form-control" name="slug" placeholder="ЧПУ (на
англ.)"
required maxlength="100" value="{{ old('slug') ?? $brand->slug ?? ''
}}">
</div>
<div class="form-group">
<textarea class="form-control" name="content" placeholder="Краткое описание"
maxlength="200"
rows="3">{{ old('content') ?? $brand->content ?? '' }}</textarea>
</div>
<div class="form-group">
<input type="file" class="form-control-file" name="image" accept="image/png,
image/jpeg">
</div>
@isset($brand->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>
Валидация данных
У нас много общих правил валидации для категории, бренда, товара. Давайте создадим
класс CatalogRequest, который будет родителем
для CategoryCatalogRequest, BrandCatalogRequest и ProductCatalogRequest:
<?php
namespace App\Http\Requests;
use App\Rules\CategoryParent;
use Illuminate\Foundation\Http\FormRequest;
/**
* С какой сущностью сейчас работаем: категория, бренд, товар
* @var array
*/
protected $entity = [];
/**
* Задает дефолтные правила для проверки данных при добавлении
* категории, бренда или товара
*/
protected function createItem() {
return [
'name' => [
'required',
'max:100',
],
'slug' => [
'required',
'max:100',
'unique:'.$this->entity['table'].',slug',
'regex:~^[-_a-z0-9]+$~i',
],
'image' => [
'mimes:jpeg,jpg,png',
'max:5000'
],
];
}
/**
* Задает дефолтные правила для проверки данных при обновлении
* категории, бренда или товара
*/
protected function updateItem() {
// получаем объект модели из маршрута: admin/entity/{entity}
$model = $this->route($this->entity['name']);
return [
'name' => [
'required',
'max:100',
],
'slug' => [
'required',
'max:100',
// проверка на уникальность slug, исключая эту сущность по
идентифкатору
'unique:'.$this->entity['table'].',slug,'.$model->id.',id',
'regex:~^[-_a-z0-9]+$~i',
],
'image' => [
'mimes:jpeg,jpg,png',
'max:5000'
],
];
}
}
Метод createItem() возвращает правила валидации, общие для добавления-редактирования
категории, бренда и товара. Метод updateItem() возвращает правила валидации, общие для
обновления категории, бренда и товара. А в дочерних классах будем эти правила дополнять или
переопределять.
namespace App\Http\Requests;
use App\Rules\CategoryParent;
/**
* С какой сущностью сейчас работаем (категория каталога)
* @var array
*/
protected $entity = [
'name' => 'category',
'table' => 'categories'
];
/**
* Объединяет дефолтные правила и правила, специфичные для категории
* для проверки данных при добавлении новой категории
*/
protected function createItem() {
$rules = [
'parent_id' => [
'required',
'regex:~^[0-9]+$~',
],
];
return array_merge(parent::createItem(), $rules);
}
/**
* Объединяет дефолтные правила и правила, специфичные для категории
* для проверки данных при обновлении существующей категории
*/
protected function updateItem() {
// получаем объект модели категории из маршрута: admin/category/{category}
$model = $this->route('category');
$rules = [
'parent_id' => [
'required',
'regex:~^[0-9]+$~',
// задаем правило, чтобы категорию нельзя было поместить внутрь
себя
new CategoryParent($model)
],
];
return array_merge(parent::updateItem(), $rules);
}
}
У класса-потомка тоже есть методы createItem() и updateItem(), где можно добавить новые правила
и переопределить дефолтные. Теперь нам нужен класс для проверки данных добавления-
редактирования бренда каталога.
> php artisan make:request BrandCatalogRequest
namespace App\Http\Requests;
/**
* Объединяет дефолтные правила и правила, специфичные для бренда
* для проверки данных при добавлении нового бренда
*/
protected function createItem() {
$rules = [];
return array_merge(parent::createItem(), $rules);
}
/**
* Объединяет дефолтные правила и правила, специфичные для бренда
* для проверки данных при обновлении существующего бренда
*/
protected function updateItem() {
$rules = [];
return array_merge(parent::updateItem(), $rules);
}
}
Как видите, для бренда не пришлось добавлять и переопределять правила — вполне подошли те,
которые определены в CatalogRequest. Осталось только изменить «type hinting» для
параметра $request в контроллере BrandController:
namespace App\Http\Controllers\Admin;
use App\Helpers\ImageSaver;
use App\Http\Controllers\Controller;
use App\Http\Requests\BrandCatalogRequest;
use App\Models\Brand;
Давайте это исправим — для этого создадим еще два метода в классе модели:
/**
* Возвращает список всех категорий каталога в виде дерева
*/
public static function hierarchy() {
return self::where('parent_id', 0)->with('descendants')->get();
}
}
Внесем исправления в шаблоны, чтобы обращаться к виртуальному
свойству descendants всместо children:
<ul>
@foreach($items as $item)
<li>
<a href="{{ route('catalog.category', ['slug' => $item->slug]) }}">{{
$item->name }}</a>
@if ($item->descendants->count())
<span class="badge badge-dark">
<i class="fa fa-plus"></i>
</span>
@include('layout.part.branch', ['items' => $item->descendants])
@endif
</li>
@endforeach
</ul>
И в классе ComposerServiceProvider будем получать категории каталога для меню от
метода hierarchy():
class ComposerServiceProvider extends ServiceProvider {
/* ... */
public function boot() {
View::composer('layout.part.roots', function($view) {
$view->with(['items' => Category::hierarchy()]);
});
/* ... */
}
}
SELECT * FROM `categories` WHERE `parent_id` = 0
SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (1, 2, 3, 4)
SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (5, 6, 7, 9, 10)
SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (8, 11, 12)
Получилось уже намного лучше, но есть еще один способ, позволяющий обойтись всего одним запросом.
Мы получаем все категории с помощью метода all() модели, а в шаблонах на каждом уровне отбираем
только нужные.
class ComposerServiceProvider extends ServiceProvider {
/* ... */
public function boot() {
View::composer('layout.part.roots', function($view) {
$view->with(['items' => Category::all()]);
});
/* ... */
}
}
<h4>Разделы каталога</h4>
<div id="catalog-sidebar">
@include('layout.part.branch', ['parent' => 0])
</div>
<ul>
@foreach ($items->where('parent_id', $parent) as $item)
<li>
<a href="{{ route('catalog.category', ['slug' => $item->slug]) }}">{{
$item->name }}</a>
@if (count($items->where('parent_id', $item->id)))
<span class="badge badge-dark">
<i class="fa fa-plus"></i>
</span>
@include('layout.part.branch', ['parent' => $item->id])
@endif
</li>
@endforeach
</ul>
Переменная $items, которая содержит все категории каталога, доступна всегда в
шаблоне branch.blade.php, потому что передается из родительского шаблона. И мы всегда передаем в
шаблон branch.blade.php переменную $parent, которая содержит идентификатор родителя. Эту
переменную мы используем, чтобы с помощью метода where() отобрать только категории текущего
уровня.
SELECT * FROM `categories`
@section('content')
<h1>Все категории</h1>
<a href="{{ route('admin.category.create') }}" class="btn btn-success mb-4">
Создать категорию
</a>
<table class="table table-bordered">
<tr>
<!-- ..... -->
</tr>
@include('admin.category.part.tree', ['level' => -1, 'parent' => 0])
</table>
@endsection
Шаблон views/admin/category/part/tree.blade.php:
@php($level++)
@foreach ($items->where('parent_id', $parent) as $item)
<tr>
<!-- ..... -->
</tr>
@if (count($items->where('parent_id', $parent)))
@include('admin.category.part.tree', ['level' => $level, 'parent' => $item-
>id])
@endif
@endforeach
Шаблон views/admin/category/part/form.blade.php:
<!-- ..... -->
<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>
@if (count($items))
@include('admin.category.part.branch', ['level' => -1, 'parent' => 0])
@endif
</select>
</div>
<!-- ..... -->
Шаблон views/admin/category/part/branch.blade.php:
@php($level++)
@foreach ($items->where('parent_id', $parent) as $item)
<option value="{{ $item->id }}" @if ($item->id == $parent_id) selected @endif>
@if ($level) {!! str_repeat(' ', $level) !!} @endif {{
$item->name }}
</option>
@if (count($items->where('parent_id', $parent)))
@include('admin.category.part.branch', ['level' => $level, 'parent' =>
$item->id])
@endif
@endforeach
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;
private $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++)
@foreach ($items->where('parent_id', $parent) as $item)
<option value="{{ $item->id }}" @if ($item->id == $category_id) selected
@endif>
@if ($level) {!! str_repeat(' ', $level) !!} @endif {{
$item->name }}
</option>
@if (count($items->where('parent_id', $parent)))
@include('admin.product.part.branch', ['level' => $level, 'parent' =>
$item->id])
@endif
@endforeach
Работать в админке сейчас не очень удобно, потому что список товаров может содержать сотни и тысячи
элементов. Так что найти нужный для редактирования товар довольно проблематично — нужна какая-то
навигация. Давайте на странице списка товаров будем показывать корневые категории. И создадим еще
один метод в контроллере, который будет показывать товары выбранной категории. Таким образом, у
нас будет навигация, которая позволит перейти внутрь корневой категории, оттуда перейти в дочернюю
категорию этой корневой и так далее.
/**
* Показывает товары выбранной категории
*
* @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
@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
/**
* Объединяет дефолтные правила и правила, специфичные для товара
* для проверки данных при добавлении нового товара
*/
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);
}
}
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;
Для товара обязательно должны быть заполнены поля «Категория» и «Бренд». Но сообщение об ошибке
совершенно не информативно.
Зададим свои сообщения в файле 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' => 'Бренд',
],
]
Контроллер OrderController
Создаем ресурсный контроллер:
use App\Http\Controllers\Controller;
use App\Models\Order;
use Illuminate\Http\Request;
/**
* Просмотр отдельного заказа
*
* @param \App\Models\Order $order
* @return \Illuminate\Http\Response
*/
public function show(Order $order) {
return view('admin.order.show', compact('order'));
}
/**
* Форма редактирования заказа
*
* @param \App\Models\Order $order
* @return \Illuminate\Http\Response
*/
public function edit(Order $order) {
return view('admin.order.edit', compact('order'));
}
/**
* Обновляет заказ покупателя
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Order $order
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Order $order) {
$order->update($request->all());
return redirect()
->route('admin.order.show', ['order' => $order->id])
->with('success', 'Заказ был успешно обновлен');
}
}
@section('content')
<h1>Все заказы</h1>
@section('content')
<h1>Данные по заказу № {{ $order->id }}</h1>
Временна́я зона
Есть проблема с показом времени заказа при просмотре списка всех заказов. Это потому, что в базе
данных created_at и updated_at хранятся в UTC. Давайте добавим accessor в модель Order и будем
преобразовывать в московское время.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Преобразует дату и время обновления заказа из UTC в Europe/Moscow
*
* @param $value
* @return \Carbon\Carbon|false
*/
public function getUpdatedAtAttribute($value) {
return Carbon::createFromFormat('Y-m-d H:i:s', $value)-
>timezone('Europe/Moscow');
}
}
Статус заказа
Создадим миграцию, чтобы добавить поле status в таблицу базы данных orders.
> php artisan make:migration alter_orders_table --table=orders
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('status');
});
}
}
> php artisan migrate
Статусов у нас будет пять, добавим их в модель Order. Заодно добавим поле status в
массив $fillable.
class Order extends Model {
protected $fillable = [
'user_id',
'name',
'email',
'phone',
'address',
'comment',
'amount',
'status',
];
И во всех шаблонах будем показывать статус заказа. Обратите внимание, что список заказов
сортируется теперь по статусу. Наверху будут новые заказы, а внизу — уже завершенные. Те заказы,
которые требуют внимания администратора, всегда на виду.
@section('content')
<h1>Все заказы</h1>
@section('content')
<h1>Данные по заказу № {{ $order->id }}</h1>
<p>
Статус заказа:
@if ($order->status == 0)
<span class="text-danger">{{ $statuses[$order->status] }}</span>
@elseif (in_array($order->status, [1,2,3]))
<span class="text-success">{{ $statuses[$order->status] }}</span>
@else
{{ $statuses[$order->status] }}
@endif
</p>
@section('content')
<h1 class="mb-4">Редактирование заказа</h1>
<form method="post" action="{{ route('admin.order.update', ['order' => $order-
>id]) }}">
@csrf
@method('PUT')
<div class="form-group">
@php($status = old('status') ?? $order->status ?? 0)
<select name="status" class="form-control" title="Статус заказа">
@foreach ($statuses as $key => $value)
<option value="{{ $key }}" @if ($key == $status) selected @endif>
{{ $value }}
</option>
@endforeach
</select>
</div>
<div class="form-group">
<input type="text" class="form-control" name="name" placeholder="Имя,
Фамилия"
required maxlength="255" value="{{ old('name') ?? $order->name
?? '' }}">
</div>
<div class="form-group">
<input type="email" class="form-control" name="email"
placeholder="Адрес почты"
required maxlength="255" value="{{ old('email') ?? $order->email
?? '' }}">
</div>
<div class="form-group">
<input type="text" class="form-control" name="phone" placeholder="Номер
телефона"
required maxlength="255" value="{{ old('phone') ?? $order->phone
?? '' }}">
</div>
<div class="form-group">
<input type="text" class="form-control" name="address"
placeholder="Адрес доставки"
required maxlength="255" value="{{ old('address') ?? $order-
>address ?? '' }}">
</div>
<div class="form-group">
<textarea class="form-control" name="comment" placeholder="Комментарий"
maxlength="255" rows="2">{{ old('comment') ?? $order->comment
?? '' }}</textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-success">Сохранить</button>
</div>
</form>
@endsection
Магазин на Laravel 7, часть 18. Панель управления,
пользователи и CRUD страниц сайта
Работа с пользователями
Нужно еще предоставить администратору возможность работы с пользователями. Так что организуем
просмотр списка и добавим форму для изменения данных пользователя. Страницу просмотра данных
пользователя делать не будем, потому как данных-то всего две строки — «Имя, Фамилия» и «Адрес
почты». При редактировании будет возможность изменить пароль пользователя. Не уверен, что это
нужно — но пусть будет для полноты картины — в конце концов, это учебный проект.
Я князь! Чего хочу — того и ворочу… эээ… действую в интересах державы.
Мультфильм «Илья Муромец и Соловей Разбойник»
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
/**
* Показывает форму для редактирования пользователя
*
* @param \App\Models\User $user
* @return \Illuminate\Http\Response
*/
public function edit(User $user) {
return view('admin.user.edit', compact('user'));
}
/**
* Обновляет данные пользователя в базе данных
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\User $user
* @return \Illuminate\Http\RedirectResponse
* @throws \Illuminate\Validation\ValidationException
*/
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', 'Данные пользователя успешно обновлены');
}
/**
* Возвращает объект валидатора с нужными нам правилами
*
* @param array $data
* @return
\Illuminate\Contracts\Validation\Validator|\Illuminate\Validation\Validator
*/
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);
}
}
Добавляем маршруты в файл 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');
// доп.маршрут для показа товаров категории
Route::get('product/category/{category}', 'ProductController@category')
->name('product.category');
// просмотр и редактирование заказов
Route::resource('order', 'OrderController', ['except' => [
'create', 'store', 'destroy'
]]);
// просмотр и редактирование пользователей
Route::resource('user', 'UserController', ['except' => [
'create', 'store', 'show', 'destroy'
]]);
});
Шаблон для просмотра списка views/admin/user/index.blade.php:
@extends('layout.admin', ['title' => 'Все пользователи'])
@section('content')
<h1 class="mb-4">Все пользователи</h1>
@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
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
/**
* Преобразует дату и время обновления пользователя из UTC в Europe/Moscow
*
* @param $value
* @return \Carbon\Carbon|false
*/
public function getUpdatedAtAttribute($value) {
return Carbon::createFromFormat('Y-m-d H:i:s', $value)-
>timezone('Europe/Moscow');
}
}
Страницы сайта
На сайте нужны страницы типа «Доставка», «Оплата», «Контакты». И у администратора должна быть
возможность такие страницы создавать, редактировать и удалять. Давайте создадим еще одну таблицу
базы данных pages и ресурсный контроллер для CRUD-операций над страницами.
> php artisan make:model Models/Page -m
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::dropIfExists('pages');
}
}
> php artisan migrate
use App\Http\Controllers\Controller;
use App\Models\Page;
use Illuminate\Http\Request;
/**
* Показывает форму для создания страницы
*
* @return \Illuminate\Http\Response
*/
public function create() {
$parents = Page::where('parent_id', 0)->get();
return view('admin.page.create', compact('parents'));
}
/**
* Сохраняет новую страницу в базу данных
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request) {
$this->validate($request, [
'name' => 'required|max:100',
'parent_id' => 'required|regex:~^[0-9]+$~',
'slug' => 'required|max:100|unique:pages|regex:~^[-_a-z0-9]+$~i',
'content' => 'required',
]);
$page = Page::create($request->all());
return redirect()
->route('admin.page.show', ['page' => $page->id])
->with('success', 'Новая страница успешно создана');
}
/**
* Показывает информацию о странице сайта
*
* @param \App\Models\Page $page
* @return \Illuminate\Http\Response
*/
public function show(Page $page) {
return view('admin.page.show', compact('page'));
}
/**
* Показывает форму для редактирования страницы
*
* @param \App\Models\Page $page
* @return \Illuminate\Http\Response
*/
public function edit(Page $page) {
$parents = Page::where('parent_id', 0)->get();
return view('admin.page.edit', compact('page', 'parents'));
}
/**
* Обновляет страницу (запись в таблице БД)
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Page $page
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Page $page) {
$this->validate($request, [
'name' => 'required|max:100',
'parent_id' => 'required|regex:~^[0-9]+$~|not_in:'.$page->id,
'slug' => 'required|max:100|unique:pages,slug,'.$page-
>id.',id|regex:~^[-_a-z0-9]+$~i',
'content' => 'required',
]);
$page->update($request->all());
return redirect()
->route('admin.page.show', ['page' => $page->id])
->with('success', 'Страница была успешно отредактирована');
}
/**
* Удаляет страницу (запись в таблице БД)
*
* @param \App\Models\Page $page
* @return \Illuminate\Http\Response
*/
public function destroy(Page $page) {
if ($page->children->count()) {
return back()->withErrors('Нельзя удалить страницу, у которой есть
дочерние');
}
$page->delete();
return redirect()
->route('admin.page.index')
->with('success', 'Страница сайта успешно удалена');
}
}
Добавляем маршруты в файл 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');
// доп.маршрут для показа товаров категории
Route::get('product/category/{category}', 'ProductController@category')
->name('product.category');
// просмотр и редактирование заказов
Route::resource('order', 'OrderController', ['except' => [
'create', 'store', 'destroy'
]]);
// просмотр и редактирование пользователей
Route::resource('user', 'UserController', ['except' => [
'create', 'store', 'show', 'destroy'
]]);
// CRUD-операции над страницами сайта
Route::resource('page', 'PageController');
});
Добавляем свойство $fillable в модель, чтобы использовать «mass assignment» и настраиваем связи
между таблицами:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
protected $fillable = [
'name',
'slug',
'content',
'parent_id',
];
/**
* Связь «один ко многим» таблицы `pages` с таблицей `pages`
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function children() {
return $this->hasMany(Page::class, 'parent_id');
}
/**
* Связь «страница принадлежит» таблицы `pages` с таблицей `pages`
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function parent() {
return $this->belongsTo(Page::class);
}
}
Шаблон для просмотра списка views/admin/page/index.blade.php:
@extends('layout.admin', ['title' => 'Все страницы сайта'])
@section('content')
<h1 class="mb-3">Все страницы сайта</h1>
<a href="{{ route('admin.page.create') }}" class="btn btn-success mb-4">
Создать страницу
</a>
@if (count($pages))
<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>
@include('admin.page.part.tree', ['level' => -1, 'parent' => 0])
</table>
@endif
@endsection
Всмомогательный шаблон для списка views/admin/page/part/tree.blade.php:
@php($level++)
@foreach($pages->where('parent_id', $parent) as $page)
<tr>
<td>{{ $page->id }}</td>
<td>
@if ($level)
{{ str_repeat('—', $level) }}
@endif
<a href="{{ route('admin.page.show', ['page' => $page->id]) }}"
style="font-weight:@if($level) normal @else bold @endif">
{{ $page->name }}
</a>
</td>
<td>{{ $page->slug }}</td>
<td>
<a href="{{ route('admin.page.edit', ['page' => $page->id]) }}">
<i class="far fa-edit"></i>
</a>
</td>
<td>
<form action="{{ route('admin.page.destroy', ['page' => $page->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>
@if (count($pages->where('parent_id', $parent)))
@include('admin.page.part.tree', ['level' => $level, 'parent' => $page-
>id])
@endif
@endforeach
@section('content')
<h1>Просмотр страницы</h1>
<div class="row">
<div class="col-12">
<p><strong>Название:</strong> {{ $page->name }}</p>
<p><strong>ЧПУ (англ):</strong> {{ $page->slug }}</p>
<p><strong>Контент (html)</strong></p>
<div class="mb-4 bg-white p-1">
@php echo nl2br(htmlspecialchars($page->content)) @endphp
</div>
@section('content')
<h1>Создание новой страницы</h1>
<form method="post" action="{{ route('admin.page.store') }}">
@include('admin.page.part.form')
</form>
@endsection
Шаблон для редактирования страницы views/admin/page/edit.blade.php:
@extends('layout.admin', ['title' => 'Редактирование страницы'])
@section('content')
<h1>Редактирование страницы</h1>
<form method="post" action="{{ route('admin.page.update', ['page' => $page->id])
}}">
@method('PUT')
@include('admin.page.part.form')
</form>
@endsection
Всмомогательный шаблон с формой views/admin/page/part/form.blade.php:
@csrf
<div class="form-group">
<input type="text" class="form-control" name="name" placeholder="Наименование"
required maxlength="100" value="{{ old('name') ?? $page->name ?? '' }}">
</div>
<div class="form-group">
<input type="text" class="form-control" name="slug" placeholder="ЧПУ (на
англ.)"
required maxlength="100" value="{{ old('slug') ?? $page->slug ?? '' }}">
</div>
<div class="form-group">
@php
$parent_id = old('parent_id') ?? $page->parent_id ?? 0;
@endphp
<select name="parent_id" class="form-control" title="Родитель">
<option value="0">Без родителя</option>
@foreach($parents as $parent)
<option value="{{ $parent->id }}" @if ($parent->id == $parent_id)
selected @endif>
{{ $parent->name }}
</option>
@endforeach
</select>
</div>
<div class="form-group">
<textarea class="form-control" name="content" placeholder="Контент (html)"
required
rows="10">{{ old('content') ?? $page->content ?? '' }}</textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
Магазин на Laravel 7, часть 19. Панель управления,
добавляем редактор для страниц сайта
Возможность добавлять и редактировать страницы сайта у нас теперь есть, но не хватает wysiwyg-
редактора. Будем использовать summernote — простой, легкий и есть возможность вставлять видео и
изображения. Но самое главное — можно навесить свои обработчики события добавления и удаления
изображений. А это означает, что можно организовать систему автозагрузки и автоудаления
изображений на сервере.
Установка wysiwyg-редактора
Тут все просто — в layout-шаблоне добавляем два файла js-скриптов и один css-файл. Даже не будем их
скачивать, а загрузим из внешнего источника.
<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 rel="stylesheet" href="{{ asset('css/app.css') }}">
<link rel="stylesheet"
href="https://pro.fontawesome.com/releases/v5.10.0/css/all.css"/>
<!-- один css-файл -->
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-
bs4.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
<script src="{{ asset('js/app.js') }}"></script>
<!-- два js-скрипта -->
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-
bs4.min.js"></script>
<script
src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/lang/summernote-ru-
RU.min.js"></script>
<script src="{{ asset('js/admin.js') }}"></script>
</head>
В шаблоне формы для добавления-редактирования страницы сайта для <textarea> зададим
идентификатор id="editor".
<div class="form-group">
<textarea class="form-control" name="content" placeholder="Контент (html)"
required
id="editor" rows="10">{{ old('content') ?? $page->content ?? ''
}}</textarea>
</div>
В файле admin.js подключим wysiwyg-редактор для <textarea> с уникальным
идентификатором id="editor".
jQuery(document).ready(function($) {
/*
* Автоматическое создание slug при вводе name (замена кириллицы на латиницу)
*/
$('input[name="name"]').on('input', function() {
/* ... */
});
/*
* Подключение wysiwyg-редактора для редактирования контента страницы
*/
$('textarea[id="editor"]').summernote({
lang: 'ru-RU',
height: 300,
});
});
Загрузка изображений
По умолчанию редактор summernote сохраняет изображение прямо в атрибуте src тега <img>:
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi assumenda
blanditiis consequatur cum cupiditate ea
facere, facilis fuga fugit ipsum itaque laboriosam, laudantium nemo, nulla odio
placeat quas recusandae repellat
repudiandae sint unde ut vitae voluptas voluptate voluptatem. Amet assumenda
dolorum enim iusto odit quis similique.
</p>
<p>
<img
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRT
b2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5
ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzV
NME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4
..........
ciI/Pv3k9zcAAACiSURBVHja7NAxAQAACAMgtX/nWcHPByLQKa5GgSxZsmTJkqVAlixZsmTJUiBLlixZsmQ
pkCVLlixZshTIkiVLlixZCmTJkiVLliwFsmTJ
kiVLlgJZsmTJkiVLgSxZsmTJkqVAlixZsmTJUiBLlixZsmQpkCVLlixZshTIkiVLlixZCmTJkiVLliwFsmT
JkiVLlgJZsmTJkiVLgSxZ31YAAQYAil4Bx7aJ
z7QAAAAASUVORK5CYII=" data-filename="test.png" style="width: 100px;">
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi assumenda
blanditiis consequatur cum cupiditate ea
facere, facilis fuga fugit ipsum itaque laboriosam, laudantium nemo, nulla odio
placeat quas recusandae repellat
repudiandae sint unde ut vitae voluptas voluptate voluptatem. Amet assumenda
dolorum enim iusto odit quis similique.
</p>
/**
* Обновляет страницу (запись в таблице БД)
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Page $page
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Page $page) {
$this->validate($request, [
'name' => 'required|max:100',
'parent_id' => 'required|regex:~^[0-9]+$~|not_in:'.$page->id,
'slug' => 'required|max:100|unique:pages,slug,'.$page-
>id.',id|regex:~^[-_a-z0-9]+$~i',
'content' => 'required',
]);
$content = $this->saveImages($request->input('content'));
$data = $request->all();
$data['content'] = $content;
$page->update($data);
return redirect()
->route('admin.page.show', ['page' => $page->id])
->with('success', 'Страница была успешно отредактирована');
}
/**
* Сохраняет на диск изображения и заменяет атрибут src тегов img
* <img src="data:image/png;base64,R0lGODlhEAAOALDD..." alt="" />
* <img src="http://server.com/storage/page/123456.png" alt="" />
*
* @param $content
* @return string
*/
private function saveImages($content) {
$dom = new \DomDocument('1.0', 'UTF-8');
// loadHTML() считает, что строка в кодировке ISO-8859-1,
// поэтому указываем явно, что строка в кодировке UTF-8
$html = '<!DOCTYPE html><html><head><meta charset="UTF-8"/></head>';
$html = $html . '<body>'.$content.'</body></html>';
$dom->loadHtml($html);
$images = $dom->getElementsByTagName('img');
foreach ($images as $img) {
$data = $img->getAttribute('src');
if (strpos($data, 'data') === false) {
continue;
}
// <img src="data:image/jpeg;base64,R0lGODlhEAAOAL..." alt="" />
// data:image/jpeg;base64, data:image/png;base64, data:image/gif
list($type, $data) = explode(';', $data);
list(, $ext) = explode('/', $type);
list(, $data) = explode(',', $data);
$data = base64_decode($data);
$name = md5(uniqid(rand(), true)) . '.' . $ext;
Storage::disk('public')->put('page/' . $name, $data);
$url = Storage::disk('public')->url('page/' . $name);
$img->removeAttribute('data-filename');
$img->removeAttribute('src');
$img->setAttribute('src', $url);
}
$content = html_entity_decode($dom->saveXML($dom->documentElement));
$content = str_replace(
[
'<html><head><meta charset="UTF-8"/></head><body>',
'</body></html>',
],
'',
$content
);
$content = trim($content);
return $content;
}
}
Кроме того, при удалении страницы нам опять нужно проанализировать html-код, чтобы найти в нем
изображения и удалить их с диска.
/**
* Удаляет страницу (запись в таблице БД)
*
* @param \App\Models\Page $page
* @return \Illuminate\Http\Response
*/
public function destroy(Page $page) {
if ($page->children->count()) {
return back()->withErrors('Нельзя удалить страницу, у которой есть
дочерние');
}
$this->removeImages($page->content);
$page->delete();
return redirect()
->route('admin.page.index')
->with('success', 'Страница сайта успешно удалена');
}
}
Второй способ
Первый способ мне не очень нравится, потому что с DomDocument (неожиданно) возникли трудности. Там
чехарда с кодировкой строки html-кода при использовании метода loadHTML() и проблемы при
использовании метода saveHTML(). Проблема, как оказалось, застарелая — в интернете полным-полно
рецептов, как это побороть с помощью тех или иных хаков.
jQuery(document).ready(function($) {
/*
* Общие настройки ajax-запросов, отправка на сервер csrf-токена
*/
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
/*
* Автоматическое создание slug при вводе name (замена кириллицы на латиницу)
*/
$('input[name="name"]').on('input', function() {
/* ... */
});
/*
* Подключение wysiwyg-редактора для редактирования контента страницы
*/
$('textarea[id="editor"]').summernote({
lang: 'ru-RU',
height: 300,
callbacks: {
/*
* При вставке изображения загружаем его на сервер
*/
onImageUpload: function(images) {
for (var i = 0; i < images.length; i++) {
uploadImage(images[i], this);
}
},
/*
* При удалении изображения удаляем его на сервере
*/
onMediaDelete: function(target) {
removeImage(target[0].src);
}
}
});
/*
* Загружает на сервер вставленное в редакторе изображение
*/
function uploadImage(image, textarea) {
var data = new FormData();
data.append('image', image);
$.ajax({
data: data,
type: 'POST',
url: '/admin/page/upload/image',
cache: false,
contentType: false,
processData: false,
success: function(url) {
// $(textarea).summernote('insertImage', url);
$(textarea).summernote('insertImage', url, function ($img) {
$img.css('max-width', '100%');
});
}
});
}
/*
* Удаляет на сервере удаленное в редакторе изображение
*/
function removeImage(src) {
$.ajax({
data: {'image': src, '_method': 'DELETE'},
type: 'POST',
url: '/admin/page/remove/image',
cache: false,
success: function(msg) {
// console.log(msg);
}
});
}
});
Не совсем очевидно, как удалить изображение, чтобы вызвать событие. Если кликнуть по изображению —
появится всплывающая менюшка. И уже в этой менюшке надо кликнуть по иконке корзины.
Обратите внимание, что мы делаем общие настройки для всех ajax-запросов, отправляя с каждым
запросом csrf-токен. Без этого нам бы пришлось при каждой отправке ajax-запроса добавлять этот токен
самостоятельно, примерно так:
/**
* Удаляет изображение, которое было удалено в wysiwyg-редакторе
*
* @param \Illuminate\Http\Request $request
* @return string
*/
public function removeImage(Request $request) {
// $path = /storage/page/CW2xtBYIcXDx7d3oJRCLZoZtIhaSFWAS8Qa7WFL3.png
$path = parse_url($request->image, PHP_URL_PATH);
$path = str_replace('/storage/', '', $path);
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
return 'Изображение было удалено';
}
return 'Не удалось удалить изображение';
}
}
Теперь метод saveImages() больше не нужен и его можно удалить. Также удаляем вызов этого метода
из store() и update(). В итоге получился такой контроллер:
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Page;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
/**
* Показывает форму для создания страницы
*
* @return \Illuminate\Http\Response
*/
public function create() {
$parents = Page::where('parent_id', 0)->get();
return view('admin.page.create', compact('parents'));
}
/**
* Сохраняет новую страницу в базу данных
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request) {
$this->validate($request, [
'name' => 'required|max:100',
'parent_id' => 'required|regex:~^[0-9]+$~',
'slug' => 'required|max:100|unique:pages|regex:~^[-_a-z0-9]+$~i',
'content' => 'required',
]);
$page = Page::create($request->all());
return redirect()
->route('admin.page.show', ['page' => $page->id])
->with('success', 'Новая страница успешно создана');
}
/**
* Показывает информацию о странице сайта
*
* @param \App\Models\Page $page
* @return \Illuminate\Http\Response
*/
public function show(Page $page) {
return view('admin.page.show', compact('page'));
}
/**
* Показывает форму для редактирования страницы
*
* @param \App\Models\Page $page
* @return \Illuminate\Http\Response
*/
public function edit(Page $page) {
$parents = Page::where('parent_id', 0)->get();
return view('admin.page.edit', compact('page', 'parents'));
}
/**
* Обновляет страницу (запись в таблице БД)
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Page $page
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Page $page) {
$this->validate($request, [
'name' => 'required|max:100',
'parent_id' => 'required|regex:~^[0-9]+$~|not_in:'.$page->id,
'slug' => 'required|max:100|unique:pages,slug,'.$page-
>id.',id|regex:~^[-_a-z0-9]+$~i',
'content' => 'required',
]);
$page->update($request->all());
return redirect()
->route('admin.page.show', ['page' => $page->id])
->with('success', 'Страница была успешно отредактирована');
}
/**
* Загружает изображение, которое было добавлено в wysiwyg-редакторе и
* возвращает ссылку на него, чтобы в редакторе вставить <img src="…"/>
*
* @param \Illuminate\Http\Request $request
* @return string
*/
public function uploadImage(Request $request) {
$path = $request->file('image')->store('page', 'public');
return Storage::disk('public')->url($path);
}
/**
* Удаляет изображение, которое было удалено в wysiwyg-редакторе
*
* @param \Illuminate\Http\Request $request
* @return string
*/
public function removeImage(Request $request) {
// $path = /storage/page/CW2xtBYIcXDx7d3oJRCLZoZtIhaSFWAS8Qa7WFL3.png
$path = parse_url($request->image, PHP_URL_PATH);
$path = str_replace('/storage/', '', $path);
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
return 'Изображение было удалено';
}
return 'Не удалось удалить изображение';
}
/**
* Удаляет изображения, которые связаны со страницей
*
* @param string $content
* @return void
*/
private function removeImages($content) {
$dom = new \DomDocument();
$dom->loadHtml($content);
$images = $dom->getElementsByTagName('img');
foreach ($images as $img) {
$src = $img->getAttribute('src');
$pattern = '~/storage/page/([0-9a-f]{32}\.(jpeg|png|gif))~';
if (preg_match($pattern, $src, $match)) {
$name = $match[1];
if (Storage::disk('public')->exists('page/' . $name)) {
Storage::disk('public')->delete('page/' . $name);
}
}
}
}
/**
* Удаляет страницу (запись в таблице БД)
*
* @param \App\Models\Page $page
* @return \Illuminate\Http\Response
*/
public function destroy(Page $page) {
if ($page->children->count()) {
return back()->withErrors('Нельзя удалить страницу, у которой есть
дочерние');
}
$this->removeImages($page->content);
$page->delete();
return redirect()
->route('admin.page.index')
->with('success', 'Страница сайта успешно удалена');
}
}
Проверка изображения
Метод uploadImage() просто сохраняет файл изображения без всяких проверок, что не очень хорошо.
Хотя изображения будет загружать администратор, все-таки добавим валидацию, что это именно
изображение и что размер не очень большой.
class PageController extends Controller {
/**
* Загружает изображение, которое было добавлено в wysiwyg-редакторе и
* возвращает ссылку на него, чтобы в редакторе вставить <img src="…"/>
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function uploadImage(Request $request) {
$validator = Validator::make($request->all(), ['image' => [
'mimes:jpeg,jpg,png',
'max:5000' // 5 Мбайт
]]);
if ($validator->passes()) {
$path = $request->file('image')->store('page', 'public');
$url = Storage::disk('public')->url($path);
return response()->json(['image' => $url]);
}
return response()->json(['errors' => $validator->errors()->all()]);
}
}
/*
* Загружает на сервер вставленное в редакторе изображение
*/
function uploadImage(image, textarea) {
var data = new FormData();
data.append('image', image);
$.ajax({
data: data,
type: 'POST',
url: '/admin/page/upload/image',
cache: false,
contentType: false,
processData: false,
dataType: 'json',
success: function(data) {
if (data.errors === undefined) {
$(textarea).summernote('insertImage', data.image, function ($img) {
$img.css('max-width', '100%');
});
} else {
$.each(data.errors, function (key, value) {
alert(value);
});
}
},
});
}
{
"errors": [
"Поле image должно быть файлом одного из следующих типов: jpeg, png.",
"Размер файла в поле image не может быть больше 1 Килобайт(а)."
]
}
{
"image":
"http://www.host21.ru/storage/page/xfDOpOlUe2J0G4yNe1zhWre0jTYnDq6O3x2adETB.png"
}
При использовании метода validate() во время AJAX-запроса Laravel не будет генерировать ответ
перенаправления (редирект). Вместо этого Laravel генерирует ответ в формате JSON, содержащий
все ошибки валидации. Этот JSON-ответ будет отправлен с кодом состояния 422 HTTP.
Так что наш код метода uploadImage() почти ничем не будет отличаться от кода, который обрабатывает
обычный запрос:
class PageController extends Controller {
/**
* Загружает изображение, которое было добавлено в wysiwyg-редакторе и
* возвращает ссылку на него, чтобы в редакторе вставить <img src="…"/>
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function uploadImage(Request $request) {
$this->validate($request, ['image' => [
'mimes:jpeg,jpg,png',
'max:5000' // 5 Мбайт
]]);
$path = $request->file('image')->store('page', 'public');
$url = Storage::disk('public')->url($path);
return response()->json(['image' => $url]);
}
}
{
"message": "The given data was invalid.",
"errors": {
"image": [
"Поле image должно быть файлом одного из следующих типов: jpeg, png.",
"Размер файла в поле image не может быть больше 1 Килобайт(а)."
]
}
}
{
"image":
"http://www.host21.ru/storage/page/xfDOpOlUe2J0G4yNe1zhWre0jTYnDq6O3x2adETB.png"
}
/*
* Загружает на сервер вставленное в редакторе изображение
*/
function uploadImage(image, textarea) {
var data = new FormData();
data.append('image', image);
$.ajax({
data: data,
type: 'POST',
url: '/admin/page/upload/image',
cache: false,
contentType: false,
processData: false,
dataType: 'json',
success: function(data) {
$(textarea).summernote('insertImage', data.image, function ($img) {
$img.css('max-width', '100%');
});
},
error: function (reject) {
$.each(reject.responseJSON.errors, function (key, value) {
alert(value);
});
}
});
}
Магазин на Laravel 7, часть 20. Показ отдельной страницы
и верхнее меню всех страниц
Показ страницы
Давайте создадим контроллер для показа страницы сайта в публичной части. У этого контроллера будет
только одно действие, а следовательно — только один метод. Создать заготовку такого контроллера
можно с помощью artisan-команды.
use App\Models\Page;
use Illuminate\Http\Request;
/**
* Get the route key for the model.
*
* @return string
*/
public function getRouteKeyName() {
return $this->getKeyName();
}
/**
* Get the primary key for the model.
*
* @return string
*/
public function getKeyName() {
return $this->primaryKey;
}
/**
* Retrieve the model for a bound value.
*
* @param mixed $value
* @param string|null $field
* @return \Illuminate\Database\Eloquent\Model|null
*/
public function resolveRouteBinding($value, $field = null) {
return $this->where($field ?? $this->getRouteKeyName(), $value)->first();
}
}
Поэтому для этого маршрута надо явно указать, что получать модель для внедрения в контроллер нужно
по уникальному slug.
Route::get('/page/{page:slug}', 'PageController')->name('page.show');
И осталось только создать шаблон views/page/show.blade.php:
@extends('layout.site')
@section('content')
<div class="card">
<div class="card-header">
<h1>{{ $page->name }}</h1>
</div>
<div class="card-body">
{!! $page->content !!}
</div>
<div class="card-footer">
Добавлена: {{ $page->created_at->format('d.m.Y H:i') }}
</div>
</div>
@endsection
Небольшое отступление
По поводу привязки модели к маршруту. Мы можем в модели задать значение переменной $primaryKey,
чтобы указать Laravel, по какому полю таблицы искать запись в таблице БД. Но, в панели управления
нужно получать страницу по идентификатору, а в публичной части — по уникальному slug. Так что нам
это не подходит.
Есть как минимум три варианта решения этой проблемы. Первый — переопределить метод
модели getRouteKeyName(). Второй — переопределить метод модели resolveRouteBinding(). Третий
— внести изменения в метод boot() сервис-провайдера RouteServiceProvider. Подробно это описано
в документации, см. здесь.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Route;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Route;
use App\Models\Page;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as
ServiceProvider;
use Illuminate\Support\Facades\Route;
parent::boot();
/*
* Если мы в панели управления — страница будет получена из
* БД по id, если в публичной части сайта — то по slug
*/
Route::bind('page', function($value) {
$current = Route::currentRouteName();
if ('page.show' == $current) { // публичная часть сайта
return Page::whereSlug($value)->firstOrFail();
}
// панель управления сайта
return Page::findOrFail($value);
});
}
/* ... */
}
Верхнее меню
Нам нужно получать от модели все страницы и показывать ссылки на эти страницы в верхнем меню.
<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 rel="stylesheet" href="{{ asset('css/app.css') }}">
<link rel="stylesheet"
href="https://pro.fontawesome.com/releases/v5.10.0/css/all.css">
<link rel="stylesheet" href="{{ asset('css/site.css') }}">
<script src="{{ asset('js/app.js') }}"></script>
<script src="{{ asset('js/site.js') }}"></script>
</head>
Шаблон страницы сайта views/page/show.blade.php:
@extends('layout.site', ['title' => $page->name])
@section('content')
<!-- ..... -->
@endsection
Шаблон категории каталога views/catalog/category.blade.php:
@extends('layout.site', ['title' => $category->name])
@section('content')
<!-- ..... -->
@endsection
Шаблон товара каталога views/catalog/product.blade.php:
@extends('layout.site', ['title' => $product->name])
@section('content')
<!-- ..... -->
@endsection
Шаблон бренда каталога views/catalog/brand.blade.php:
@extends('layout.site', ['title' => $brand->name])
@section('content')
<!-- ..... -->
@endsection
Шаблон корзины покупателя views/basket/index.blade.php:
@extends('layout.site', ['title' => 'Ваша корзина'])
@section('content')
<!-- ..... -->
@endsection
Шаблон страницы оформления заказа views/basket/checkout.blade.php:
@extends('layout.site', ['title' => 'Оформить заказ'])
@section('content')
<!-- ..... -->
@endsection
Шаблон успешного оформления заказа views/basket/success.blade.php:
@extends('layout.site', ['title' => 'Заказ размещен'])
@section('content')
<!-- ..... -->
@endsection
Шаблон страницы регистрации views/auth/register.blade.php:
@extends('layout.site', ['title' => 'Регистрация на сайте'])
@section('content')
<!-- ..... -->
@endsection
Шаблон страницы входа в ЛК views/auth/login.blade.php:
@extends('layout.site', ['title' => 'Вход в личный кабинет'])
@section('content')
<!-- ..... -->
@endsection
Добавляем профили
Создаем модедь и миграцию с помощью artisan-команды:
/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::dropIfExists('profiles');
}
}
Route::name('user.')->prefix('user')->group(function () {
// регистрация, вход в ЛК, восстановление пароля
Auth::routes();
});
Route::group([
'as' => 'user.', // имя маршрута, например user.index
'prefix' => 'user', // префикс маршрута, например user/index
'middleware' => ['auth'] // один или несколько посредников
], function () {
// главная страница личного кабинета пользователя
Route::get('index', 'UserController@index')->name('index');
// CRUD-операции над профилями пользователя
Route::resource('profile', 'ProfileController');
});
Маршрут главной страницы личного кабинета теперь в другой группе. Эта группа использует
посредник auth, то есть все маршруты в ней доступны только аутентифицированным пользователям. А
посредник в конструкторе контроллера UserController уберем.
namespace App\Http\Controllers;
use App\Models\Profile;
use Illuminate\Http\Request;
/**
* Показывает список всех профилей
*
* @return \Illuminate\Http\Response
*/
public function index() {
$profiles = auth()->user()->profiles()->paginate(4);
return view('user.profile.index', compact('profiles'));
}
/**
* Показывает форму для создания профиля
*
* @return \Illuminate\Http\Response
*/
public function create() {
return view('user.profile.create');
}
/**
* Сохраняет новый профиль в базу данных
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request) {
// проверяем данные формы профиля
$this->validate($request, [
'user_id' => 'in:' . auth()->user()->id,
'title' => 'required|max:255',
'name' => 'required|max:255',
'email' => 'required|email|max:255',
'phone' => 'required|max:255',
'address' => 'required|max:255',
]);
// валидация пройдена, создаем профиль
$profile = Profile::create($request->all());
return redirect()
->route('user.profile.show', ['profile' => $profile->id])
->with('success', 'Новый профиль успешно создан');
}
/**
* Показывает информацию о профиле
*
* @param \App\Models\Profile $profile
* @return \Illuminate\Http\Response
*/
public function show(Profile $profile) {
if ($profile->user_id !== auth()->user()->id) {
abort(404); // это чужой профиль
}
return view('user.profile.show', compact('profile'));
}
/**
* Показывает форму для редактирования профиля
*
* @param \App\Models\Profile $profile
* @return \Illuminate\Http\Response
*/
public function edit(Profile $profile) {
if ($profile->user_id !== auth()->user()->id) {
abort(404); // это чужой профиль
}
return view('user.profile.edit', compact('profile'));
}
/**
* Обновляет профиль (запись в таблице БД)
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Profile $profile
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Profile $profile) {
// проверяем данные формы профиля
$this->validate($request, [
'user_id' => 'in:' . auth()->user()->id,
'title' => 'required|max:255',
'name' => 'required|max:255',
'email' => 'required|email|max:255',
'phone' => 'required|max:255',
'address' => 'required|max:255',
]);
// валидация пройдена, обновляем профиль
$profile->update($request->all());
return redirect()
->route('user.profile.show', ['profile' => $profile->id])
->with('success', 'Профиль был успешно отредактирован');
}
/**
* Удаляет профиль (запись в таблице БД)
*
* @param \App\Models\Profile $profile
* @return \Illuminate\Http\Response
*/
public function destroy(Profile $profile) {
if ($profile->user_id !== auth()->user()->id) {
abort(404); // это чужой профиль
}
$profile->delete();
return redirect()
->route('user.profile.index')
->with('success', 'Профиль был успешно удален');
}
}
Поскольку мы используем «mass assignment», добавляем свойство fillable:
class Profile extends Model {
protected $fillable = [
'user_id',
'title',
'name',
'email',
'phone',
'address',
'comment',
];
/* ... */
}
Шаблон списка всех профилей пользователя views/user/profile/index.blade.php:
@extends('layout.site', ['title' => 'Ваши профили'])
@section('content')
<h1>Ваши профили</h1>
@if (count($profiles))
<table class="table table-bordered">
<tr>
<th>№</th>
<th width="22%">Наименование</th>
<th width="22%">Имя, Фамилия</th>
<th width="22%">Адрес почты</th>
<th width="22%">Номер телефона</th>
<th><i class="fas fa-edit"></i></th>
<th><i class="fas fa-trash-alt"></i></th>
</tr>
@foreach($profiles as $profile)
<tr>
<td>{{ $loop->iteration }}</td>
<td>
<a href="{{ route('user.profile.show', ['profile' =>
$profile->id]) }}">
{{ $profile->title }}
</a>
</td>
<td>{{ $profile->name }}</td>
<td><a href="mailto:{{ $profile->email }}">{{ $profile->email
}}</a></td>
<td>{{ $profile->phone }}</td>
<td>
<a href="{{ route('user.profile.edit', ['profile' =>
$profile->id]) }}">
<i class="far fa-edit"></i>
</a>
</td>
<td>
<form action="{{ route('user.profile.destroy', ['profile'
=> $profile->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>
{{ $profiles->links() }}
@endif
@endsection
Шаблон просмотра профиля пользователя views/user/profile/show.blade.php:
@extends('layout.site', ['title' => 'Данные профиля'])
@section('content')
<h1>Данные профиля</h1>
@section('content')
<h1>Создание профиля</h1>
<form method="post" action="{{ route('user.profile.store') }}">
@include('user.profile.part.form')
</form>
@endsection
Шаблон редактирования профиля пользователя views/user/profile/edit.blade.php:
@extends('layout.site', ['title' => 'Редактирование профиля'])
@section('content')
<h1>Редактирование профиля</h1>
<form method="post" action="{{ route('user.profile.update', ['profile' =>
$profile->id]) }}">
@method('PUT')
@include('user.profile.part.form')
</form>
@endsection
Вспомогательный шаблон с формой создания-
редактирования views/user/profile/part/form.blade.php:
@csrf
<input type="hidden" name="user_id" value="{{ auth()->user()->id }}">
<div class="form-group">
<input type="text" class="form-control" name="title" placeholder="Название
профиля"
required maxlength="255" value="{{ old('title') ?? $profile->title ?? ''
}}">
</div>
<div class="form-group">
<input type="text" class="form-control" name="name" placeholder="Имя, Фамилия"
required maxlength="255" value="{{ old('name') ?? $profile->name ?? ''
}}">
</div>
<div class="form-group">
<input type="email" class="form-control" name="email" placeholder="Адрес почты"
required maxlength="255" value="{{ old('email') ?? $profile->email ?? ''
}}">
</div>
<div class="form-group">
<input type="text" class="form-control" name="phone" placeholder="Номер
телефона"
required maxlength="255" value="{{ old('phone') ?? $profile->phone ?? ''
}}">
</div>
<div class="form-group">
<input type="text" class="form-control" name="address" placeholder="Адрес
доставки"
required maxlength="255" value="{{ old('address') ?? $profile->address
?? '' }}">
</div>
<div class="form-group">
<textarea class="form-control" name="comment" placeholder="Комментарий"
maxlength="255" rows="2">{{ old('comment') ?? $profile->comment ?? ''
}}</textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-success">Сохранить</button>
</div>
Используем профили
Теперь при оформлении заказа нам нужно предоставить аутентифицированному пользователю
возможность выбрать подходящий профиль. Начнем с
метода checkout() контроллера BasketController.
class BasketController extends Controller {
/**
* Форма оформления заказа
*
* @param Request $request
* @return \Illuminate\Http\Response
*/
public function checkout(Request $request) {
$profile = null;
$profiles = null;
if (auth()->check()) { // если пользователь аутентифицирован
$user = auth()->user();
// ...и у него есть профили для оформления
$profiles = $user->profiles;
// ...и был запрошен профиль для оформления
$prof_id = (int)$request->input('profile_id');
if ($prof_id) {
$profile = $user->profiles()->whereIdAndUserId($prof_id, $user-
>id)->first();
}
}
return view('basket.checkout', compact('profiles', 'profile'));
}
}
Если пользователь залогинен, мы получаем его профили и передаем в шаблон формы оформления
заказа. На переменную $profile пока не обращаем внимание, о ней чуть позже.
@extends('layout.site', ['title' => 'Оформить заказ'])
@section('content')
<h1 class="mb-4">Оформить заказ</h1>
@if ($profiles && $profiles->count())
@include('basket.select', ['current' => $profile->id ?? 0])
@endif
<form method="post" action="{{ route('basket.saveorder') }}" id="checkout">
<!-- ..... -->
</form>
@endsection
Как видите, в форме подключаем еще один шаблон views/basket/select.blade.php, который
позволяет выбрать подходящий профиль.
<form action="{{ route('basket.checkout') }}" method="get" id="profiles">
<div class="form-group">
<select name="profile_id" class="form-control">
<option value="0">Выберите профиль</option>
@foreach($profiles as $profile)
<option value="{{ $profile->id }}"@if($profile->id == $current)
selected @endif>
{{ $profile->title }}
</option>
@endforeach
</select>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Выбрать</button>
</div>
</form>
При отправке этой формы передается GET-параметр profile_id, данные отправляются на
роут basket.checkout. За показ страницы по этому роуту отвечает метод checkout() контроллера.
Показывается все та же форма оформления заказа, но мы уже можем получить данные профиля,
поскольку есть идентификатор $profile_id. И передать данные профиля в шаблон, чтобы заполнить
поля формы.
@extends('layout.site', ['title' => 'Оформить заказ'])
@section('content')
<h1 class="mb-4">Оформить заказ</h1>
@isset ($profiles)
@include('basket.select', ['current' => $profile->id ?? 0])
@endisset
<form method="post" action="{{ route('basket.saveorder') }}" id="checkout">
@csrf
<div class="form-group">
<input type="text" class="form-control" name="name" placeholder="Имя,
Фамилия"
required maxlength="255" value="{{ old('name') ?? $profile->name
?? '' }}">
</div>
<div class="form-group">
<input type="email" class="form-control" name="email"
placeholder="Адрес почты"
required maxlength="255" value="{{ old('email') ?? $profile-
>email ?? '' }}">
</div>
<div class="form-group">
<input type="text" class="form-control" name="phone" placeholder="Номер
телефона"
required maxlength="255" value="{{ old('phone') ?? $profile-
>phone ?? '' }}">
</div>
<div class="form-group">
<input type="text" class="form-control" name="address"
placeholder="Адрес доставки"
required maxlength="255" value="{{ old('address') ?? $profile-
>address ?? '' }}">
</div>
<div class="form-group">
<textarea class="form-control" name="comment" placeholder="Комментарий"
maxlength="255" rows="2">{{ old('comment') ?? $profile-
>comment ?? '' }}</textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-success">Оформить</button>
</div>
</form>
@endsection
Практически все готово, только давайте будем получать данные профиля с помощью ajax-запроса:
jQuery(document).ready(function($) {
/*
* Общие настройки ajax-запросов, отправка на сервер csrf-токена
*/
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
/*
* Раскрытие и скрытие пунктов меню каталога в левой колонке
*/
$('#catalog-sidebar > ul ul').hide();
$('#catalog-sidebar .badge').on('click', function () {
/* ... */
});
/*
* Получение данных профиля пользователя при оформлении заказа
*/
$('form#profiles button[type="submit"]').hide();
// при выборе профиля отправляем ajax-запрос, чтобы получить данные
$('form#profiles select').change(function () {
// если выбран элемент «Выберите профиль»
if ($(this).val() == 0) {
// очищаем все поля формы оформления заказа
$('#checkout').trigger('reset');
return;
}
var data = new FormData($('form#profiles')[0]);
$.ajax({
url: '/basket/profile',
data: data,
processData: false,
contentType: false,
type: 'POST',
dataType: 'JSON',
success: function(data) {
$('input[name="name"]').val(data.profile.name);
$('input[name="email"]').val(data.profile.email);
$('input[name="phone"]').val(data.profile.phone);
$('input[name="address"]').val(data.profile.address);
$('textarea[name="comment"]').val(data.profile.comment);
},
error: function (reject) {
alert(reject.responseJSON.error);
}
});
});
});
Данные мы отправляем методом POST по маршруту basket/profile. Соответственно, надо добавить
маршрут:
Route::post('/basket/profile', 'BasketController@profile')
->name('basket.profile');
И добавляем метод profile() в контроллер BasketController, который будет возвращать данные
профиля в json-формате.
class BasketController extends Controller {
/**
* Возвращает профиль пользователя в формате JSON
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function profile(Request $request) {
if ( ! $request->ajax()) {
abort(404);
}
if ( ! auth()->check()) {
return response()->json(['error' => 'Нужна авторизация!'], 404);
}
$user = auth()->user();
$profile_id = (int)$request->input('profile_id');
if ($profile_id) {
$profile = $user->profiles()->whereIdAndUserId($profile_id, $user->id)-
>first();
if ($profile) {
return response()->json(['profile' => $profile]);
}
}
return response()->json(['error' => 'Профиль не найден!'], 404);
}
}
<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">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'Интернет-магазин' }}</title>
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
<link rel="stylesheet"
href="https://pro.fontawesome.com/releases/v5.10.0/css/all.css"/>
<link rel="stylesheet" href="{{ asset('css/site.css') }}">
<script src="{{ asset('js/app.js') }}"></script>
<script src="{{ asset('js/site.js') }}"></script>
</head>
Сейчас наш контроллер CatalogController, который отвечает за страницы каталога товаров, выглядит
так:
namespace App\Http\Controllers;
use App\Models\Brand;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Http\Request;
class CatalogController extends Controller {
public function index() {
$roots = Category::where('parent_id', 0)->get();
return view('catalog.index', compact('roots'));
}
Мы можем упростить контроллер, чтобы не самим получать экземпляр модели категории, бренда и
товара, а доверить это Laravel. Фреймворк будет внедрять экземпляры моделей в методы контроллера,
если мы используем «type hinting».
namespace App\Http\Controllers;
use App\Models\Brand;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Http\Request;
Route::get('/catalog/index', 'CatalogController@index')->name('catalog.index');
Route::get('/catalog/category/{category}', 'CatalogController@category')-
>name('catalog.category');
Route::get('/catalog/brand/{brand}', 'CatalogController@brand')-
>name('catalog.brand');
Route::get('/catalog/product/{product}', 'CatalogController@product')-
>name('catalog.product');
Фреймворк по умолчанию берёт параметр {brand} из маршрута и ищет запись в таблице БД brands по
полю id. Чтобы изменить поле, по которому идет поиск записи в таблице БД, можно поступить так:
Route::get('/catalog/index', 'CatalogController@index')->name('catalog.index');
Route::get('/catalog/category/{category:slug}', 'CatalogController@category')-
>name('catalog.category');
Route::get('/catalog/brand/{brand:slug}', 'CatalogController@brand')-
>name('catalog.brand');
Route::get('/catalog/product/{product:slug}', 'CatalogController@product')-
>name('catalog.product');
Теперь поиск записи будет по уникальному полю slug. Но осталось еще исправить имя параметра в
шаблонах — заменить slug на category, brand или product. Сильно жалею, что не сделал с самого
начала, так что теперь много мутороной работы. Надо найти все вызовы функции route() и заменить
имя параметра.
<a href="{{ route('catalog.brand', ['slug' => $brand->slug]) }}">{{ $brand->name
}}</a>
<a href="{{ route('catalog.brand', ['brand' => $brand->slug]) }}">{{ $brand->name
}}</a>
<a href="{{ route('catalog.category', ['slug' => $category->slug]) }}">{{
$category->name }}</a>
<a href="{{ route('catalog.category', ['category' => $category->slug]) }}">{{
$category->name }}</a>
<a href="{{ route('catalog.product', ['slug' => $product->slug]) }}">{{ $product-
>name }}</a>
<a href="{{ route('catalog.product', ['product' => $product->slug]) }}">{{
$product->name }}</a>
Еще лучше — вообще не указывать имя параметра, чтобы больше с этим никогда не сталкиваться, если
возникнет необходимость изменить имя параметра маршртута.
1. Дочерние категории
Для каждой категории каталога будем показывать дочерние категории для удобства навигации. Кроме
того, для каждой категории будем показывать товары не только этой категории, но и товары,
принадлежащие потомкам этой категории. Для этого изменим
метод category() контроллера CatalogController.
class CatalogController extends Controller {
public function category(Category $category) {
// получаем всех потомков этой категории
$descendants = $category->getAllChildren($category->id);
$descendants[] = $category->id;
// товары этой категории и всех потомков
$products = Product::whereIn('category_id', $descendants)->paginate(6);
return view('catalog.category', compact('category', 'products'));
}
}
Шаблон для показа категории views/catalog/category.blade.php:
@extends('layout.site', ['title' => $category->name])
@section('content')
<h1>{{ $category->name }}</h1>
<p>{{ $category->content }}</p>
<div class="row">
@foreach ($category->children as $child)
@include('catalog.part.category', ['category' => $child])
@endforeach
</div>
<h5 class="bg-info text-white p-2 mb-4">Товары раздела</h5>
<div class="row">
@foreach ($products as $product)
@include('catalog.part.product', ['product' => $product])
@endforeach
</div>
{{ $products->links() }}
@endsection
Вспомогательный шаблон views/catalog/part/category.blade.php:
<div class="col-md-4 mb-4">
<div class="card list-item">
<div class="card-header">
<h3 class="mb-0">{{ $category->name }}</h3>
</div>
<div class="card-body p-0">
@if ($category->image)
@php($url = url('storage/catalog/category/thumb/' . $category-
>image))
<img src="{{ $url }}" class="img-fluid" alt="">
@else
<img src="https://via.placeholder.com/300x150" class="img-fluid"
alt="">
@endif
</div>
<div class="card-footer">
<a href="{{ route('catalog.category', ['category' => $category->slug])
}}"
class="btn btn-dark">Товары раздела</a>
</div>
</div>
</div>
Вспомогательный шаблон views/catalog/part/product.blade.php:
<div class="col-md-4 mb-4">
<div class="card list-item">
<div class="card-header">
<h3 class="mb-0">{{ $product->name }}</h3>
</div>
<div class="card-body p-0">
@if ($product->image)
@php($url = url('storage/catalog/product/thumb/' . $product-
>image))
<img src="{{ $url }}" class="img-fluid" alt="">
@else
<img src="https://via.placeholder.com/300x150" class="img-fluid"
alt="">
@endif
</div>
<div class="card-footer">
<!-- Форма для добавления товара в корзину -->
<form action="{{ route('basket.add', ['id' => $product->id]) }}"
method="post" class="d-inline">
@csrf
<button type="submit" class="btn btn-success">В корзину</button>
</form>
<a href="{{ route('catalog.product', ['product' => $product->slug]) }}"
class="btn btn-dark float-right">Смотреть</a>
</div>
</div>
</div>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque ducimus,
eligendi exercitationem expedita,
iure iusto laborum magnam qui quidem repellat similique tempora tempore ullam!
Deserunt doloremque impedit
quis repudiandae voluptas.
</p>
@section('content')
<h1>{{ $brand->name }}</h1>
<p>{{ $brand->content }}</p>
<h5 class="bg-info text-white p-1 mb-4">Товары бренда</h5>
<div class="row">
@foreach ($products as $product)
@include('catalog.part.product', ['product' => $product])
@endforeach
</div>
{{ $products->links() }}
@endsection
Добавление в корзину
Мы сейчас добавляем товар в корзину с перезагрузкой страницы, что выглядит совсем уж архаично.
Давайте это изменим и будем отправлять на сервер ajax-запрос. Для этого во всех формах добавления в
корзину добавим css-класс add-to-basket.
<!-- Форма для добавления товара в корзину -->
<form action="{{ route('basket.add', ['id' => $product->id]) }}"
method="post" class="form-inline add-to-basket">
@csrf
<label for="input-quantity">Количество</label>
<input type="text" name="quantity" id="input-quantity" value="1"
class="form-control mx-2 w-25">
<button type="submit" class="btn btn-success">Добавить в корзину</button>
</form>
<!-- Форма для добавления товара в корзину -->
<form action="{{ route('basket.add', ['id' => $product->id]) }}"
method="post" class="d-inline add-to-basket">
@csrf
<button type="submit" class="btn btn-success">В корзину</button>
</form>
Теперь редактируем файл js-скрипта site.js:
jQuery(document).ready(function($) {
/*
* Общие настройки ajax-запросов, отправка на сервер csrf-токена
*/
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
/*
* Раскрытие и скрытие пунктов меню каталога в левой колонке
*/
$('#catalog-sidebar > ul ul').hide();
$('#catalog-sidebar .badge').on('click', function () {
/* ... */
});
/*
* Получение данных профиля пользователя при оформлении заказа
*/
$('form#profiles button[type="submit"]').hide();
// при выборе профиля отправляем ajax-запрос, чтобы получить данные
$('form#profiles select').change(function () {
/* ... */
});
/*
* Добавление товара в корзину с помощью ajax-запроса без перезагрузки
*/
$('form.add-to-basket').submit(function (e) {
// отменяем отправку формы стандартным способом
e.preventDefault();
// получаем данные этой формы добавления в корзину
var $form = $(this);
var data = new FormData($form[0]);
$.ajax({
url: $form.attr('action'),
data: data,
processData: false,
contentType: false,
type: 'POST',
dataType: 'HTML',
beforeSend: function () {
var spinner = ' <span class="spinner-border spinner-border-
sm"></span>';
$form.find('button').append(spinner);
},
success: function(html) {
$form.find('.spinner-border').remove();
$('#top-basket').html(html);
}
});
});
});
И изменяем метод add() контроллера BasketController, чтобы он мог обрабатывать и ajax-запросы.
class BasketController extends Controller {
/**
* Добавляет товар с идентификатором $id в корзину
*/
public function add(Request $request, $id) {
$quantity = $request->input('quantity') ?? 1;
$this->basket->increase($id, $quantity);
if ( ! $request->ajax()) {
// выполняем редирект обратно на ту страницу,
// где была нажата кнопка «В корзину»
return back();
}
// в случае ajax-запроса возвращаем html-код корзины в правом
// верхнем углу, чтобы заменить исходный html-код, потому что
// теперь количество позиций будет другим
$positions = $this->basket->products->count();
return view('basket.part.basket', compact('positions'));
}
}
Нам еще потребуется малюсенький шаблон views/basket/part/basket.blade.php:
<a class="nav-link @if ($positions) text-success @endif"
href="{{ route('basket.index') }}">
Корзина
@if ($positions) ({{ $positions }}) @endif
</a>
Этот фрагмент html-кода будет отправляться клиенту при ajax-запросе. А наш js-код этот фрагмент html-
кода будет вставлять внутрь элемента #top-basket. Как нетрудно догадаться, идентификатор #top-
basket надо прописать в layout-шаблоне site.blade.php.
<!-- Этот блок расположен справа -->
<ul class="navbar-nav ml-auto">
<li class="nav-item" id="top-basket">
<a class="nav-link @if ($positions) text-success @endif"
href="{{ route('basket.index') }}">
Корзина
@if ($positions) ({{ $positions }}) @endif
</a>
</li>
@guest
<!-- ..... -->
@else
<!-- ..... -->
@endif
</ul>
/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('new');
$table->dropColumn('hit');
$table->dropColumn('sale');
});
}
}
> php artisan migrate
Изменяем форму
Изменяем шаблон создания-редактирования товара. У меня почему-то не оказалось поля price на
форме, так что сразу его добавим.
@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">
<!-- цена (руб) -->
<input type="text" class="form-control w-25 d-inline mr-4" placeholder="Цена
(руб.)"
name="price" required value="{{ old('price') ?? $product->price ?? ''
}}">
<!-- новинка -->
<div class="form-check form-check-inline">
@php
$checked = false; // создание нового товара
if (isset($product)) $checked = $product->new; // редактирование товара
if (old('new')) $checked = true; // были ошибки при заполнении формы
@endphp
<input type="checkbox" name="new" class="form-check-input" id="new-product"
@if($checked) checked @endif value="1">
<label class="form-check-label" for="new-product">Новинка</label>
</div>
<!-- лидер продаж -->
<div class="form-check form-check-inline">
@php
$checked = false; // создание нового товара
if (isset($product)) $checked = $product->hit; // редактирование товара
if (old('hit')) $checked = true; // были ошибки при заполнении формы
@endphp
<input type="checkbox" name="hit" class="form-check-input" id="hit-product"
@if($checked) checked @endif value="1">
<label class="form-check-label" for="hit-product">Лидер продаж</label>
</div>
<!-- распродажа -->
<div class="form-check form-check-inline ">
@php
$checked = false; // создание нового товара
if (isset($product)) $checked = $product->sale; // редактирование
товара
if (old('sale')) $checked = true; // были ошибки при заполнении формы
@endphp
<input type="checkbox" name="sale" class="form-check-input" id="sale-
product"
@if($checked) checked @endif value="1">
<label class="form-check-label" for="sale-product">Распродажа</label>
</div>
</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="Бренд" required>
<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>
Правила валидации
Добавляем правила валидации для поля price:
namespace App\Http\Requests;
/**
* С какой сущностью сейчас работаем (товар каталога)
* @var array
*/
protected $entity = [
'name' => 'product',
'table' => 'products'
];
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize() {
return parent::authorize();
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules() {
return parent::rules();
}
/**
* Объединяет дефолтные правила и правила, специфичные для товара
* для проверки данных при добавлении нового товара
*/
protected function createItem() {
$rules = [
'category_id' => [
'required',
'integer',
'min:1'
],
'brand_id' => [
'required',
'integer',
'min:1'
],
'price' => [
'required',
'numeric',
'min:1'
],
];
return array_merge(parent::createItem(), $rules);
}
/**
* Объединяет дефолтные правила и правила, специфичные для товара
* для проверки данных при обновлении существующего товара
*/
protected function updateItem() {
$rules = [
'category_id' => [
'required',
'integer',
'min:1'
],
'brand_id' => [
'required',
'integer',
'min:1'
],
'price' => [
'required',
'numeric',
'min:1'
],
];
return array_merge(parent::updateItem(), $rules);
}
}
Задаем сообщения об ошибках в файле lang/ru/validation.php:
return [
'custom' => [
/* ... */
'category_id' => [
'required' => 'Поле «:attribute» обязательно для заполнения',
'integer' => 'Поле «:attribute» должно быть целым положительным
числом',
'min' => 'Поле «:attribute» обязательно для заполнения',
],
'brand_id' => [
'required' => 'Поле «:attribute» обязательно для заполнения',
'integer' => 'Поле «:attribute» должно быть целым положительным
числом',
'min' => 'Поле «:attribute» обязательно для заполнения',
],
'price' => [
'required' => 'Поле «:attribute» обязательно для заполнения',
'numeric' => 'Поле «:attribute» должно быть положительным числом',
'min' => 'Поле «:attribute» не может быть меньше :min',
],
],
'attributes' => [
'name' => 'Имя, Фамилия',
'slug' => 'ЧПУ (англ)',
'email' => 'Адрес почты',
'password' => 'Пароль',
'password_confirmation' => 'Подтверждение пароля',
'address' => 'Адрес доставки',
'phone' => 'Номер телефона',
/* ... */
'parent_id' => 'Родитель',
'category_id' => 'Категория',
'brand_id' => 'Бренд',
'price' => 'Цена',
],
]
Контроллер в админке
Изменяем методы store() и update() контроллера ProductController:
class ProductController extends Controller {
/**
* Сохраняет новый товар в базу данных
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(ProductCatalogRequest $request) {
$request->merge([
'new' => $request->has('new'),
'hit' => $request->has('hit'),
'sale' => $request->has('sale'),
]);
$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 \Illuminate\Http\Request $request
* @param \App\Models\Product $product
* @return \Illuminate\Http\Response
*/
public function update(ProductCatalogRequest $request, Product $product) {
$request->merge([
'new' => $request->has('new'),
'hit' => $request->has('hit'),
'sale' => $request->has('sale'),
]);
$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', 'Товар был успешно обновлен');
}
}
Разрешаем «mass assigment» для модели Product:
class Product extends Model {
protected $fillable = [
'category_id',
'brand_id',
'name',
'slug',
'content',
'image',
'price',
'new',
'hit',
'sale',
];
/* ... */
}
Главная страница
Теперь на главной странице сайта можем показать новинки, лидеров продаж и товары распродажи:
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
@section('content')
<h1>Интернет-магазин</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto autem
distinctio
dolorum ducimus earum eligendi est eum eveniet excepturi exercitationem
explicabo facilis
fuga hic illum ipsam libero modi, nobis odio, officia officiis optio quae
quibusdam
reiciendis repellendus sed sunt tenetur, voluptatum. Ab adipisci aperiam
esse iure neque
quis repellendus temporibus.
</p>
@if($new->count())
<h2>Новинки</h2>
<div class="row">
@foreach($new as $item)
@include('catalog.part.product', ['product' => $item])
@endforeach
</div>
@endif
@if($hit->count())
<h2>Лидеры продаж</h2>
<div class="row">
@foreach($hit as $item)
@include('catalog.part.product', ['product' => $item])
@endforeach
</div>
@endif
@if($sale->count())
<h2>Распродажа</h2>
<div class="row">
@foreach($sale as $item)
@include('catalog.part.product', ['product' => $item])
@endforeach
</div>
@endif
@endsection
Добавим еще бейдж на фото товара, чтобы было видно, что это новинка, лидер продаж или товар
распродажи. Во-первых, для списка товаров, это шаблон views/catalog/part/product.blade.php:
<div class="col-md-4 mb-4">
<div class="card list-item">
<div class="card-header">
<h3 class="mb-0">{{ $product->name }}</h3>
</div>
<div class="card-body p-0 position-relative">
<div class="position-absolute">
@if($product->new)
<span class="badge badge-info text-white ml-1">Новинка</span>
@endif
@if($product->hit)
<span class="badge badge-danger ml-1">Лидер продаж</span>
@endif
@if($product->sale)
<span class="badge badge-success ml-1">Распродажа</span>
@endif
</div>
@if($product->image)
@php($url = url('storage/catalog/product/thumb/' . $product-
>image))
<img src="{{ $url }}" class="img-fluid" alt="">
@else
<img src="https://via.placeholder.com/300x150" class="img-fluid"
alt="">
@endif
</div>
<div class="card-footer">
<!-- Форма для добавления товара в корзину -->
<form action="{{ route('basket.add', ['id' => $product->id]) }}"
method="post" class="d-inline add-to-basket">
@csrf
<button type="submit" class="btn btn-success">В корзину</button>
</form>
<a href="{{ route('catalog.product', ['product' => $product->slug]) }}"
class="btn btn-dark float-right">Смотреть</a>
</div>
</div>
</div>
Во-вторых, на карточке товара, это шаблон views/catalog/product.blade.php:
@extends('layout.site')
@section('content')
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h1>{{ $product->name }}</h1>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 position-relative">
<div class="position-absolute">
@if($product->new)
<span class="badge badge-info text-white ml-
1">Новинка</span>
@endif
@if($product->hit)
<span class="badge badge-danger ml-1">Лидер
продаж</span>
@endif
@if($product->sale)
<span class="badge badge-success ml-
1">Распродажа</span>
@endif
</div>
@if($product->image)
@php($url = url('storage/catalog/product/image/' .
$product->image))
<img src="{{ $url }}" alt="" class="img-fluid">
@else
<img src="https://via.placeholder.com/600x300" alt=""
class="img-fluid">
@endif
</div>
<div class="col-md-6">
<p>Цена: {{ number_format($product->price, 2, '.', '')
}}</p>
<!-- Форма для добавления товара в корзину -->
<form action="{{ route('basket.add', ['id' => $product-
>id]) }}"
method="post" class="form-inline add-to-basket">
@csrf
<label for="input-quantity">Количество</label>
<input type="text" name="quantity" id="input-quantity"
value="1"
class="form-control mx-2 w-25">
<button type="submit" class="btn btn-success">
Добавить в корзину
</button>
</form>
</div>
</div>
<div class="row">
<div class="col-12">
<p class="mt-4 mb-0">{{ $product->content }}</p>
</div>
</div>
</div>
<div class="card-footer">
<div class="row">
<div class="col-md-6">
@isset($product->category)
Категория:
<a href="{{ route('catalog.category', ['category' =>
$product->category->slug]) }}">
{{ $product->category->name }}
</a>
@endisset
</div>
<div class="col-md-6 text-right">
@isset($product->brand)
Бренд:
<a href="{{ route('catalog.brand', ['brand' => $product-
>brand->slug]) }}">
{{ $product->brand->name }}
</a>
@endisset
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
Заказы пользователя
Хотелось бы еще предоставить пользователям возможность просмотра истории заказов в личном
кабинете. Создадим контроллер OrderController, добавим два новых маршрута и создадим два новых
шаблона.
> php artisan make:controller OrderController
namespace App\Http\Controllers;
use App\Models\Order;
Route::group([
'as' => 'user.', // имя маршрута, например user.index
'prefix' => 'user', // префикс маршрута, например user/index
'middleware' => ['auth'] // один или несколько посредников
], function () {
// главная страница личного кабинета пользователя
Route::get('index', 'UserController@index')->name('index');
// CRUD-операции над профилями пользователя
Route::resource('profile', 'ProfileController');
// просмотр списка заказов в личном кабинете
Route::get('order', 'OrderController@index')->name('order.index');
// просмотр отдельного заказа в личном кабинете
Route::get('order/{order}', 'OrderController@show')->name('order.show');
});
Два новых шаблона user.order.index и user.order.show:
@extends('layout.site', ['title' => 'Ваши заказы'])
@section('content')
<h1>Ваши заказы</h1>
@if($orders->count())
<table class="table table-bordered">
<tr>
<th width="2%">№</th>
<th width="19%">Дата и время</th>
<th width="13%">Статус</th>
<th width="19%">Покупатель</th>
<th width="24%">Адрес почты</th>
<th width="21%">Номер телефона</th>
<th width="2%"><i class="fas fa-eye"></i></th>
</tr>
@foreach($orders as $order)
<tr>
<td>{{ $order->id }}</td>
<td>{{ $order->created_at->format('d.m.Y H:i') }}</td>
<td>{{ $statuses[$order->status] }}</td>
<td>{{ $order->name }}</td>
<td><a href="mailto:{{ $order->email }}">{{ $order->email
}}</a></td>
<td>{{ $order->phone }}</td>
<td>
<a href="{{ route('user.order.show', ['order' => $order->id])
}}">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
@endforeach
</table>
{{ $orders->links() }}
@else
<p>Заказов пока нет</p>
@endif
@endsection
@section('content')
<h1>Данные по заказу № {{ $order->id }}</h1>
В шаблоне главной страницы личного кабинета создадим ссылки для просмотра профилей и заказов:
@section('content')
<h1>Личный кабинет</h1>
<p>Добро пожаловать, {{ auth()->user()->name }}!</p>
<p>Это личный кабинет постоянного покупателя нашего интернет-магазина.</p>
<ul>
<li><a href="{{ route('user.profile.index') }}">Ваши профили</a></li>
<li><a href="{{ route('user.order.index') }}">Ваши заказы</a></li>
</ul>
<form action="{{ route('user.logout') }}" method="post">
@csrf
<button type="submit" class="btn btn-primary">Выйти</button>
</form>
@endsection
@section('content')
<h1>{{ $category->name }}</h1>
<p>{{ $category->content }}</p>
<div class="row">
@foreach ($category->children as $child)
@include('catalog.part.category', ['category' => $child])
@endforeach
</div>
<div class="bg-info p-2 mb-4">
<!-- Фильтр для товаров категории -->
<form method="get"
action="{{ route('catalog.category', ['category' => $category->slug])
}}">
@include('catalog.part.filter')
<a href="{{ route('catalog.category', ['category' => $category->slug])
}}"
class="btn btn-light">Сбросить</a>
</form>
</div>
<div class="row">
@foreach ($products as $product)
@include('catalog.part.product', ['product' => $product])
@endforeach
</div>
{{ $products->links() }}
@endsection
<!-- цена (руб) -->
<select name="price" class="form-control d-inline w-25 mr-4" title="Цена">
<option value="0">Выберите цену</option>
<option value="min"@if(request()->price == 'min') selected @endif>Дешевые
товары</option>
<option value="max"@if(request()->price == 'max') selected @endif>Дорогие
товары</option>
</select>
<!-- новинка -->
<div class="form-check form-check-inline">
<input type="checkbox" name="new" class="form-check-input" id="new-product"
@if(request()->has('new')) checked @endif value="yes">
<label class="form-check-label" for="new-product">Новинка</label>
</div>
<!-- лидер продаж -->
<div class="form-check form-check-inline">
<input type="checkbox" name="hit" class="form-check-input" id="hit-product"
@if(request()->has('hit')) checked @endif value="yes">
<label class="form-check-label" for="hit-product">Лидер продаж</label>
</div>
<!-- распродажа -->
<div class="form-check form-check-inline ">
<input type="checkbox" name="sale" class="form-check-input" id="sale-product"
@if(request()->has('sale')) checked @endif value="yes">
<label class="form-check-label" for="sale-product">Распродажа</label>
</div>
<button type="submit" class="btn btn-light">Фильтровать</button>
Изменим метод category() контроллера CatalogController:
class CatalogController extends Controller {
/* ... */
public function category(Request $request, Category $category) {
$descendants = $category->getAllChildren($category->id);
$descendants[] = $category->id;
$builder = Product::whereIn('category_id', $descendants);
// дешевые или дорогие товары
if ($request->has('price') && in_array($request->price, ['min', 'max'])) {
$products = $builder->get();
$count = $products->count();
if ($count > 1) {
$max = $builder->get()->max('price'); // цена самого дорогого
товара
$min = $builder->get()->min('price'); // цена самого дешевого
товара
$avg = ($min + $max) * 0.5;
if ($request->price == 'min') {
$builder->where('price', '<=', $avg);
} else {
$builder->where('price', '>=', $avg);
}
}
}
// отбираем только новинки
if ($request->has('new')) {
$builder->where('new', true);
}
// отбираем только лидеров продаж
if ($request->has('hit')) {
$builder->where('hit', true);
}
// отбираем только со скидкой
if ($request->has('sale')) {
$builder->where('sale', true);
}
$products = $builder->paginate(6)->withQueryString();
return view('catalog.category', compact('category', 'products'));
}
/* ... */
}
Фильтр по цене вычисляет среднюю цену товара в категории как среднее арифметическое максимальной
и минимальной цены. Если выбраны дешевые товары — будут показаны товары, у которых цена меньше
или равна средней арифметической, если выбраны дорогие товары — будут показаны товары, у которых
цена меньше или равна средней арифметической.
У этого алгоритма есть недостаток — если в категории большинство товаров дешевые (например —
100.00, 200.00, 300.00 и 400.рублей), а дорогих товаров мало (например — только один 1000.00 рублей),
то средняя цена будет 550.00 рублей. Когда выбраны дешевые товары — будут показано четыре товара,
а когда дорогие — только один.
Возможно, есть смысл вычислить среднюю цену как среднее арифметическре всех цен в разделе
каталога, тогда распределение по дешевым и дорогим будет более равномерным. Еще один алгоритм
отбора дешевых и дорогих товаров — делить их ровно пополам. То есть 50% товаров попадают в
дешевые, а еще 50% — попадают в дорогие.
$products = $builder->paginate(6)->withQueryString();
return view('catalog.category', compact('category', 'products'));
}
/* ... */
}
Рефакторинг кода
Теперь метод category() контроллера выглядит запутанно, и выполняет слишком много работы.
Давайте вынесем фильтрацию товаров в отдельный класс app/Helpers/ProductFilter:
namespace App\Helpers;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\Builder;
class ProductFilter {
private $builder;
private $request;
Используем скоупы
Сейчас, чтобы получить товары категории, мы используем следующий код:
Кажется, что в этом нет особого смысла, но давайте добавим еще один скоуп:
$products = $builder->paginate(6)->withQueryString();
/**
* Позволяет фильтровать товары по нескольким условиям
*
* @param \Illuminate\Database\Eloquent\Builder $builder
* @param $filters
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeFilterProducts($builder, $filters) {
return $filters->apply($builder);
}
/* ... */
}
Мы внедряем экземпляр класса ProductFilter в метод category() контроллера. Создаем экземпляр
построителя запроса, используя скоуп scopeCategoryProducts() и отбираем товары категории. Но
скоуп scopeFilterProducts() используем немного не так, как принято делать. Вместо того, чтобы
добавить условие where() — вызываем метод apply() класса ProductFilter. Который вернет нам все
тот же объект построителя запроса, к которому добавлено несколько условий where() — это наши
фильтры.
class ProductFilter {
private $builder;
private $request;
/**
* Позволяет выбирать товары категории и всех ее потомков
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param integer $id
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeCategoryProducts($query, $id) {
$descendants = Category::getAllChildren($id);
$descendants[] = $id;
return $query->whereIn('category_id', $descendants);
}
/**
* Позволяет фильтровать товары по нескольким условиям
*
* @param \Illuminate\Database\Eloquent\Builder $builder
* @param \App\Helpers\ProductFilter $filters
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeFilterProducts($builder, $filters) {
return $filters->apply($builder);
}
/* ... */
}
Вот теперь метод category() контроллера выглядит вполне прилично:
class CatalogController extends Controller {
/* ... */
public function category(Category $category, ProductFilter $filters) {
$products = Product::categoryProducts($category->id)
->filterProducts($filters)
->paginate(6)
->withQueryString();
return view('catalog.category', compact('category', 'products'));
}
/* ... */
}
@section('content')
<h1>{{ $brand->name }}</h1>
<p>{{ $brand->content }}</p>
<div class="bg-info p-2 mb-4">
<!-- Фильтр для товаров бренда -->
<form method="get"
action="{{ route('catalog.brand', ['brand' => $brand->slug]) }}">
@include('catalog.part.filter')
<a href="{{ route('catalog.brand', ['brand' => $brand->slug]) }}"
class="btn btn-light">Сбросить</a>
</form>
</div>
<div class="row">
@foreach ($products as $product)
@include('catalog.part.product', ['product' => $product])
@endforeach
</div>
{{ $products->links() }}
@endsection
И изменяем метод brand() контроллера CatalogController:
class CatalogController extends Controller {
/* ... */
public function brand(Brand $brand, ProductFilter $filters) {
$products = $brand
->products() // возвращает построитель запроса
->filterProducts($filters)
->paginate(6)
->withQueryString();
return view('catalog.brand', compact('brand', 'products'));
}
/* ... */
}
/*
* Страницы «Доставка», «Контакты» и прочие
*/
Route::get('/page/{page:slug}', 'PageController')->name('page.show');
/*
* Каталог товаров: категория, бренд и товар
*/
Route::group([
'as' => 'catalog.', // имя маршрута, например catalog.index
'prefix' => 'catalog', // префикс маршрута, например catalog/index
], function () {
// главная страница каталога
Route::get('index', 'CatalogController@index')
->name('index');
// категория каталога товаров
Route::get('category/{category:slug}', 'CatalogController@category')
->name('category');
// бренд каталога товаров
Route::get('brand/{brand:slug}', 'CatalogController@brand')
->name('brand');
// страница товара каталога
Route::get('product/{product:slug}', 'CatalogController@product')
->name('product');
});
/*
* Корзина покупателя
*/
Route::group([
'as' => 'basket.', // имя маршрута, например basket.index
'prefix' => 'basket', // префикс маршрута, например bsaket/index
], function () {
// список всех товаров в корзине
Route::get('index', 'BasketController@index')
->name('index');
// страница с формой оформления заказа
Route::get('checkout', 'BasketController@checkout')
->name('checkout');
// получение данных профиля для оформления
Route::post('profile', 'BasketController@profile')
->name('profile');
// отправка данных формы для сохранения заказа в БД
Route::post('saveorder', 'BasketController@saveOrder')
->name('saveorder');
// страница после успешного сохранения заказа в БД
Route::get('success', 'BasketController@success')
->name('success');
// отправка формы добавления товара в корзину
Route::post('add/{id}', 'BasketController@add')
->where('id', '[0-9]+')
->name('add');
// отправка формы изменения кол-ва отдельного товара в корзине
Route::post('plus/{id}', 'BasketController@plus')
->where('id', '[0-9]+')
->name('plus');
// отправка формы изменения кол-ва отдельного товара в корзине
Route::post('minus/{id}', 'BasketController@minus')
->where('id', '[0-9]+')
->name('minus');
// отправка формы удаления отдельного товара из корзины
Route::post('remove/{id}', 'BasketController@remove')
->where('id', '[0-9]+')
->name('remove');
// отправка формы для удаления всех товаров из корзины
Route::post('clear', 'BasketController@clear')
->name('clear');
});
/*
* Регистрация, вход в ЛК, восстановление пароля
*/
Route::name('user.')->prefix('user')->group(function () {
Auth::routes();
});
/*
* Личный кабинет зарегистрированного пользователя
*/
Route::group([
'as' => 'user.', // имя маршрута, например user.index
'prefix' => 'user', // префикс маршрута, например user/index
'middleware' => ['auth'] // один или несколько посредников
], function () {
// главная страница личного кабинета пользователя
Route::get('index', 'UserController@index')->name('index');
// CRUD-операции над профилями пользователя
Route::resource('profile', 'ProfileController');
// просмотр списка заказов в личном кабинете
Route::get('order', 'OrderController@index')->name('order.index');
// просмотр отдельного заказа в личном кабинете
Route::get('order/{order}', 'OrderController@show')->name('order.show');
});
/*
* Панель управления магазином для администратора сайта
*/
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');
// просмотр и редактирование заказов
Route::resource('order', 'OrderController', ['except' => [
'create', 'store', 'destroy'
]]);
// просмотр и редактирование пользователей
Route::resource('user', 'UserController', ['except' => [
'create', 'store', 'show', 'destroy'
]]);
// CRUD-операции над страницами сайта
Route::resource('page', 'PageController');
// загрузка изображения из wysiwyg-редактора
Route::post('page/upload/image', 'PageController@uploadImage')
->name('page.upload.image');
// удаление изображения в wysiwyg-редакторе
Route::delete('page/remove/image', 'PageController@removeImage')
->name('page.remove.image');
});