Вы находитесь на странице: 1из 203

Магазин на Laravel 7, часть 1.

Создание таблиц БД,


заполнение начальными данными
Создание таблиц БД
Начнем с каталога товаров. Нам потребуются три таблицы в базе данных для хранения категорий,
брендов и товаров. Подключаемся к серверу БД и создаем новую базу данных larashop. После этого
создаем три модели — Product, Category и Brand — вместе с файлами миграции. Отредактируем
файлы классов миграций, чтобы наши таблицы содержали все необходимые поля.
> php artisan make:model Category -m
> php artisan make:model Brand -m
> php artisan make:model Product -m
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateCategoriesTable extends Migration {


/**
* Run the migrations.
*
* @return void
*/
public function up() {
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('parent_id')->nullable(false)->default(0);
$table->string('name', 100);
$table->string('content', 200)->nullable();
$table->string('slug', 100)->unique();
$table->string('image', 50)->nullable();
$table->timestamps();
});
}

/**
* 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;

class CreateBrandsTable extends Migration {


/**
* Run the migrations.
*
* @return void
*/
public function up() {
Schema::create('brands', function (Blueprint $table) {
$table->id();
$table->string('name', 100);
$table->string('content', 200)->nullable();
$table->string('slug', 100)->unique();
$table->string('image', 50)->nullable();
$table->timestamps();
});
}

/**
* 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;

class CreateProductsTable extends Migration {


/**
* Run the migrations.
*
* @return void
*/
public function up() {
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->bigInteger('category_id')->unsigned()->nullable();
$table->bigInteger('brand_id')->unsigned()->nullable();
$table->string('name', 100);
$table->text('content')->nullable();
$table->string('slug', 100)->unique();
$table->string('image', 50)->nullable();
$table->decimal('price', 10, 2, true)->default(0);
$table->timestamps();

// внешний ключ, ссылается на поле id таблицы categories


$table->foreign('category_id')
->references('id')
->on('categories')
->nullOnDelete();
// внешний ключ, ссылается на поле id таблицы brands
$table->foreign('brand_id')
->references('id')
->on('brands')
->nullOnDelete();
});
}

/**
* 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

Теперь все готово к миграции, создаем таблицы базы данных с помощью команды:

> php artisan migrate


Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated: 2014_10_12_000000_create_users_table (0.03 seconds)
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated: 2014_10_12_100000_create_password_resets_table (0.03 seconds)
Migrating: 2019_08_19_000000_create_failed_jobs_table
Migrated: 2019_08_19_000000_create_failed_jobs_table (0.02 seconds)
Migrating: 2020_09_28_130327_create_categories_table
Migrated: 2020_09_28_130327_create_categories_table (0.03 seconds)
Migrating: 2020_09_28_130335_create_brands_table
Migrated: 2020_09_28_130335_create_brands_table (0.03 seconds)
Migrating: 2020_09_28_130346_create_products_table
Migrated: 2020_09_28_130346_create_products_table (0.1 seconds)
--
-- Структура таблицы `users`
--
CREATE TABLE `users` (
`id` bigint(20) UNSIGNED NOT NULL,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`email_verified_at` timestamp NULL DEFAULT NULL,
`password` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`remember_token` varchar(100) 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;

--
-- Индексы таблицы `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;

class DatabaseSeeder extends Seeder {


/**
* Seed the application's database.
*
* @return void
*/
public function run() {
$this->call(CategoryTableSeeder::class);
$this->command->info('Таблица категорий загружена данными!');

$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;

$factory->define(Category::class, function (Faker $faker) {


$name = $faker->realText(rand(30, 40));
return [
'name' => $name,
'content' => $faker->realText(rand(150, 200)),
'slug' => Str::slug($name),
];
});
<?php
/** @var \Illuminate\Database\Eloquent\Factory $factory */

use App\Brand;
use Illuminate\Support\Str;
use Faker\Generator as Faker;

$factory->define(Brand::class, function (Faker $faker) {


$name = $faker->realText(rand(20, 30));
return [
'name' => $name,
'content' => $faker->realText(rand(150, 200)),
'slug' => Str::slug($name),
];
});
<?php
/** @var \Illuminate\Database\Eloquent\Factory $factory */

use App\Product;
use Illuminate\Support\Str;
use Faker\Generator as Faker;

$factory->define(Product::class, function (Faker $faker) {


$name = $faker->realText(rand(40, 50));
return [
'category_id' => rand(1, 4),
'brand_id' => rand(1, 4),
'name' => $name,
'content' => $faker->realText(rand(400, 500)),
'slug' => Str::slug($name),
'price' => rand(1000, 2000),
];
});
Осталось только отредактировать файлы
классов CategoryTableSeeder, BrandTableSeeder и ProductTableSeeder:
<?php
use Illuminate\Database\Seeder;

class CategoryTableSeeder extends Seeder {


public function run() {
// создать 4 категории
factory(App\Category::class, 4)->create();
}
}
<?php
use Illuminate\Database\Seeder;

class BrandTableSeeder extends Seeder {


public function run() {
// создать 4 бренда
factory(App\Brand::class, 4)->create();
}
}
<?php
use Illuminate\Database\Seeder;

class ProductTableSeeder extends Seeder {


public function run() {
// создать 12 товаров
factory(App\Product::class, 12)->create();
}
}

Заполняем таблицы базы данных начальными данными:

> php artisan migrate:fresh --seed

Магазин на Laravel 7, часть 2. Создание контроллера и


шаблонов, добавление маршрутов

Теперь создаем контроллер CatalogController,


шаблоны index.blade.php, category.blade.php, brand.blade.php, product.blade.php и добавляем
необходимые маршруты. Маршртутов для начала у нас будет пять: главная страница сайта, страница
каталога, страница категории, страница бренда и карточка товара.

Добавляем маршртуры
Добавляем необходимые маршруты:

<?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;

class CatalogController extends Controller {

public function index() {


$roots = Category::where('parent_id', 0)->get();
return view('catalog.index', compact('roots'));
}
public function category($slug) {
$category = Category::where('slug', $slug)->firstOrFail();
$products = Product::where('category_id', $category->id)->get();
return view('catalog.category', compact('category', 'products'));
}

public function brand($slug) {


$brand = Brand::where('slug', $slug)->firstOrFail();
$products = Product::where('brand_id', $brand->id)->get();
return view('catalog.brand', compact('brand', 'products'));
}

public function product($slug) {


$product = Product::select(
'products.*',
'categories.name as category_name',
'categories.slug as category_slug',
'brands.name as brand_name',
'brands.slug as brand_slug'
)
->join('categories', 'products.category_id', '=', 'categories.id')
->join('brands', 'products.brand_id', '=', 'brands.id')
->where('products.slug', $slug)
->firstOrFail();
return view('catalog.product', compact('product'));
}
}

Создаем шаблоны
Шаблон 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. В консоли последовательно запускаем три команды:

> composer require laravel/ui


> php artisan ui bootstrap
> npm install && npm run dev
Шаблон resources/views/layout/site.blade.php:
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0,
maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Магазин</title>
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
<script src="{{ asset('js/app.js') }}"></script>
</head>
<body>

</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. Создание главной страницы
сайта, работа над шаблонами

Контроллер главной страницы


Давайте создадим еще контроллер главной страницы сайта. У этого контроллера будет только одно
действие, а следовательно — только один метод. Создать заготовку такого контроллера можно с
помощью artisan-команды.

> php artisan make:controller IndexController --invokable


<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class IndexController extends Controller {


public function __invoke(Request $request){
return view('index');
}
}

При добавлении маршрута для такого контроллера не нужно указывать метод:

<?php
use Illuminate\Support\Facades\Route;

// маршрут для главной страницы без указания метода


Route::get('/', 'IndexController')->name('index');

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>

<p>{{ $category->content }}</p>

<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>

<p>{{ $brand->content }}</p>

<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

Еще два шаблона


При показе списка товаров всегда используется одинаковый код. Списков у нас два — товары категории
и товары бренда. Но в будущем будет больше — популярные товары, результаты поиска. И если мы
решим что-то изменть для отдельного товара в списке, то исправлять придется в нескольких местах. Так
что давайте вынесем элемент списка в отдельный
шаблон resources/views/catalog/part/product.blade.php.
<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>

Тогда шаблоны списка товаров будут намного проще:

@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>

<p>{{ $brand->content }}</p>

<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;

class Category extends Model {


/**
* Возвращает список товаров выбранной категории
*/
public function getProducts() {
return Product::where('category_id', $this->id)->get();
}
}
namespace App;

use Illuminate\Database\Eloquent\Model;

class Brand extends Model {


/**
* Возвращает список товаров выбранного бренда
*/
public function getProducts() {
return Product::where('brand_id', $this->id)->get();
}
}
namespace App;

use Illuminate\Database\Eloquent\Model;

class Product extends Model {


/**
* Возвращает категорию выбранного товара
*/
public function getCategory() {
return Category::find($this->category_id);
}
/**
* Возвращает бренд выбранного товара
*/
public function getBrand() {
return Brand::find($this->brand_id);
}
}

Теперь наш контроллер выглядит уже лучше:

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'));
}

public function category($slug) {


$category = Category::where('slug', $slug)->firstOrFail();
// получаем товары категории от модели
$products = $category->getProducts();
return view('catalog.category', compact('category', 'products'));
}

public function brand($slug) {


$brand = Brand::where('slug', $slug)->firstOrFail();
// получаем товары бренда от модели
$products = $brand->getProducts();
return view('catalog.brand', compact('brand', 'products'));
}

public function product($slug) {


$product = Product::where('slug', $slug)->firstOrFail();
// получаем от модели категорию и бренд товара
$category = $product->getCategory();
$brand = $product->getBrand();
return view('catalog.product', compact('product', 'category', 'brand'));
}
}

Связи моделей
Но мы пойдем дальше и будем использовать связи моделей:

namespace App;

use Illuminate\Database\Eloquent\Model;

class Category extends Model {


/**
* Связь «один ко многим» таблицы `categories` с таблицей `products`
*/
public function products() {
return $this->hasMany(Product::class);
}
}
namespace App;

use Illuminate\Database\Eloquent\Model;

