Блог на Laravel 7, часть 12. Доп.страницы сайта в панели управления и в публичной части

22.01.2021

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

Страницы в панели управления

Хотя у нас простой блог, но может возникнуть необходимость создания страниц сайта — что-то типа «Об этом сайте» или «Размещение рекламы». И у администратора должна быть возможность такие страницы создавать, редактировать и удалять. Давайте создадим еще одну таблицу базы данных pages и ресурсный контроллер для CRUD-операций над страницами.

> php artisan make:model Page -m
namespace App;

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);
    }
}
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();
            $table->string('name', 100)->nullable(false);
            $table->text('content')->nullable(false);
            $table->string('slug', 100)->unique()->nullable(false);
            $table->timestamps();

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

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

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

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

use App\Helpers\ImageUploader;
use App\Http\Controllers\Controller;
use App\Page;
use Illuminate\Http\Request;

class PageController extends Controller {

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

    /**
     * Показывает список всех страниц
     */
    public function index() {
        $roots = Page::whereNull('parent_id')->with('children')->get();
        return view('admin.page.index', compact('roots'));
    }

    /**
     * Показывает форму для создания страницы
     */
    public function create() {
        $parents = Page::whereNull('parent_id')->get();
        return view('admin.page.create', compact('parents'));
    }

    /**
     * Сохраняет новую страницу в базу данных
     */
    public function store(Request $request) {
        $this->validate($request, [
            'name' => 'required|max:100',
            'parent_id' => 'numeric|nullable',
            'slug' => 'required|max:100|unique:pages|regex:~^[-_a-z0-9]+$~i',
            'content' => 'required',
        ]);
        Page::create($request->all());
        return redirect()
            ->route('admin.page.index')
            ->with('success', 'Новая страница успешно создана');
    }

    /**
     * Показывает форму для редактирования страницы
     */
    public function edit(Page $page) {
        $parents = Page::whereNull('parent_id')->where('id', '<>', $page->id)->get();
        return view('admin.page.edit', compact('page', 'parents'));
    }

    /**
     * Обновляет страницу (запись в таблице БД)
     */
    public function update(Request $request, Page $page) {
        $this->validate($request, [
            'name' => 'required|max:100',
            'parent_id' => 'numeric|not_in:'.$page->id.'|nullable',
            // обязательное, не больше 100 символов, уникальное (без учета slug этой
            // записи таблицы pages) и содержать буквы, цифры, дефис и подчеркивание
            '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.index')
            ->with('success', 'Страница была успешно отредактирована');
    }

    /**
     * Удаляет страницу (запись в таблице БД)
     */
    public function destroy(Page $page, ImageUploader $imageUploader) {
        if ($page->children->count()) {
            return back()->withErrors('Нельзя удалить страницу, у которой есть дочерние');
        }
        $imageUploader->destroy($page->content);
        $page->delete();
        return redirect()
            ->route('admin.page.index')
            ->with('success', 'Страница сайта успешно удалена');
    }
}

Добавляем маршруты в файл web.php:

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

Добавляем свойство $fillable в модель, чтобы использовать «mass assignment» и настраиваем связи между таблицами:

namespace App;

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>
    @perm('create-page')
        <a href="{{ route('admin.page.create') }}" class="btn btn-success mb-4">
            Создать страницу
        </a>
    @endperm
    @if ($roots->count())
        <table class="table table-bordered">
            <tr>
                <th>#</th>
                <th width="45%">Наименование</th>
                <th width="45%">ЧПУ (англ.)</th>
                <th><i class="fas fa-edit"></i></th>
                <th><i class="fas fa-trash-alt"></i></th>
            </tr>
            @foreach ($roots as $root)
                <tr>
                    <td>{{ $root->id }}</td>
                    <td><strong>{{ $root->name }}</strong></td>
                    <td>{{ $root->slug }}</td>
                    <td>
                        @perm('edit-page')
                            <a href="{{ route('admin.page.edit', ['page' => $root->id]) }}">
                                <i class="far fa-edit"></i>
                            </a>
                        @endperm
                    </td>
                    <td>
                        @perm('delete-page')
                            <form action="{{ route('admin.page.destroy', ['page' => $root->id]) }}"
                                  method="post" onsubmit="return confirm('Удалить эту страницу?')">
                                @csrf
                                @method('DELETE')
                                <button type="submit" class="m-0 p-0 border-0 bg-transparent">
                                    <i class="far fa-trash-alt text-danger"></i>
                                </button>
                            </form>
                        @endperm
                    </td>
                </tr>
                @foreach ($root->children as $child)
                    <tr>
                        <td>{{ $child->id }}</td>
                        <td>{{ $child->name }}</td>
                        <td>{{ $child->slug }}</td>
                        <td>
                        @perm('edit-page')
                            <a href="{{ route('admin.page.edit', ['page' => $child->id]) }}">
                                <i class="far fa-edit"></i>
                            </a>
                        @endperm
                        </td>
                        <td>
                            @perm('delete-page')
                                <form action="{{ route('admin.page.destroy', ['page' => $child->id]) }}"
                                      method="post" onsubmit="return confirm('Удалить эту страницу?')">
                                    @csrf
                                    @method('DELETE')
                                    <button type="submit" class="m-0 p-0 border-0 bg-transparent">
                                        <i class="far fa-trash-alt text-danger"></i>
                                    </button>
                                </form>
                            @endperm
                        </td>
                    </tr>
                @endforeach
            @endforeach
        </table>
    @endif
@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.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.form')
    </form>
@endsection

Шаблон views/admin/page/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 ?? null;
    @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>

При выборе родителя для страницы нам приходится работать с null-значениями. У страницы может не быть родителя, в этом случае parent_id равно null. По умолчанию Laravel содержит посредники TrimStrings и ConvertEmptyStringsToNull, они перечислены в классе App\Http\Kernel. Чтобы валидатор не считал значение null как некорректное, мы добавляем правило валидации nullable.

Заполнение БД данными

Надо наполнить таблицу базы данных pages данными, то есть создать несколько страниц сайта.

> php artisan make:seeder PageTableSeeder
Seeder created successfully.
use Illuminate\Database\Seeder;

class PageTableSeeder extends Seeder {
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run() {
        // создать 5 страниц
        factory(App\Page::class, 5)->create();
    }
}
> php artisan make:factory PageFactory --model=Page
Factory created successfully.
use App\Page;
use Faker\Generator as Faker;
use Illuminate\Support\Str;

$factory->define(Page::class, function (Faker $faker) {
    $name = $faker->realText(rand(20, 30));
    $content = '<p>' . $faker->realText(rand(400, 500)) . '</p>' .
        '<p>' . $faker->realText(rand(400, 500)) . '</p>' .
        '<p>' . $faker->realText(rand(400, 500)) . '</p>';
    return [
        'name' => $name,
        'content' => $content,
        'slug' => Str::slug($name) . '-' . rand(100, 999),
    ];
});

И вносим изменения в класс DatabaseSeeder:

use Illuminate\Database\Seeder;

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