class Brand extends Model {


/**
* Связь «один ко многим» таблицы `brands` с таблицей `products`
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function products() {
return $this->hasMany(Product::class);
}
}
namespace App;

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;

class CatalogController extends Controller {


public function index() {
$roots = Category::where('parent_id', 0)->get();
return view('catalog.index', compact('roots'));
}

public function category($slug) {


$category = Category::where('slug', $slug)->firstOrFail();
return view('catalog.category', compact('category'));
}

public function brand($slug) {


$brand = Brand::where('slug', $slug)->firstOrFail();
return view('catalog.brand', compact('brand'));
}

public function product($slug) {


$product = Product::where('slug', $slug)->firstOrFail();
return view('catalog.product', compact('product'));
}
}
В шаблоне страницы товара получить доступ к объекту категории товара можно через
свойство category. Аналогично, получить доступ к объекту бренда товара можно через свойство brand.
@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">
@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

Магазин на Laravel 7, часть 5. Создаем корзину


покупателя, добавление товара в корзину
Добавим еще один контроллер BasketController, который будет отвечать за корзину покупателя.
Корзины будем хранить в таблице baskets базы данных. И нам еще потребуется таблица для связи
многие-ко-многим — для товаров и корзин. В одной корзине может быть несколько товаров, один товар
может быть в нескольких корзинах.

Контроллер BasketController
Создаем контроллер BasketController:
> php artisan make:controller BasketController
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class BasketController extends Controller {


public function index() {
return view('basket.index');
}

public function checkout() {


return view('basket.checkout');
}
}

Сразу создадим два шаблона:

@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;

class CreateBasketsTable extends Migration {


public function up() {
Schema::create('baskets', function (Blueprint $table) {
$table->id();
$table->timestamps();
});
}

public function down() {


Schema::dropIfExists('baskets');
}
}
Создаем таблицу для связи baskets и products:
> php artisan make:migration create_basket_product_table
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateBasketProductTable extends Migration {


public function up() {
Schema::create('basket_product', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('basket_id');
$table->unsignedBigInteger('product_id');
$table->unsignedTinyInteger('quantity');

$table->foreign('basket_id')
->references('id')
->on('baskets')
->cascadeOnDelete();
$table->foreign('product_id')
->references('id')
->on('products')
->cascadeOnDelete();
});
}

public function down() {


Schema::dropIfExists('basket_product');
}
}

Задаем связь между таблицами:

namespace App;

use Illuminate\Database\Eloquent\Model;

class Basket extends Model {


/**
* Связь «многие ко многим» таблицы `baskets` с таблицей `products`
*/
public function products() {
return $this->belongsToMany(Product::class)->withPivot('quantity');
}
}
namespace App;

use Illuminate\Database\Eloquent\Model;

class Product extends Model {


/**
* Связь «многие ко многим» таблицы `products` с таблицей `baskets`
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function baskets() {
return $this->belongsToMany(Basket::class)->withPivot('quantity');
}
}

Применяем наши миграции:

> php artisan migrate

Добавление в корзину
Во-первых, нам нужен новый маршрут в файле 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;

class BasketController extends Controller {


public function index() {
return view('basket.index');
}

public function checkout() {


return view('basket.checkout');
}

/**
* Добавляет товар с идентификатором $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;

use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;

class EncryptCookies extends Middleware {


/**
* The names of the cookies that should not be encrypted.
*
* @var array
*/
protected $except = [
'basket_id'
];
}

Но не забываем вернуть все обратно, после того, как закончим работать надо корзиной покупателя.

Содержимое корзины
Надо доработать метод index() контроллера и внести изменения в шаблон, который отвечает за показ
корзины:
namespace App\Http\Controllers;

use App\Basket;
use Illuminate\Http\Request;

class BasketController extends Controller {


/**
* Показывает корзину покупателя
*/
public function index(Request $request) {
$basket_id = $request->cookie('basket_id');
if (!empty($basket_id)) {
$products = Basket::findOrFail($basket_id)->products;
return view('basket.index', compact('products'));
} else {
abort(404);
}
}
/* ... */
}
@extends('layout.site')

@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;

class Basket extends Model {


/**
* Связь «многие ко многим» таблицы `baskets` с таблицей `products`
*/
public function products() {
return $this->belongsToMany(Product::class)->withPivot('quantity');
}

/**
* Увеличивает кол-во товара $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;

class BasketController extends Controller {

private $basket;

public function __construct() {


$this->getBasket();
}

/**
* Показывает корзину покупателя
*/
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-шаблоне надо добавить ссылку на страницу корзины. И нужна кнопка «Добавить в корзину» для
списка товаров.

<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">


<!-- Бренд и кнопка «Гамбургер» -->
<a class="navbar-brand" href="{{ route('index') }}">Магазин</a>
<button class="navbar-toggler" type="button" data-toggle="collapse"
data-target="#navbar-example" aria-controls="navbar-larashop"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<!-- Основная часть меню (может содержать ссылки, формы и прочее) -->
<div class="collapse navbar-collapse" id="navbar-larashop">
<!-- Этот блок расположен слева -->
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link" href="{{ route('catalog.index') }}">Каталог</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Доставка</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Контакты</a>
</li>
</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>

<!-- Этот блок расположен справа -->


<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="{{ route('basket.index') }}">Корзина</a>
</li>
</ul>
</div>
</nav>
<div class="col-md-6 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">
<img src="https://via.placeholder.com/400x120" alt="" class="img-
fluid">
</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', ['slug' => $product->slug]) }}"
class="btn btn-dark float-right">Перейти к товару</a>
</div>
</div>
</div>
Магазин на Laravel 7, часть 7. Меню каталога товаров и
популярные бренды в левой колонке

Два меню с сайдбаре


На всех страницах сайта в левой колонке показывается меню каталога и список популярных брендов.
Это значит, что эти данные мы должны получать всегда, и отправлять их в layout-шаблон. Именно для
таких случаев в Laravel предусмотрено готовое решение — View Composers. Но лучше мы создадим два
маленьких шаблона (roots.blade.php и brands.blade.php) в директории layout/part, чтобы не
прегружать layout-шаблон — и с помощью композера будет отправлять в них данные.
<h4>Разделы каталога</h4>
<ul>
@foreach($items as $item)
<li>
<a href="{{ route('catalog.category', ['slug' => $item->slug]) }}">{{
$item->name }}</a>
</li>
@endforeach
</ul>
<h4>Популярные бренды</h4>
<ul>
@foreach($items as $item)
<li>
<a href="{{ route('catalog.brand', ['slug' => $item->slug]) }}">{{ $item-
>name }}</a>
<span class="badge badge-dark float-right">{{ $item->products_count
}}</span>
</li>
@endforeach
</ul>

И будем подключать эти два шаблона внутри layout-шаблона:


<div class="row">
<div class="col-md-3">
@include('layout.part.roots')
@include('layout.part.brands')
<!--
<h4>Разделы каталога</h4>
<p>Здесь будут корневые разделы</p>
<h4>Популярные бренды</h4>
<p>Здесь будут популярные бренды</p>
-->
</div>
<div class="col-md-9">
@yield('content')
</div>
</div>
Теперь создадим поставщика услуг ComposerServiceProvider:
> php artisan make:provider ComposerServiceProvider
И сразу добавим его в массив providers файла конфигурации config/app.php:
return [
/* ... */
'providers' => [
/* ... */
App\Providers\ComposerServiceProvider::class,
/* ... */
],
/* ... */
];
Далее редактируем созданный app/Providers/ComposerServiceProvider.php:
namespace App\Providers;

use App\Brand;
use App\Category;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;

class ComposerServiceProvider extends ServiceProvider {


/**
* Register services.
*
* @return void
*/
public function register() {
// .....
}

/**
* 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;

class Category extends Model {


/* ... */

/**
* Возвращает список корневых категорий каталога товаров
*/
public static function roots() {
return self::where('parent_id', 0)->get();
}
}
namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

class Brand extends Model {


/* ... */

/**
* Возвращает список популярных брендов каталога товаров.
* Следовало бы отобрать бренды, товары которых продаются
* чаще всего. Но поскольку таких данных у нас еще нет,
* просто получаем 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');
});
}
});
});

Подключим этот js-файл в layout-шаблоне и посмотрим результат:

Жадная загрузка
Сейчас для построения меню у нас выполняется пять запросов к базе данных, которые получают
корневые категории + дочерние категории для каждой корневой.

SELECT * FROM `categories` WHERE `parent_id` = 0


SELECT * FROM `categories` WHERE `categories`.`parent_id` = 1 AND
`categories`.`parent_id` IS NOT NULL
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 2 AND
`categories`.`parent_id` IS NOT NULL
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 3 AND
`categories`.`parent_id` IS NOT NULL
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 4 AND
`categories`.`parent_id` IS NOT NULL
Происходит это из-за так называемой ленивой загрузки данных (отложенная загрузка). Именно она
используется при обращении к виртуальному свойству children, доступному после реализации связи
таблицы categories с таблицей categories. Использовать ленивую загрузку очень удобно, поскольку
она работает только тогда, когда мы ее вызываем.

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

class Category extends Model {


/* ... */

/**
* Возвращает список корневых категорий каталога товаров
*/
public static function roots() {
return self::where('parent_id', 0)->with('children')->get();
}
}

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

SELECT * FROM `categories` WHERE `parent_id` = 0


SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (1, 2, 3, 4)

Магазин на Laravel 7, часть 8. Регистрация и


аутентификация пользователей на сайте
В Laravel сделать аутентификацию очень просто — почти всё готово из коробки. Мы уже установили
ранее пакет laravel/ui, чтобы использовать в шаблонах фреймворк bootstrap. Для создания заготовок
всех необходимых для аутентификации контроллеров, шаблонов и роутов нужно выполнить artisan-
команду.
> php artisan ui:auth
> npm install && npm run dev

Будут созданы контроллеры:

• RegisterController — обеспечивает регистрацию пользователей


• LoginController — обеспечивает аутентификацию пользователей
• ForgotPasswordController — отправляет письмо на сброс пароля
• ResetPasswordController — содержит логику для сброса паролей

Будут созданы шаблоны:

• auth.register — форма регистрации пользователей


• auth.login — форма аутентификации пользователей
• auth.passwords.email — форма для ввода адреса почты (восстановление пароля)
• auth.passwords.reset — форма для ввода нового пароля (восстановление пароля)

Будут добавлены маршруты:

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, то будет показана форма регистрации нового пользователя.

Но не хватает перевода на русский язык. Чтобы получить языковые файлы, установим пакет

> composer require laravel-lang/lang:~7.0


После этого нужно с директорию vendor/laravel-lang/lang/src/ru и файл vendor/laravel-
lang/lang/json/ru.json в директорию resources/lang. И проверяем форму регистрации
пользователей еще раз.

Теперь посмотрим, что находится в layout-шаблоне app.blade.php. Нас интересует следующий


фрагмент кода:
<!-- Right Side Of Navbar -->
<ul class="navbar-nav ml-auto">
<!-- Authentication Links -->
@guest
<li class="nav-item">
<a class="nav-link" href="{{ route('login') }}">{{ __('Login') }}</a>
</li>
@if (Route::has('register'))
<li class="nav-item">
<a class="nav-link" href="{{ route('register') }}">{{
__('Register') }}</a>
</li>
@endif
@else
<li class="nav-item dropdown">
<a id="navbarDropdown" class="nav-link dropdown-toggle" href="#"
role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false" v-pre>
{{ Auth::user()->name }}
</a>
<div class="dropdown-menu dropdown-menu-right" aria-
labelledby="navbarDropdown">
<a class="dropdown-item" href="{{ route('logout') }}"
onclick="event.preventDefault();
document.getElementById('logout-form').submit();">
{{ __('Logout') }}
</a>
<form id="logout-form" action="{{ route('logout') }}" method="POST"
class="d-none">
@csrf
</form>
</div>
</li>
@endguest
</ul>
Возьмем отсюда все полезное и вставим в наш layout-шаблон site.balde.php:
<!-- Этот блок расположен справа -->
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="{{ route('basket.index') }}">Корзина</a>
</li>
@guest
<li class="nav-item">
<a class="nav-link" href="{{ route('user.login') }}">Войти</a>
</li>
@if (Route::has('user.register'))
<li class="nav-item">
<a class="nav-link" href="{{ route('user.register')
}}">Регистрация</a>
</li>
@endif
@else
<li class="nav-item">
<a class="nav-link" href="{{ route('user.index') }}">Личный кабинет</a>
</li>
@endif
</ul>
Давайте добавим flash-сообщения, которые будут показываться при регистрации, входе в личный
кабинет и при выходе:

class RegisterController extends Controller {


/* ... */

/**
* Сразу после регистрации выполняем редирект и устанавливаем 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', 'Вы успешно вышли из личного кабинета');
}
}

Добавленные в контроллерах flash-сообщения будем показываем в layout-шаблоне, так что добавим в


него следующий код:

<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">&times;</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;

class UserController extends Controller {


/**
* Create a new controller instance.
*
* @return void
*/
public function __construct() {
$this->middleware('auth');
}

/**
* 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;

use Illuminate\Auth\Middleware\Authenticate as Middleware;

class Authenticate extends Middleware {


/**
* Get the path the user should be redirected to when they are not
authenticated.
*
* @param \Illuminate\Http\Request $request
* @return string|null
*/
protected function redirectTo($request) {
if (! $request->expectsJson()) {
return route('user.login');
}
}
}
Для панели управления нам потребуется несколько контроллеров. Неудобно в конструкторе каждого из
них прописывать два посредника 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');
});

Магазин на Laravel 7, часть 9. Панель управления сайтом,


авторизация администратора

Есть еще один момент, о котором забыл упомянуть в предыдущей части. Если аутентифицированный
пользователь попробует перейти на страницу регистрации или на страницу восстановления пароля — он
будет перенаправлен на страницу /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;

class AlterUsersTable extends Migration {


/**
* Run the migrations.
*
* @return void
*/
public function up() {
Schema::table('users', function (Blueprint $table) {
$table->boolean('admin')->after('email')->default(false);
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('admin');
});
}
}
> php artisan migrate

Теперь создадим контроллер, который будет отвечать за показ главной страницы панели управления:

> php artisan make:controller Admin\AdminController --invokable


namespace App\Http\Controllers\Admin;

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

class IndexController extends Controller {


/**
* Create a new controller instance.
*
* @return void
*/
public function __construct() {
$this->middleware('auth');
$this->middleware('admin');
}

/**
* 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>

<!-- Этот блок расположен справа -->


<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a onclick="document.getElementById('logout-form').submit();
return false"
href="{{ route('user.logout') }}" class="nav-link">Выйти</a>
</li>
</ul>
<form id="logout-form" action="{{ route('user.logout') }}"
method="post" class="d-none">
@csrf
</form>
</div>
</nav>

<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">&times;</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 Illuminate\Foundation\Http\Kernel as HttpKernel;

class Kernel extends HttpKernel {


/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'admin' => \App\Http\Middleware\Administrator::class,
'auth.basic' =>
\Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
}
И создадим класс посредника Administrator:
> php artisan make:middleware Administrator
namespace App\Http\Middleware;

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;

class Basket extends Model {


/* ... */
public static function getBasket() {
$basket_id = request()->cookie('basket_id');
if (!empty($basket_id)) {
try {
$basket = Basket::findOrFail($basket_id);
} catch (ModelNotFoundException $e) {
$basket = Basket::create();
}
} else {
$basket = Basket::create();
}
Cookie::queue('basket_id', $basket->id, 525600);
return $basket;
}
}
А в контроллере BasketController метод getBasket() можно удалить, получая объект корзины в
конструкторе:
namespace App\Http\Controllers;

use App\Basket;
use Illuminate\Http\Request;

class BasketController extends Controller {

private $basket;

public function __construct() {


$this->basket = Basket::getBasket();
}
/* ... */
}
Подозреваю, что это не слишком правильное решение, но мне еще много чего предстоит узнать о Laravel.

Теперь осталось только в layout-шаблоне показать кол-во позиций в корзине и выделить ее цветом:

<ul class="navbar-nav ml-auto">


<li class="nav-item">
<a class="nav-link @if ($positions) text-success @endif" href="{{
route('basket.index') }}">
Корзина
@if ($positions) ({{ $positions }}) @endif
</a>
</li>
@guest
<li class="nav-item">
<a class="nav-link" href="{{ route('user.login') }}">Войти</a>
</li>
@if (Route::has('user.register'))
<li class="nav-item">
<a class="nav-link" href="{{ route('user.register')
}}">Регистрация</a>
</li>
@endif
@else
<li class="nav-item">
<a class="nav-link" href="{{ route('user.index') }}">Личный кабинет</a>
</li>
@endif
</ul>

Как-то не слишком удачно у меня получилось с корзиной. Каждый раз, когда новый посетитель приходит на
сайт — создается новая запись в таблице БД 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()]);
});
}
}

Магазин на Laravel 7, часть 10. Форма оформления,


сохранение заказа в базу данных
Давайте теперь займемся оформлением заказа в магазине. Нам потребуются две таблицы в базе данных
— таблица orders (для хранения заказов) и таблица order_items (для хранения заказанных товаров).
Создаем две модели и две миграции с помощью artisan-команды.
> php artisan make:model Models/Order -m
> php artisan make:model Models/OrderItem -m
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateOrdersTable extends Migration {


/**
* Run the migrations.
*
* @return void
*/
public function up() {
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->bigInteger('user_id')->unsigned()->nullable();
$table->string('name');
$table->string('email');
$table->string('phone');
$table->string('address');
$table->string('comment')->nullable();
$table->decimal('amount', 10, 2)->unsigned();
$table->timestamps();

// внешний ключ, ссылается на поле id таблицы users


$table->foreign('user_id')
->references('id')
->on('users')
->nullOnDelete();
});
}

/**
* 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;

class CreateOrderItemsTable extends Migration {


/**
* Run the migrations.
*
* @return void
*/
public function up() {
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->bigInteger('order_id')->unsigned();
$table->bigInteger('product_id')->unsigned()->nullable();
$table->string('name', 100);
$table->decimal('price', 10, 2)->unsigned();
$table->tinyInteger('quantity')->unsigned()->default(1);
$table->decimal('cost', 10, 2)->unsigned();

// внешний ключ, ссылается на поле id таблицы orders


$table->foreign('order_id')
->references('id')
->on('orders')
->cascadeOnDelete();
// внешний ключ, ссылается на поле id таблицы products
$table->foreign('product_id')
->references('id')
->on('products')
->nullOnDelete();
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::dropIfExists('order_items');
}
}

Запускаем миграцию, чтобы создать таблицы базы данных:

> php artisan migrate


Migrating: 2020_10_10_105603_create_orders_table
Migrated: 2020_10_10_105603_create_orders_table (0.06 seconds)
Migrating: 2020_10_10_111729_create_order_items_table
Migrated: 2020_10_10_111729_create_order_items_table (0.09 seconds)
Обратите внимание, что файлы классов моделей Order.php и OrderItem.php будут созданы в
директории app/Models. Это для того, чтобы не сваливать все модели в одну кучу в директории app. И
раз у нас теперь есть директория Models — переместим в нее все файлы моделей из директории app.
Надо изменить namespace в файлах
моделей User.php, Product.php, Category.php, Brand.php, Basket.php, Auth/RegisterController.
// вот так было
namespace App;
// вот так стало
namespace App\Models;

Поскольку в контроллерах мы используем модели — в них тоже потребуется внести изменения:

// вот так было


use App\User;
use App\Product;
use App\Category;
use App\Brand;
use App\Basket;
// вот так стало
use App\Models\User;
use App\Models\Product;
use App\Models\Category;
use App\Models\Brand;
use App\Models\Basket;
Еще два места, где нужно внести изменения — сервис-провайдер ComposerServiceProvider и файл
конфигурации config/auth.php:
use App\Models\Basket;
use App\Models\Brand;
use App\Models\Category;
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
]

После этого почистим все, что Laravel успел закешировать:

> php artisan cache:clear # Очистка кэша приложения


> php artisan route:clear # очистка кэша маршрутов
> php artisan view:clear # Очистка кэша шаблонов
> php artisan config:clear # Очистка кэша конфигурации
При использовании IDE-helper-а «barryvdh/laravel-ide-helper» — заново создаем файл
файл _ide_helper.php в корне проекта:
> php artisan ide-helper:generate

Теперь настроим связи между моделями:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Order extends Model {


/**
* Связь «один ко многим» таблицы `orders` с таблицей `order_items`
*/
public function items() {
return $this->hasMany(OrderItem::class);
}
}
namespace App\Models;

use App\Models\Product;
use Illuminate\Database\Eloquent\Model;

class OrderItem extends Model {


/**
* Связь «элемент принадлежит» таблицы `order_items` с таблицей `products`
*/
public function product() {
return $this->belongsTo(Product::class);
}
}
Файл шабона для оформления заказа у нас уже есть — это checkout.blade.php в
директории views/basket. Но он практически пустой — так что разместим в нем форму с полями «Имя»,
«Почта», «Телефон», «Адрес» и «Комментарий».
@extends('layout.site')

@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 = Basket::getBasket();
$user_id = auth()->check() ? auth()->user()->id : null;
$order = Order::create(
$request->all() + ['amount' => $basket->getAmount(), 'user_id' =>
$user_id]
);

foreach ($basket->products as $product) {


$order->items()->create([
'product_id' => $product->id,
'name' => $product->name,
'price' => $product->price,
'quantity' => $product->pivot->quantity,
'cost' => $product->price * $product->pivot->quantity,
]);
}

// уничтожаем корзину
$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">&times;</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">&times;</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-команды.

> php artisan make:controller Admin/CategoryController --resource


namespace App\Http\Controllers\Admin;

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

class CategoryController extends Controller {


public function index() {
// ...
}

public function create() {


// ...
}

public function store(Request $request) {


// ...
}

public function show($id) {


// ...
}

public function edit($id) {


// ...
}

public function update(Request $request, $id) {


// ...
}

public function destroy($id) {


// ...
}
}

Но можно поступить лучше — сразу указать модель, с которой будет работать контроллер:

> php artisan make:controller Admin/CategoryController --resource --


model=Models/Category
namespace App\Http\Controllers\Admin;

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

class CategoryController extends Controller {


/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index() {
// ...
}

/**
* 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) {
// ...
}
}

Семь маршрутов для CRUD


Теперь для каждого экшена нам нужен маршрут — всего семь маршрутов. Это можно сделать с помощью
одной строки кода.

/*
Тип 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');
});

Проверить маршруты можно с помощью artisan-команды:

> php artisan route:list --name=category


+-----------+--------------------------------+------------------------+------------
-------------------------------------------+------------+
| Method | URI | Name | Action
| Middleware |
+-----------+--------------------------------+------------------------+------------
-------------------------------------------+------------+
| GET|HEAD | admin/category | admin.category.index |
App\Http\Controllers\Admin\CategoryController@index | .......... |
| GET|HEAD | admin/category/{category} | admin.category.show |
App\Http\Controllers\Admin\CategoryController@show | .......... |
| GET|HEAD | admin/category/create | admin.category.create |
App\Http\Controllers\Admin\CategoryController@create | .......... |
| POST | admin/category | admin.category.store |
App\Http\Controllers\Admin\CategoryController@store | .......... |
| GET|HEAD | admin/category/{category}/edit | admin.category.edit |
App\Http\Controllers\Admin\CategoryController@edit | .......... |
| PUT|PATCH | admin/category/{category} | admin.category.update |
App\Http\Controllers\Admin\CategoryController@update | .......... |
| DELETE | admin/category/{category} | admin.category.destroy |
App\Http\Controllers\Admin\CategoryController@destroy | .......... |
| GET|HEAD | catalog/category/{slug} | catalog.category |
App\Http\Controllers\CatalogController@category | .......... |
+-----------+--------------------------------+------------------------+------------
-------------------------------------------+------------+

Реализуем метод index()


Хорошо, давайте реализуем метод index() контроллера, который будет отвечать за показ всех
категорий.
class CategoryController extends Controller {
/* ... */
public function index() {
$roots = Category::roots();
return view('admin.category.index', compact('roots'));
}
/* ... */
}
И создадим шаблон index.blade.php в директории views/admin/category:
@extends('layout.admin')