        /* ..... */

        $this->call(PageTableSeeder::class);
        $this->command->info('Таблица страниц загружена данными!');
    }
}

Кроме того, нам еще нужно добавить права доступа для управления страницами сайта:

use Illuminate\Database\Seeder;

class PermissionTableSeeder extends Seeder {
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run() {
        $permissions = [
            ['slug' => 'manage-users', 'name' => 'Управление пользователями'],
            ['slug' => 'create-user', 'name' => 'Создание пользователя'],
            ['slug' => 'edit-user', 'name' => 'Редактирование пользователя'],
            ['slug' => 'delete-user', 'name' => 'Удаление пользователя'],

            /* ...все прочие права... */

            ['slug' => 'manage-pages', 'name' => 'Управление страницами сайта'],
            ['slug' => 'create-page', 'name' => 'Создание страницы сайта'],
            ['slug' => 'edit-page', 'name' => 'Редактирование страницы сайта'],
            ['slug' => 'delete-page', 'name' => 'Удаление страницы сайта'],
        ];
        foreach ($permissions as $item) {
            $permission = new App\Permission();
            $permission->name = $item['name'];
            $permission->slug = $item['slug'];
            $permission->save();
        }
    }
}

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

> php artisan migrate:fresh --seed

Страницы в публичной части

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

Создаем контроллер для показа страницы:

> php artisan make:controller PageController --invokable
namespace App\Http\Controllers;

use App\Page;

class PageController extends Controller {
    public function __invoke(Page $page) {
        return view('page.show', compact('page'));
    }
}

Добавляем новый маршрут в файл routes/web.php:

/*
 * Страницы сайта с доп.информацией
 */
Route::get('page/{page:slug}', 'PageController')->name('page');

Создаем шаблон resource/views/page/show.blade.php для показа страницы:

@extends('layout.site', ['title' => $page->name])

@section('content')
    <h1>{{ $page->name }}</h1>
    {!! $page->content !!}
@endsection

Создадем шаблон resource/views/layout/part/all-pages.blade.php для показа списка страниц в левой колонке:

<ul>
@foreach($pages as $page)
    <li>
        <a href="{{ route('page', ['page' => $page->slug]) }}">{{ $page->name }}</a>
        @if ($page->children->count())
            <ul>
            @foreach($page->children as $child)
                <li>
                    <a href="{{ route('page', ['page' => $child->slug]) }}">{{ $child->name }}</a>
                </li>
            @endforeach
            </ul>
        @endif
    </li>
@endforeach
</ul>

Передадим в это шаблон коллекцию всех страниц верхнего уровня. Поскольку нам надо показывать и дочерние страницы — используем жадную загрузку, чтобы уменьшить кол-во SQL-запросов к базе данных.

class ComposerServiceProvider extends ServiceProvider {
    /* ... */
    public function register() {
        /* ... */
        View::composer('layout.part.all-pages', function($view) {
            $view->with(['pages' => Page::whereNull('parent_id')->with('children')->get()]);
        });
    }
    /* ... */
}

В layout-шаблоне resource/views/layout/site.blade.php подключим этот шаблон в левой колонке:

<div class="row">
    <div class="col-md-3">
        <h4>Категории блога</h4>
        @include('layout.part.categories', ['parent' => 0])
        <h4>Популярные теги</h4>
        @include('layout.part.popular-tags')
        <h4>Доп.информация</h4>
        @include('layout.part.all-pages')
    </div>
    <!-- ..... -->
</div>

Небольшое отступление

Прежде, чем двигаться дальше, давайте посмотрим, как в 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 • MySQL • PHP • Web-разработка • База данных • Блог • Панель управления • Практика • Страница сайта • Фреймворк • Шаблон сайта

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.