@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

Реализуем метод show()


Теперь реализуем метод show() контроллера, который будет отвечать за показ выбранной категории.
class CategoryController extends Controller {
/* ... */
public function show(Category $category) {
return view('admin.category.show', compact('category'));
}
/* ... */
}
И создадим шаблон show.blade.php в директории views/admin/category:
@extends('layout.admin')

@section('content')
<h1>Просмотр категории</h1>
<div class="row">
<div class="col-md-6">
<p><strong>Название:</strong> {{ $category->name }}</p>
<p><strong>ЧПУ (англ):</strong> {{ $category->slug }}</p>
<p><strong>Краткое описание</strong></p>
@isset($category->content)
<p>{{ $category->content }}</p>
@else
<p>Описание отсутствует</p>
@endisset
</div>
<div class="col-md-6">
<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()
Перед удалением категории мы должны проверить, что категория не имеет дочерних категорий и не
содержит товаров.

class CategoryController extends Controller {


/* ... */
public function destroy(Category $category) {
if ($category->children->count()) {
$errors[] = 'Нельзя удалить категорию с дочерними категориями';
}
if ($category->products->count()) {
$errors[] = 'Нельзя удалить категорию, которая содержит товары';
}
if (!empty($errors)) {
return back()->withErrors($errors);
}
$category->delete();
return redirect()
->route('admin.category.index')
->with('success', 'Категория каталога успешно удалена');
}
/* ... */
}
Магазин на Laravel 7, часть 12. Панель управления,
создание и редактирование категорий
Методы create() и edit()
Реализуем еще два метода контроллера — create() и edit() — для создания новой категории
каталога и для редактирования существующей.
class CategoryController extends Controller {
/* ... */
public function create() {
// для возможности выбора родителя
$parents = Category::roots();
return view('admin.category.create', compact('parents'));
}
/* ... */
public function edit(Category $category) {
// для возможности выбора родителя
$parents = Category::roots();
return view('admin.category.edit', compact('category', 'parents'));
}
/* ... */
}

Два новых шаблона


И создадим два шаблона в директории views/admin/category — это
файлы create.blade.php и edit.blade.php.
@extends('layout.admin')

@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('&nbsp;&nbsp;&nbsp;', $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('&nbsp;&nbsp;&nbsp;', $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;

class UserAvatarController extends Controller {


/**
* Обновление аватара пользователя.
*/
public function update(Request $request) {
// будет сохранен как storage/app/avatars/L6ceL...xzXFw.jpeg
$path = $request->file('avatar')->store('avatars');
return $path;
}
}
Мы указываем только директорию avatars, а имя файла будет сформировано автоматически. Метод
вернёт путь к файлу, поэтому можно сохранить в БД весь путь, включая сгенерированное имя. Файл
будет сохранен на диск по умолчанию (и не будет доступен из веб), но можно указать диск вторым
аргументом метода store().
// будет сохранен как storage/app/public/avatars/L6ceL...xzXFw.jpeg и будет
// доступен из веб как http://server.com/storage/avatars/L6ceL...xzXFw.jpeg
$path = $request->file('avatar')->store('avatars', 'public');
Чтобы задать свое имя файла и (опционально) диск для сохранения, можно использовать
метод storeAs():
// будет использован диск по умолчанию
$path = $request->file('avatar')->storeAs(
'avatars', // директория, куда сохранять
$request->user()->id // имя файла
);
// явное указание диска для сохранения
$path = $request->file('avatar')->storeAs(
'avatars', // директория, куда сохранять
$request->user()->id, // имя файла
'public' // диск, куда сохранять
);

Магазин на Laravel 7, часть 13. Панель управления,


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

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

class CategoryController extends Controller {


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

Обрезка изображения
Изображение может быть слишком большим и не подходить для использования на сайте из-за
неподходящих пропорций. Мы будем создавать из исходного изображения еще два — размером
600x300px и размером 300x150px. Для этого установим пакет intervention/image с
помощью composer.
> composer require intervention/image
Открываем файл config/app.php и добавляем следующие строки:
return [
/* ... */
'providers' => [
/* ... */
Intervention\Image\ImageServiceProvider::class,
],

'aliases' => [
/* ... */
'Image' => Intervention\Image\Facades\Image::class,
]
/* ... */
]
Поскольку обрезать и сохранять изображения нам придется еще и для брендов и товаров — создадим
отдельный класс ImageSaver в директории app/Helpers.
namespace App\Helpers;

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

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

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

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

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

class CategoryController extends Controller {

private $imageSaver;

public function __construct(ImageSaver $imageSaver) {


$this->imageSaver = $imageSaver;
}

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

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

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

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

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

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

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

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

[storage]

[app]

[public] доступна из веб через символическую ссылку


[catalog]
[category]
[source]
bt8QPiw96zrwl65f7eKOXJ8yao1r8e4VOHQtpQff.jpeg
..........
[image]
bt8QPiw96zrwl65f7eKOXJ8yao1r8e4VOHQtpQff.jpeg
..........
[thumb]
bt8QPiw96zrwl65f7eKOXJ8yao1r8e4VOHQtpQff.jpeg
..........
[brand]
[source]
fd9QPiw96zrwl65f7eKODJ8yao1r8e4VOHQtpSda.jpeg
..........
[image]
fd9QPiw96zrwl65f7eKODJ8yao1r8e4VOHQtpSda.jpeg
..........
[thumb]
fd9QPiw96zrwl65f7eKODJ8yao1r8e4VOHQtpSda.jpeg
..........
[product]
[source]
ey7QPiw96zrwl65f7eKOYJ8yao1r8e4VOHQtpFvs.jpeg
..........
[image]
ey7QPiw96zrwl65f7eKOYJ8yao1r8e4VOHQtpFvs.jpeg
..........
[thumb]
ey7QPiw96zrwl65f7eKOYJ8yao1r8e4VOHQtpFvs.jpeg
..........
И осталось только показать загруженное изображение в шаблоне show.blade.php:
@extends('layout.admin')

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

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


Сейчас валидация данных формы добавления и редактирования категории у нас внутри
методов store() и update(). Давайте создадим отдельный класс и всю логику валидации переместим в
него.
> php artisan make:request CategoryCatalogRequest
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class CategoryCatalogRequest extends FormRequest {


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

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

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

namespace App\Http\Controllers\Admin;

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

class CategoryController extends Controller {


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

Сообщения об ошибках
Давайте приведем в порядок сообщения об ошибках — для этого открываем на редактирование
файл validation.php в директории resources/lang/ru.
return [
/* ... */
'custom' => [
'name' => [
'required' => 'Поле «:attribute» обязательно для заполнения',
'max' => 'Поле «:attribute» должно быть не больше :max символов',
],
'email' => [
'required' => 'Поле «:attribute» обязательно для заполнения',
'max' => 'Поле «:attribute» должно быть не больше :max символов',
],
'phone' => [
'required' => 'Поле «:attribute» обязательно для заполнения',
'max' => 'Поле «:attribute» должно быть не больше :max символов',
],
'address' => [
'required' => 'Поле «:attribute» обязательно для заполнения',
'max' => 'Поле «:attribute» должно быть не больше :max символов',
],
'slug' => [
'required' => 'Поле «:attribute» обязательно для заполнения',
'unique' => 'Поле «:attribute» должно быть уникальным значением',
'regex' => 'Поле «:attribute» допускает только буквы, цифры, «-» и
«_»',
'max' => 'Поле «:attribute» должно быть не больше :max символов',
],
],

'attributes' => [
'name' => 'Имя, Фамилия',
'slug' => 'ЧПУ (англ)',
'email' => 'Адрес почты',
'password' => 'Пароль',
'password_confirmation' => 'Подтверждение пароля',
'address' => 'Адрес доставки',
'phone' => 'Номер телефона',
/* ... */
],
];
Магазин на Laravel 7, часть 14. Панель управления,
доп.проверка для родителя категории
Осталась еще одна проблема — нет проверки, что при редактировании категории в качестве родителя не
будет выбана эта же категория или один из ее потомков. Здесь простыми правилами валидации не
обойтись — потребуется класс, который реализует
интерфейс Illuminate\Contracts\Validation\Rule. Давайте создадим такой класс с помощью
artisan-команды.
> php artisan make:rule CategoryParent
namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;

class CategoryParent implements Rule {


/**
* Create a new rule instance.
*
* @return void
*/
public function __construct() {
// ...
}

/**
* 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;

class CategoryCatalogRequest extends FormRequest {


/* ... */
public function rules() {
switch ($this->method()) {
case 'POST':
return [
'parent_id' => 'required|regex:~^[0-9]+$~',
'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' => ['required', 'regex:~^[0-9]+$~', new
CategoryParent($model)],
'name' => 'required|max:100',
'slug' =>
'required|max:100|unique:categories,slug,'.$id.',id|regex:~^[-_a-z0-9]+$~i',
'image' => 'mimes:jpeg,jpg,png|max:5000'
];
}
}
}
При создании объекта класса CategoryParent мы передаем в конструктор объект класса
модели Category — через него мы сможем обратиться к методу validParent().
namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;
use App\Models\Category;

class CategoryParent implements Rule {

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' => 'Родитель',
],
];

Теперь все готово, можно проверять:


Заголовки страниц
Установим заголовок по умолчанию в layout-шаблоне views/layout/admin.blade.php:
<head>
<!-- ..... -->
<title>{{ $title ?? 'Панель управления' }}</title>
<!-- ..... -->
</head>

И будем передавать значение заголовка из дочерних шаблонов:

@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
@extends('layout.admin', ['title' => 'Редактирование категории'])

@section('content')
<h1>Редактирование категории</h1>
<!-- ..... -->
@endsection
Создание, редактирование и удаление бренда
Нам опять нужен ресурсный контроллер, который позволит выполянить CRUD-операции над брендами.

> php artisan make:controller Admin/BrandController --resource --model=Models/Brand


namespace App\Http\Controllers\Admin;

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

class BrandController extends Controller {

private $imageSaver;

public function __construct(ImageSaver $imageSaver) {


$this->imageSaver = $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

Магазин на Laravel 7, часть 15. Панель управления,


добавление и редактирование брендов
Чтобы закончить с брендами, осталось создать два шаблона create.blade.php и edit.blade.php.
Поскольку формы для создания и редактирования бренда практически одинаковые — создадим
отдельный шаблон form.blade.php — как это делали для категорий. И еще — поскольку мы используем
«mass assignment», нужно добавить свойство $fillable в модель Brand.
class Brand extends Model {

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;

abstract class CatalogRequest extends FormRequest {

/**
* С какой сущностью сейчас работаем: категория, бренд, товар
* @var array
*/
protected $entity = [];

public function authorize() {


return true;
}

public function rules() {


switch ($this->method()) {
case 'POST':
return $this->createItem();
case 'PUT':
case 'PATCH':
return $this->updateItem();
}
}

/**
* Задает дефолтные правила для проверки данных при добавлении
* категории, бренда или товара
*/
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;

class CategoryCatalogRequest extends CatalogRequest {

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

public function authorize() {


return parent::authorize();
}

public function rules() {


return parent::rules();
}

/**
* Объединяет дефолтные правила и правила, специфичные для категории
* для проверки данных при добавлении новой категории
*/
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;

class BrandCatalogRequest extends CatalogRequest {


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

public function authorize() {


return parent::authorize();
}

public function rules() {


return parent::rules();
}

/**
* Объединяет дефолтные правила и правила, специфичные для бренда
* для проверки данных при добавлении нового бренда
*/
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;

class BrandController extends Controller {


/* ... */
public function store(BrandCatalogRequest $request) {
/* ... */
}
/* ... */
public function update(BrandCatalogRequest $request, Brand $brand) {
/* ... */
}
/* ... */
}
Для определения того, что сущность создается или обновляется, мы используем метод method() и
сравниваем полученное значение с POST, PUT, PATCH. Но мы можем пойти другим путем — получить имя
маршрута и сравнивать его с admin.item.create, admin.item.update.
class ItemRequest extends FormRequest {
public function rules() {
switch ($this->route()->getName()) {
case 'admin.item.create':
return $this->createItem();
case 'admin.item.update':
return $this->updateItem();
}
}
}

Работа над ошибками


1. Меню каталога в левой колонке
Не дает мне покоя меню каталога в левой колонке в публичной части сайта. Мы показываем только два
уровня каталога, хотя их может быть три, четыре или пять. Давайте доработаем меню, чтобы показывать
все уровни каталога, независимо от того, сколько их всего. Для этого создадим еще один
шаблон branch.blade.php в директории views/layout/part.
<ul>
@foreach ($items as $item)
<li>
<a href="{{ route('catalog.category', ['slug' => $item->slug]) }}">{{
$item->name }}</a>
@if ($item->children->count())
<span class="badge badge-dark">
<i class="fa fa-plus"></i>
</span>
@include('layout.part.branch', ['items' => $item->children])
@endif
</li>
@endforeach
</ul>
Тогда шаблон views/layout/part/tree.blade.php будет совсем простым:
<h4>Разделы каталога</h4>
<div id="catalog-sidebar">
@include('layout.part.branch', ['items' => $items])
</div>

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

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` = 5
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 8
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 6
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 7
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 9
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 10
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 11
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 12

Давайте это исправим — для этого создадим еще два метода в классе модели:

class Category extends Model {


/**
* Связь «один ко многим» таблицы `categories` с таблицей `categories`, но
* позволяет получить не только дочерние категории, но и дочерние-дочерние
*/
public function descendants() {
return $this->hasMany(Category::class, 'parent_id')->with('descendants');
}

/**
* Возвращает список всех категорий каталога в виде дерева
*/
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`

2. Иерархия категорий в панели управления


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

class CategoryController extends Controller {


/* ... */
public function index() {
$items = Category::all();
return view('admin.category.index', compact('items'));
}
/* ... */
public function create() {
// все категории для возможности выбора родителя
$items = Category::all();
return view('admin.category.create', compact('items'));
}
/* ... */
public function edit(Category $category) {
// все категории для возможности выбора родителя
$items = Category::all();
return view('admin.category.edit', compact('category', 'items'));
}
/* ... */
}
Шаблон views/admin/category/index.blade.php:
@extends('layout.admin', ['title' => 'Все категории каталога'])

@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('&nbsp;&nbsp;&nbsp;', $level) !!} @endif {{
$item->name }}
</option>
@if (count($items->where('parent_id', $parent)))
@include('admin.category.part.branch', ['level' => $level, 'parent' =>
$item->id])
@endif
@endforeach

Магазин на Laravel 7, часть 16. Панель управления,


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

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

> php artisan make:controller Admin/ProductController --resource --


model=Models/Product
namespace App\Http\Controllers\Admin;

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

class ProductController extends Controller {

private $imageSaver;

public function __construct(ImageSaver $imageSaver) {


$this->imageSaver = $imageSaver;
}

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

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

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

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

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

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

protected $fillable = [
'parent_id',
'name',
'slug',
'content',
'image',
];
/* ... */
}
Прописываем маршруты в файле web.php:
Route::group([
'as' => 'admin.', // имя маршрута, например admin.index
'prefix' => 'admin', // префикс маршрута, например admin/index
'namespace' => 'Admin', // пространство имен контроллера
'middleware' => ['auth', 'admin'] // один или несколько посредников
], function () {
// главная страница панели управления
Route::get('index', 'IndexController')->name('index');
// CRUD-операции над категориями каталога
Route::resource('category', 'CategoryController');
// CRUD-операции над брендами каталога
Route::resource('brand', 'BrandController');
// CRUD-операции над товарами каталога
Route::resource('product', 'ProductController');
});
Создаем шаблон views/admin/product/index.blade.php:
@extends('layout.admin', ['title' => 'Все товары каталога'])

@section('content')
<h1>Все товары</h1>
<a href="{{ route('admin.product.create') }}" class="btn btn-success mb-4">
Создать товар
</a>
<table class="table table-bordered">
<tr>
<th width="30%">Наименование</th>
<th width="65%">Описание</th>
<th><i class="fas fa-edit"></i></th>
<th><i class="fas fa-trash-alt"></i></th>
</tr>
@foreach ($products as $product)
<tr>
<td>
<a href="{{ route('admin.product.show', ['product' => $product-
>id]) }}">
{{ $product->name }}
</a>
</td>
<td>{{ iconv_substr($product->content, 0, 150) }}</td>
<td>
<a href="{{ route('admin.product.edit', ['product' => $product-
>id]) }}">
<i class="far fa-edit"></i>
</a>
</td>
<td>
<form action="{{ route('admin.product.destroy', ['product' =>
$product->id]) }}"
method="post" onsubmit="return confirm('Удалить этот
товар?')">
@csrf
@method('DELETE')
<button type="submit" class="m-0 p-0 border-0 bg-transparent">
<i class="far fa-trash-alt text-danger"></i>
</button>
</form>
</td>
</tr>
@endforeach
</table>
{{ $products->links() }}
@endsection
Создаем шаблон views/admin/product/show.blade.php:
@extends('layout.admin', ['title' => 'Просмотр товара'])

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

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

@section('content')
<h1>Создание нового товара</h1>
<form method="post" action="{{ route('admin.product.store') }}"
enctype="multipart/form-data">
@include('admin.product.part.form')
</form>
@endsection
Создаем шаблон views/admin/product/edit.blade.php:
@extends('layout.admin', ['title' => 'Редактирование товара'])

@section('content')
<h1>Редактирование товара</h1>
<form method="post" enctype="multipart/form-data"
action="{{ route('admin.product.update', ['product' => $product->id])
}}">
@method('PUT')
@include('admin.product.part.form')
</form>
@endsection
Создаем шаблон views/admin/product/part/form.blade.php:
@csrf
<div class="form-group">
<input type="text" class="form-control" name="name" placeholder="Наименование"
required maxlength="100" value="{{ old('name') ?? $product->name ?? ''
}}">
</div>
<div class="form-group">
<input type="text" class="form-control" name="slug" placeholder="ЧПУ (на
англ.)"
required maxlength="100" value="{{ old('slug') ?? $product->slug ?? ''
}}">
</div>
<div class="form-group">
@php
$category_id = old('category_id') ?? $product->category_id ?? 0;
@endphp
<select name="category_id" class="form-control" title="Категория">
<option value="0">Выберите</option>
@if (count($items))
@include('admin.product.part.branch', ['level' => -1, 'parent' => 0])
@endif
</select>
</div>
<div class="form-group">
@php
$brand_id = old('brand_id') ?? $product->brand_id ?? 0;
@endphp
<select name="brand_id" class="form-control" title="Бренд">
<option value="0">Выберите</option>
@foreach($brands as $brand)
<option value="{{ $brand->id }}" @if ($brand->id == $brand_id) selected
@endif>
{{ $brand->name }}
</option>
@endforeach
</select>
</div>
<div class="form-group">
<textarea class="form-control" name="content" placeholder="Описание"
rows="4">{{ old('content') ?? $product->content ?? '' }}</textarea>
</div>
<div class="form-group">
<input type="file" class="form-control-file" name="image" accept="image/png,
image/jpeg">
</div>
@isset($product->image)
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="remove" id="remove">
<label class="form-check-label" for="remove">
Удалить загруженное изображение
</label>
</div>
@endisset
<div class="form-group">
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
Создаем шаблон views/admin/product/part/branch.blade.php:
@php($level++)
@foreach ($items->where('parent_id', $parent) as $item)
<option value="{{ $item->id }}" @if ($item->id == $category_id) selected
@endif>
@if ($level) {!! str_repeat('&nbsp;&nbsp;&nbsp;', $level) !!} @endif {{
$item->name }}
</option>
@if (count($items->where('parent_id', $parent)))
@include('admin.product.part.branch', ['level' => $level, 'parent' =>
$item->id])
@endif
@endforeach

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

class ProductController extends Controller {


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

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

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

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

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

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


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

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

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

> php artisan make:request ProductCatalogRequest


namespace App\Http\Requests;

class ProductCatalogRequest extends CatalogRequest {


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

public function authorize() {


return parent::authorize();
}

public function rules() {


return parent::rules();
}

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

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

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

namespace App\Http\Controllers\Admin;

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

class ProductController extends Controller {


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

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

Зададим свои сообщения в файле lang/ru/validation.php, если не выбраны категория и/или бренд:
return [
'custom' => [
/* ... */
'category_id' => [
'required' => 'Поле «:attribute» обязательно для заполнения',
'integer' => 'Поле «:attribute» должно быть целым положительным
числом',
'min' => 'Поле «:attribute» обязательно для заполнения',
],
'brand_id' => [
'required' => 'Поле «:attribute» обязательно для заполнения',
'integer' => 'Поле «:attribute» должно быть целым положительным
числом',
'min' => 'Поле «:attribute» обязательно для заполнения',
],
],

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

Магазин на Laravel 7, часть 17. Панель управления,


работа с заказами, изменение статуса
Администратор магазина должен иметь возможность просматривать заказы, оформленные в магазине и
как-то их обрабатывать. Для начала организуем возможность просмотра списка заказов и отдельного
заказа. Обработка заказа подразумевает изменение статуса заказа, так что добавим в
таблицу orders поле status. Чтобы изменять значение этого поля, нужна возможность редактирования
заказа.

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

> php artisan make:controller Admin/OrderController --resource --model=Models/Order


namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\Order;
use Illuminate\Http\Request;

class OrderController extends Controller {


/**
* Просмотр списка заказов
*
* @return \Illuminate\Http\Response
*/
public function index() {
$orders = Order::orderBy('created_at', 'desc')->paginate(5);
return view('admin.order.index',compact('orders'));
}

/**
* Просмотр отдельного заказа
*
* @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', 'Заказ был успешно обновлен');
}
}

Маршруты для заказов


Добавляем маршруты в файле roure/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(), чтобы их исключить.

Шаблоны для заказов


Шаблон views/admin/order/index.blade.php для просмотра списка заказов:
@extends('layout.admin', ['title' => 'Все заказы'])

@section('content')
<h1>Все заказы</h1>

<table class="table table-bordered">


<tr>
<th>№</th>
<th width="18%">Дата и время</th>
<th width="18%">Покупатель</th>
<th width="18%">Адрес почты</th>
<th width="18%">Номер телефона</th>
<th width="18%">Пользователь</th>
<th><i class="fas fa-eye"></i></th>
<th><i class="fas fa-edit"></i></th>
</tr>
@foreach($orders as $order)
<tr>
<td>{{ $order->id }}</td>
<td>{{ $order->created_at->format('d.m.Y H:i') }}</td>
<td>{{ $order->name }}</td>
<td><a href="mailto:{{ $order->email }}">{{ $order->email
}}</a></td>
<td>{{ $order->phone }}</td>
<td>
@isset($order->user)
{{ $order->user->name }}
@endisset
</td>
<td>
<a href="{{ route('admin.order.show', ['order' => $order->id])
}}">
<i class="far fa-eye"></i>
</a>
</td>
<td>
<a href="{{ route('admin.order.edit', ['order' => $order->id])
}}">
<i class="far fa-edit"></i>
</a>
</td>
</tr>
@endforeach
</table>
{{ $orders->links() }}
@endsection
Шаблон views/admin/order/edit.blade.php для редактирования заказа:
@extends('layout.admin', ['title' => 'Редактирование заказа'])
@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">
<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
Шаблон views/admin/order/show.blade.php для просмотра заказа:
@extends('layout.admin', ['title' => 'Просмотр заказа'])

@section('content')
<h1>Данные по заказу № {{ $order->id }}</h1>

<h3 class="mb-3">Состав заказа</h3>


<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>

<h3 class="mb-3">Данные покупателя</h3>


<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

Временна́я зона
Есть проблема с показом времени заказа при просмотре списка всех заказов. Это потому, что в базе
данных created_at и updated_at хранятся в UTC. Давайте добавим accessor в модель Order и будем
преобразовывать в московское время.
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;

class Order extends Model {


/**
* Преобразует дату и время создания заказа из UTC в Europe/Moscow
*
* @param $value
* @return \Carbon\Carbon|false
*/
public function getCreatedAtAttribute($value) {
return Carbon::createFromFormat('Y-m-d H:i:s', $value)-
>timezone('Europe/Moscow');
}

/**
* Преобразует дату и время обновления заказа из 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;

class AlterOrdersTable extends Migration {


/**
* Run the migrations.
*
* @return void
*/
public function up() {
Schema::table('orders', function (Blueprint $table) {
$table->unsignedTinyInteger('status')
->after('amount')
->nullable(false)
->default(0);
});
}

/**
* 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',
];

public const STATUSES = [


0 => 'Новый',
1 => 'Обработан',
2 => 'Оплачен',
3 => 'Доставлен',
4 => 'Завершен',
];
/* ... */
}

Будем передавать во все шаблоны возможные значения статуса заказа:

class OrderController extends Controller {


/* ... */
public function index() {
$orders = Order::orderBy('status', 'asc')->paginate(5);
$statuses = Order::STATUSES;
return view('admin.order.index',compact('orders', 'statuses'));
}
/* ... */
public function show(Order $order) {
$statuses = Order::STATUSES;
return view('admin.order.show', compact('order', 'statuses'));
}
/* ... */
public function edit(Order $order) {
$statuses = Order::STATUS;
return view('admin.order.edit', compact('order', 'statuses'));
}
/* ... */
}

И во всех шаблонах будем показывать статус заказа. Обратите внимание, что список заказов
сортируется теперь по статусу. Наверху будут новые заказы, а внизу — уже завершенные. Те заказы,
которые требуют внимания администратора, всегда на виду.

@extends('layout.admin', ['title' => 'Все заказы'])

@section('content')
<h1>Все заказы</h1>

<table class="table table-bordered">


<tr>
<th>№</th>
<th width="18%">Дата и время</th>
<th width="5%">Статус</th>
<th width="18%">Покупатель</th>
<th width="18%">Адрес почты</th>
<th width="18%">Номер телефона</th>
<th width="18%">Пользователь</th>
<th><i class="fas fa-eye"></i></th>
<th><i class="fas fa-edit"></i></th>
</tr>
@foreach($orders as $order)
<tr>
<td>{{ $order->id }}</td>
<td>{{ $order->created_at->format('d.m.Y H:i') }}</td>
<td>
@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
</td>
<td>{{ $order->name }}</td>
<td><a href="mailto:{{ $order->email }}">{{ $order->email
}}</a></td>
<td>{{ $order->phone }}</td>
<td>
@isset($order->user)
{{ $order->user->name }}
@endisset
</td>
<td>
<a href="{{ route('admin.order.show', ['order' => $order->id])
}}">
<i class="far fa-eye"></i>
</a>
</td>
<td>
<a href="{{ route('admin.order.edit', ['order' => $order->id])
}}">
<i class="far fa-edit"></i>
</a>
</td>
</tr>
@endforeach
</table>
{{ $orders->links() }}
@endsection

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

@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>

<h3 class="mb-3">Состав заказа</h3>


<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>

<h3 class="mb-3">Данные покупателя</h3>


<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

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

@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 страниц сайта
Работа с пользователями
Нужно еще предоставить администратору возможность работы с пользователями. Так что организуем
просмотр списка и добавим форму для изменения данных пользователя. Страницу просмотра данных
пользователя делать не будем, потому как данных-то всего две строки — «Имя, Фамилия» и «Адрес
почты». При редактировании будет возможность изменить пароль пользователя. Не уверен, что это
нужно — но пусть будет для полноты картины — в конце концов, это учебный проект.
Я князь! Чего хочу — того и ворочу… эээ… действую в интересах державы.
Мультфильм «Илья Муромец и Соловей Разбойник»

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

> php artisan make:controller Admin/UserController --resource --model=Models/User


<?php

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;

class UserController extends Controller {


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

/**
* Показывает форму для редактирования пользователя
*
* @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>

<table class="table table-bordered">


<tr>
<th>#</th>
<th width="25%">Дата регистрации</th>
<th width="25%">Имя, фамилия</th>
<th width="25%">Адрес почты</th>
<th width="20%">Кол-во заказов</th>
<th><i class="fas fa-edit"></i></th>
</tr>
@foreach($users as $user)
<tr>
<td>{{ $user->id }}</td>
<td>{{ $user->created_at->format('d.m.Y H:i') }}</td>
<td>{{ $user->name }}</td>
<td><a href="mailto:{{ $user->email }}">{{ $user->email }}</a></td>
<td>{{ $user->orders->count() }}</td>
<td>
<a href="{{ route('admin.user.edit', ['user' => $user->id])
}}">
<i class="far fa-edit"></i>
</a>
</td>
</tr>
@endforeach
</table>
{{ $users->links() }}
@endsection
Шаблон для редактирования views/admin/user/index.blade.php:
@extends('layout.admin', ['title' => 'Редактирование пользователя'])

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

Преобразование даты регистрации из UTC в Europe/Moscow:

namespace App\Models;

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;

class User extends Authenticatable {


/**
* Преобразует дату и время регистрации пользователя из UTC в Europe/Moscow
*
* @param $value
* @return \Carbon\Carbon|false
*/
public function getCreatedAtAttribute($value) {
return Carbon::createFromFormat('Y-m-d H:i:s', $value)-
>timezone('Europe/Moscow');
}

/**
* Преобразует дату и время обновления пользователя из 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;

class CreatePagesTable extends Migration {


/**
* Run the migrations.
*
* @return void
*/
public function up() {
Schema::create('pages', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('parent_id')->nullable(false)->default(0);
$table->string('name', 100)->nullable(false);
$table->text('content')->nullable(false);
$table->string('slug', 100)->unique()->nullable(false);
$table->timestamps();
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::dropIfExists('pages');
}
}
> php artisan migrate

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

> php artisan make:controller Admin/PageController --resource --model=Models/Page


namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\Page;
use Illuminate\Http\Request;

class PageController extends Controller {


/**
* Показывает список всех страниц
*
* @return \Illuminate\Http\Response
*/
public function index() {
$pages = Page::all();
return view('admin.page.index', compact('pages'));
}

/**
* Показывает форму для создания страницы
*
* @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;

class Page extends 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

Шаблон для просмотра страницы views/admin/page/show.blade.php:


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

@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>

<a href="{{ route('admin.page.edit', ['page' => $page->id]) }}"


class="btn btn-success">
Редактировать страницу
</a>
<form method="post" class="d-inline" onsubmit="return confirm('Удалить
эту страницу?')"
action="{{ route('admin.page.destroy', ['page' => $page->id])
}}">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">
Удалить страницу
</button>
</form>
</div>
</div>
@endsection
Шаблон для создания страницы views/admin/page/create.blade.php:
@extends('layout.admin', ['title' => 'Создание новой страницы'])

@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>

Но во-первых, в этом случае неудобно работать в редакторе, если переключиться в режим


редактирования кода. А во-вторых, не хотелось бы хранить изображения в базе данных, раздувая ее без
необходимости.
Первый способ
При сохранении страницы будем вызывать метод saveImages(), который проанализирует html-код,
найдет в нем изображения и сохранит их на диск. И заменит значения атрибутов src всех изображений c
base64-строк на ссылки.

class PageController extends Controller {


/**
* Сохраняет новую страницу в базу данных
*
* @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',
]);
$content = $this->saveImages($request->input('content'));
$data = $request->all();
$data['content'] = $content;
$page = Page::create($data);
return redirect()
->route('admin.page.show', ['page' => $page->id])
->with('success', 'Новая страница успешно создана');
}

/**
* Обновляет страницу (запись в таблице БД)
*
* @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-код, чтобы найти в нем
изображения и удалить их с диска.

class PageController extends Controller {


/**
* Удаляет изображения, которые связаны со страницей
*
* @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', 'Страница сайта успешно удалена');
}
}

Второй способ
Первый способ мне не очень нравится, потому что с DomDocument (неожиданно) возникли трудности. Там
чехарда с кодировкой строки html-кода при использовании метода loadHTML() и проблемы при
использовании метода saveHTML(). Проблема, как оказалось, застарелая — в интернете полным-полно
рецептов, как это побороть с помощью тех или иных хаков.

К счастью, редактор summernote предоставляет возможность навесить свои обработчики на события


вставки и удаления изображений.

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-запроса добавлять этот токен
самостоятельно, примерно так:

var data = new FormData();


data.append('image', image);
data.append('_token', $('meta[name="csrf-token"]').attr('content'));
Токен наш код получает из мета-тега внутри <head> страницы, только надо этот мета-тег добавить в
layout-шаблон:
<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>
<!-- .......... -->
</head>
Теперь добавляем два новых маршрута в файл 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');
// загрузка изображения из редактора
Route::post('page/upload/image', 'PageController@uploadImage')
->name('page.upload.image');
// удаление изображения в редакторе
Route::delete('page/remove/image', 'PageController@removeImage')
->name('page.remove.image');
});
Осталось только добавить методы uploadImage() и removeImage() в контроллер:
class PageController extends Controller {
/**
* Загружает изображение, которое было добавлено в 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 'Не удалось удалить изображение';
}
}
Теперь метод 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;

class PageController extends Controller {


/**
* Показывает список всех страниц
*
* @return \Illuminate\Http\Response
*/
public function index() {
$pages = Page::all();
return view('admin.page.index', compact('pages'));
}

/**
* Показывает форму для создания страницы
*
* @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);
});
}
},
});
}

В случае успеха или неудачи мы отправляем клиенту JSON-ответ разного содержания:

{
"errors": [
"Поле image должно быть файлом одного из следующих типов: jpeg, png.",
"Размер файла в поле image не может быть больше 1 Килобайт(а)."
]
}
{
"image":
"http://www.host21.ru/storage/page/xfDOpOlUe2J0G4yNe1zhWre0jTYnDq6O3x2adETB.png"
}

Сообщение об ошибке при попытке загрузить изображение в gif-формате:


Сообщение об ошибке при попытке загрузить слишком большое изображение:

Мы сделали проверку сами, но документация Laravel говорит по этому поводу:

При использовании метода 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-команды.

> php artisan make:controller PageController --invokable


namespace App\Http\Controllers;

use App\Models\Page;
use Illuminate\Http\Request;

class PageController extends Controller {


/**
* Handle the incoming request.
*
* @param \Illuminate\Http\Request $request
* @param App\Models\Page $page
* @return \Illuminate\Http\Response
*/
public function __invoke(Request $request, Page $page) {
return view('page', compact('page'));
}
}
Добавим маршрут в файл routes/web.php:
Route::get('page/{page}', 'PageController')->name('page.show');
Но тут сразу возникает проблема. Laravel будет пытаться получить модель по уникальному
идентификатору страницы. Это задается в методе getRouteKeyName() родительского класса
модели Illuminate\Database\Eloquent\Model.
abstract class Model implements ... {
/**
* The primary key for the model.
*
* @var string
*/
protected $primaryKey = 'id';

/**
* 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;

class Page extends Model {


/**
* Если мы в панели управления — страница будет получена из
* БД по id, если в публичной части сайта — то по slug
*
* @return string
*/
public function getRouteKeyName() {
$current = Route::currentRouteName();
if ('page.show' == $current) {
return 'slug'; // мы в публичной части сайта
}
return 'id'; // мы в панели управления
}
/* ... */
}
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Route;

class Page extends Model {


/**
* Если мы в панели управления — страница будет получена из
* БД по id, если в публичной части сайта — то по slug
*
* @param mixed $value
* @param string|null $field
* @return \Illuminate\Database\Eloquent\Model|null
*/
public function resolveRouteBinding($value, $field = null) {
$current = Route::currentRouteName();
if ('page.show' == $current) {
// мы в публичной части сайта
return $this->whereSlug($value)->firstOrFail();
}
// мы в панели управления
return $this->findOrFail($value);
}
/* ... */
}
namespace App\Providers;

use App\Models\Page;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as
ServiceProvider;
use Illuminate\Support\Facades\Route;

class RouteServiceProvider extends ServiceProvider {


/* ... */
public function boot() {

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);
});
}
/* ... */
}

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

<ul class="navbar-nav mr-auto">


<li class="nav-item">
<a class="nav-link" href="{{ route('catalog.index') }}">Каталог</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Доставка</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
role="button" data-toggle="dropdown" aria-haspopup="true"
aria-expanded="false">
Оплата (dropdown)
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="#">Оплата (navlink)</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#">Первая дочерняя (оплата)</a>
<a class="dropdown-item" href="#">Вторая дочерняя (оплата)</a>
</div>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Контакты</a>
</li>
</ul>
В Bootstrap сделано так, что элемент панели Navbar может быть либо ссылкой, либо выпадающим
списком. Но не может быть одновременно и тем и другим. Поэтому, если у страницы есть дочерние
страницы, то первым элементом выпадающего списка будет ссылка на страницу первого уровня, а
дальше — ссылки на дочерние страницы.
В классе ComposerServiceProvider будем передавать переменную pages в
шаблон layout.part.pages:
class ComposerServiceProvider extends ServiceProvider {
/* ... */
public function boot() {
View::composer('layout.part.roots', function($view) {
$view->with(['items' => Category::all()]);
});
View::composer('layout.part.brands', function($view) {
$view->with(['items' => Brand::popular()]);
});
View::composer('layout.site', function($view) {
$view->with(['positions' => Basket::getCount()]);
});
View::composer('layout.part.pages', function($view) {
$view->with(['pages' => Page::all()]);
});
}
}
В layout-шаблоне подключим шаблон layout.part.pages с помощью директивы @include():
<!-- Основная часть меню -->
<div class="collapse navbar-collapse" id="navbar-example">
<!-- Этот блок расположен слева -->
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link" href="{{ route('catalog.index') }}">Каталог</a>
</li>
@include('layout.part.pages')
</ul>

<!-- Этот блок расположен посередине -->


<form class="form-inline my-2 my-lg-0">
<!-- ..... -->
</form>

<!-- Этот блок расположен справа -->


<ul class="navbar-nav ml-auto">
<!-- ..... -->
</ul>
</div>
И создадим шаблон views/layout/part/pages.blade.php, который будет показывать ссылки на все
страницы сайта. Шаблон не будем делать рекурсивным, потому как нет задачи сделать
неограниченную глубину вложенности для страниц.
@foreach ($pages->where('parent_id', 0) as $page)
@if (count($pages->where('parent_id', $page->id)))
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
role="button" data-toggle="dropdown" aria-haspopup="true"
aria-expanded="false">
{{ $page->name }}
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="{{ route('page.show', ['page' =>
$page->slug]) }}">
{{ $page->name }}
</a>
<div class="dropdown-divider"></div>
@foreach ($pages->where('parent_id', $page->id) as $child)
<a class="dropdown-item" href="{{ route('page.show', ['page' =>
$child->slug]) }}">
{{ $child->name }}
</a>
@endforeach
</div>
</li>
@else
<li class="nav-item">
<a class="nav-link" href="{{ route('page.show', ['page' => $page-
>slug]) }}">
{{ $page->name }}
</a>
</li>
@endif
@endforeach
Заголовки страниц
Совсем про них забыл, так что давайте это исправим. В layout-шаблоне зададим значение по умолчанию
для заголовка и будем передавать из дочерних шаблонов актуальное значение заголовка.

<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

Магазин на Laravel 7, часть 21. Добавляем профили и


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

Добавляем профили
Создаем модедь и миграцию с помощью artisan-команды:

> php artisan make:model Models/Profile -m


use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateProfilesTable extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up() {
Schema::create('profiles', function (Blueprint $table) {
$table->id();
$table->bigInteger('user_id')->unsigned()->nullable(false);
$table->string('title')->nullable(false); // название профиля
$table->string('name')->nullable(false); // имя пользователя
$table->string('email')->nullable(false); // почта пользователя
$table->string('phone')->nullable(false); // телефон пользователя
$table->string('address')->nullable(false); // адрес доставки заказа
$table->string('comment')->nullable(); // комментарий к заказу
$table->timestamps();

// внешний ключ, ссылается на поле id таблицы users


$table->foreign('user_id')
->references('id')
->on('users')
->cascadeOnDelete();
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::dropIfExists('profiles');
}
}

Запускаем миграцию, чтобы создать таблицу базы данных:

> php artisan migrate

Создаем ресурсный контроллер для CRUD-операций над профилями:

> php artisan make:controller ProfileController --resource --model=Models/Profile

Создадим маршртуты для CRUD-операций над профилями:

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;

class UserController extends Controller {


public function __construct() {
$this->middleware('auth');
}

public function index() {


return view('user.index');
}
}
namespace App\Http\Controllers;

class UserController extends Controller {


public function index() {
return view('user.index');
}
}
Добавим в модель User связи между таблицами:
class User extends Authenticatable {
/**
* Связь «один ко многим» таблицы `users` с таблицей `profiles`
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function profiles() {
return $this->hasMany(Profile::class);
}
}
Добавим в модель Profile связи между таблицами:
class Profile extends Model {
/**
* Связь «профиль принадлежит» таблицы `profiles` с таблицей `users`
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user() {
return $this->belongsTo(User::class);
}
}
Теперь поработаем над контроллером ProfileController:
namespace App\Http\Controllers;

use App\Models\Profile;
use Illuminate\Http\Request;

class ProfileController extends Controller {

/**
* Показывает список всех профилей
*
* @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>

<a href="{{ route('user.profile.create') }}" class="btn btn-success mb-4">


Создать профиль
</a>

@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>

<p><strong>Название профиля:</strong> {{ $profile->title }}</p>


<p><strong>Имя, фамилия:</strong> {{ $profile->name }}</p>
<p>
<strong>Адрес почты:</strong>
<a href="mailto:{{ $profile->email }}">{{ $profile->email }}</a>
</p>
<p><strong>Номер телефона:</strong> {{ $profile->phone }}</p>
<p><strong>Адрес доставки:</strong> {{ $profile->address }}</p>
@isset ($profile->comment)
<p><strong>Комментарий:</strong> {{ $profile->comment }}</p>
@endisset

<a href="{{ route('user.profile.edit', ['profile' => $profile->id]) }}"


class="btn btn-success">
Редактировать профиль
</a>
<form method="post" class="d-inline" onsubmit="return confirm('Удалить этот
профиль?')"
action="{{ route('user.profile.destroy', ['profile' => $profile->id])
}}">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">
Удалить профиль
</button>
</form>
@endsection
Шаблон создания профиля пользователя views/user/profile/create.blade.php:
@extends('layout.admin', ['title' => 'Создание профиля'])

@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);
}
}

И последнее — добавляем в layout-шаблон мета-тег c csrf-токеном, чтобы использовать в ajax-запросах:

<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>

Магазин на Laravel 7, часть 22. Рефакторинг кода, работа


над каталогом товаров и корзиной
Рефакторинг кода
Фреймворк Laravel имеет в своём арсенале много полезных функций, и одна из них — привязка модели к
маршруту (Route Model Binding). Привязка модели к маршруту — это механизм внедрения экземпляра
модели по ключу маршрута. Звучит сложно, но на самом деле все просто. Мы уже использовали привязку
в панели управления много раз.

Сейчас наш контроллер 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'));
}

public function category($slug) {


$category = Category::where('slug', $slug)->firstOrFail();
return view('catalog.category', compact('category'));
}

public function brand($slug) {


$brand = Brand::where('slug', $slug)->firstOrFail();
return view('catalog.brand', compact('brand'));
}

public function product($slug) {


$product = Product::where('slug', $slug)->firstOrFail();
return view('catalog.product', compact('product'));
}
}
И у нас есть маршруты в файле routes/web.php:
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');

Мы можем упростить контроллер, чтобы не самим получать экземпляр модели категории, бренда и
товара, а доверить это Laravel. Фреймворк будет внедрять экземпляры моделей в методы контроллера,
если мы используем «type hinting».

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'));
}

public function category(Category $category) {


return view('catalog.category', compact('category'));
}

public function brand(Brand $brand) {


return view('catalog.brand', compact('brand'));
}

public function product(Product $product) {


return view('catalog.product', compact('product'));
}
}
Важно, что название параметра /catalog/brand/{brand} должно соответствовать имени переменной
аргумента метода brand(Brand $brand). Только так фреймворк будет понимать, что надо создать
экземпляр модели App\Models\Brand и внедрить его в метод brand().
Route::get('/catalog/brand/{brand}', 'CatalogController@brand')-
>name('catalog.brand');

class CatalogController extends Controller {

public function brand(Brand $brand) {


return view('catalog.brand', compact('brand'));
}
}

Давайте так и сделаем:

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>

Еще лучше — вообще не указывать имя параметра, чтобы больше с этим никогда не сталкиваться, если
возникнет необходимость изменить имя параметра маршртута.

<a href="{{ route('catalog.brand', [$brand->slug]) }}">{{ $brand->name }}</a>


<a href="{{ route('catalog.category', [$category->slug]) }}">{{ $category->name
}}</a>
<a href="{{ route('catalog.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>

2. Главная страница каталога


Кроме корневых категорий будем показывать популярные бренды. Для этого изменим
метод index() контроллера CatalogController.
class CatalogController extends Controller {
public function index() {
// корневые категории
$roots = Category::where('parent_id', 0)->get();
// популярные бренды
$brands = Brand::popular();
return view('catalog.index', compact('roots', 'brands'));
}
}
Шаблон для показа главной страницы каталога views/catalog/category.blade.php:
@extends('layout.site', ['title' => 'Каталог товаров'])
@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 class="mb-4">Разделы каталога</h2>


<div class="row">
@foreach ($roots as $root)
@include('catalog.part.category', ['category' => $root])
@endforeach
</div>

<h2 class="mb-4">Популярные бренды</h2>


<div class="row">
@foreach ($brands as $brand)
@include('catalog.part.brand', ['brand' => $brand])
@endforeach
</div>
@endsection
Вспомогательный шаблон views/catalog/part/brand.blade.php:
<div class="col-md-4 mb-4">
<div class="card list-item">
<div class="card-header px-1">
<h3 class="mb-0">{{ $brand->name }}</h3>
</div>
<div class="card-body p-0">
@if ($brand->image)
@php($url = url('storage/catalog/brand/thumb/' . $brand->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 px-1">
<a href="{{ route('catalog.brand', ['brand' => $brand->slug]) }}"
class="btn btn-dark">Товары бренда</a>
</div>
</div>
</div>
3. Товары бренда
Добавим постраничную навигацию для просмотра товаров бренда. Для этого изменим
метод brand() контроллера CatalogController.
class CatalogController extends Controller {
public function brand(Brand $brand) {
$products = $brand->products()->paginate(6);
return view('catalog.brand', compact('brand', 'products'));
}
}
Шаблон для показа товаров бренда views/catalog/brand.blade.php:
@extends('layout.site', ['title' => $brand->name])

@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>

Магазин на Laravel 7, часть 23. Главная страница сайта,


новинки, лидеры продаж и распродажа
На главной странице сайта будем показывать новинки, лидеров продаж и товары распродажи. Для этого
нам надо добавить в таблицу базы данных products три новых поля — new, hit и sale. Тогда для
главной страницы сможем отобрать и показать три коллекции. Потребуется добавить на форму для
редактирования товара в панели управления три checkbox-а.

Три новых поля


Добавляем три новых поля в таблицу products базы данных:
> php artisan make:migration alter_products_table --table=products
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class AlterProductsTable extends Migration {


/**
* Run the migrations.
*
* @return void
*/
public function up() {
Schema::table('products', function (Blueprint $table) {
$table->boolean('new')->after('price')->default(false);
$table->boolean('hit')->after('price')->default(false);
$table->boolean('sale')->after('price')->default(false);
});
}

/**
* 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;

class ProductCatalogRequest extends CatalogRequest {

/**
* С какой сущностью сейчас работаем (товар каталога)
* @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;

class IndexController extends Controller {


public function __invoke(Request $request) {
$new = Product::whereNew(true)->latest()->limit(3)->get();
$hit = Product::whereHit(true)->latest()->limit(3)->get();
$sale = Product::whereSale(true)->latest()->limit(3)->get();
return view('index', compact('new', 'hit', 'sale'));
}
}
@extends('layout.site')

@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;

class OrderController extends Controller {

public function index() {


$orders = Order::whereUserId(auth()->user()->id)
->orderBy('created_at', 'desc')
->paginate(5);
$statuses = Order::STATUSES;
return view('user.order.index', compact('orders', 'statuses'));
}

public function show(Order $order) {


if (auth()->user()->id !== $order->user_id) {
// можно просматривать только свои заказы
abort(404);
}
$statuses = Order::STATUSES;
return view('user.order.show', compact('order', 'statuses'));
}
}

Два новых маршрута для просмотра списка заказов и отдельного заказа:

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

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

@section('content')
<h1>Данные по заказу № {{ $order->id }}</h1>

<p>Статус заказа: {{ $statuses[$order->status] }}</p>

<h3 class="mb-3">Состав заказа</h3>


<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>

<h3 class="mb-3">Данные покупателя</h3>


<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

В шаблоне главной страницы личного кабинета создадим ссылки для просмотра профилей и заказов:

@extends('layout.site', ['title' => 'Личный кабинет'])

@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

Магазин на Laravel 7, часть 24. Фильтр товаров категории


по цене, новинкам и лидерам продаж
Фильтр для товаров категории
Поскольку у нас теперь товары имеют атрибуты new, hit и sale, мы можем их отбирать по этим
атрибутам. Другими словами — реализовать фильтр товаров. Давайте создадим
шаблон filter.blade.php в директрии views/catalog/part и подключим его в
шаблоне catalog.category.
@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>
<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% — попадают в дорогие.

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()->sortBy('price')->values();
$count = $products->count();
if ($count > 1) {
$half = intdiv($count, 2);
if ($count % 2) {
// нечетное кол-во товаров, надо найти цену товара, который
ровно посередине
$avg = $products[$half]['price'];
} else {
// четное количество, надо найти такую цену, которая поделит
товары пополам
$avg = 0.5 * ($products[$half - 1]['price'] +
$products[$half]['price']);
}
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'));
}
/* ... */
}

Рефакторинг кода
Теперь метод category() контроллера выглядит запутанно, и выполняет слишком много работы.
Давайте вынесем фильтрацию товаров в отдельный класс app/Helpers/ProductFilter:
namespace App\Helpers;

use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\Builder;

class ProductFilter {

private $builder;
private $request;

public function __construct(Builder $builder, Request $request) {


$this->builder = $builder;
$this->request = $request;
}

public function apply() {


foreach ($this->request->query() as $filter => $value) {
if ($this->exists($filter)) {
$this->$filter($value);
}
}
return $this->builder;
}

private function exists($filter) {


return method_exists($this, $filter);
}

private function price($value) {


if (in_array($value, ['min', 'max'])) {
$products = $this->builder->get();
$count = $products->count();
if ($count > 1) {
$max = $this->builder->get()->max('price'); // цена самого дорогого
товара
$min = $this->builder->get()->min('price'); // цена самого дешевого
товара
$avg = ($min + $max) * 0.5;
if ($value == 'min') {
$this->builder->where('price', '<=', $avg);
} else {
$this->builder->where('price', '>=', $avg);
}
}
}
}

private function new($value) {


if ('yes' == $value) {
$this->builder->where('new', true);
}
}

private function hit($value) {


if ('yes' == $value) {
$this->builder->where('hit', true);
}
}

private function sale($value) {


if ('yes' == $value) {
$this->builder->where('sale', true);
}
}
}
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);

$products = (new ProductFilter($builder, $request))->apply()->paginate(6)-


>withQueryString();

return view('catalog.category', compact('category', 'products'));


}
/* ... */
}

Используем скоупы
Сейчас, чтобы получить товары категории, мы используем следующий код:

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);

$products = (new ProductFilter($builder, $request))->apply()->paginate(6)-


>withQueryString();

return view('catalog.category', compact('category', 'products'));


}
/* ... */
}

При использовании локального скоупа (scope), получаем уже такой код:

class CatalogController extends Controller {


/* ... */
public function category(Request $request, Category $category) {
$descendants = $category->getAllChildren($category->id);
$descendants[] = $category->id;
$builder = Product::categoryProducts($descendants);

$products = (new ProductFilter($builder, $request))->apply()->paginate(6)-


>withQueryString();

return view('catalog.category', compact('category', 'products'));


}
/* ... */
}
class Product extends Model {
/**
* Позволяет выбирать товары категории и всех ее потомков
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param array $parents
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeCategoryProducts($query, $parents) {
return $query->whereIn('category_id', $parents);
}
/* ... */
}

Кажется, что в этом нет особого смысла, но давайте добавим еще один скоуп:

class CatalogController extends Controller {


/* ... */
public function category(Category $category, ProductFilter $filters) {
$descendants = $category->getAllChildren($category->id);
$descendants[] = $category->id;
// получаем товары категории и ее потомков, потом применяем фильтры
$builder = Product::categoryProducts($descendants)-
>filterProducts($filters);

$products = $builder->paginate(6)->withQueryString();

return view('catalog.category', compact('category', 'products'));


}
/* ... */
}
class Product extends Model {
/**
* Позволяет выбирать товары категории и всех ее потомков
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param array $parents
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeCategoryProducts($query, $parents) {
return $query->whereIn('category_id', $parents);
}

/**
* Позволяет фильтровать товары по нескольким условиям
*
* @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;

public function __construct(Request $request) {


$this->request = $request;
}

public function apply($builder) {


$this->builder = $builder;
foreach ($this->request->query() as $filter => $value) {
if ($this->exists($filter)) {
$this->$filter($value);
}
}
return $this->builder;
}

private function exists($filter) {


return method_exists($this, $filter);
}
/* ... */
}
Только вызов метода Category::getAllChildren() все портит — давайте сделаем этот метод
статическим. И будем его вызывать в методе scopeCategoryProducts() модели Product, а не в
методе category() контроллера CatalogController.
class Product extends Model {
/**
* Возвращает всех потомков категории с идентификатором $id
*/
public static 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, self::getAllChildren($child->id));
}
}
return $ids;
}

/**
* Позволяет выбирать товары категории и всех ее потомков
*
* @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'));
}
/* ... */
}

Фильтр для товаров бренда


Аналогичный фильтр можем сделать для товаров брнеда, для этого добавляем форму в
шаблон catalog.brand:
@extends('layout.site', ['title' => $brand->name])

@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'));
}
/* ... */
}

Приводим в порядок маршруты


Это файл routes/web.php — чтобы сразу было понятно, какой маршрут за что отвечает:
/*
* Главная страница интернет-магазина
*/
Route::get('/', 'IndexController')->name('index');

/*
* Страницы «Доставка», «Контакты» и прочие
*/
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');
});

Вам также может понравиться