- PVSM.RU - https://www.pvsm.ru -

Laravel. Установка, настройка, создание и деплой приложения

Итак, у вас есть желание попробовать или узнать о фреймворке Laravel [1].

Если вы фамильярны с другими PHP фреймворками — для вас это не составит особого труда, если же нет — это отличный выбор для первого фреймворка.

Laravel - PHP framework for artisans!

Статья очень большая. Рекомендую читать ее полностью во время выходных.

Для ленивых:
GitHub [2]
Приложение [3]

Установка

Для установки Laravel нам потребуется Composer [4]

Composer является инструментом для управления зависимостями в PHP. Он позволяет объявлять зависимые библиотеки, необходимые для проекта, и устанавливать их в проект.
Composer [5]

Установка окружения будет происходить в среде *nix (на сайте так же есть мануал по установке на Windows [6], плюс к этому вам нужен будет сервер, например WAMP [7] и Git [8]).

Предположим, что у Вас совсем чистенькая ОС. Тогда откройте терминал и введите эти строчки скопируйте и вставьте

# Установка недостающих компонентов
sudo apt-get update
sudo apt-get install -y build-essential
sudo apt-get install -y python-software-properties

# Добавление в репозиторий php 5.5
sudo add-apt-repository ppa:ondrej/php5	
sudo apt-get update

# Установка сервера
sudo apt-get install -y php5
sudo apt-get install -y apache2
sudo apt-get install -y libapache2-mod-php5
sudo apt-get install -y mysql-server
sudo apt-get install -y php5-mysql
sudo apt-get install -y php5-curl
sudo apt-get install -y php5-gd
sudo apt-get install -y php5-mcrypt
sudo apt-get install -y git-core
sudo apt-get install -y phpmyadmin

# Хак для phpmyadmin
echo "Include /etc/phpmyadmin/apache.conf" | sudo tee -a /etc/apache2/apache2.conf 
# Перезапустим apache
sudo /etc/init.d/apache2 restart

# Включение mod_rewrite
sudo a2enmod rewrite 

# Глобально установим Composer
curl -sS https://getcomposer.org/installer | php 
sudo mv composer.phar /usr/local/bin/composer

Через некоторое время у вас будут установлены все необходимые инструменты.
Перейдем непосредственно к установке Laravel.

# Предпочитаемая мной структура папок
cd # перейдем в директорию /home/%user%
mkdir workspace #создадим папку workspace
cd workspace # перейдем в нее
mkdir php # создадим папку php
cd php # перейдем в папку php

Создадим проект laravel в папке habr

composer create-project laravel/laravel habr --prefer-dist 
# .... тут будет долгий процес создания проекта ....

Перейдем в созданный проект и убедимся, что все работает, запустив команду php artisan serve

cd habr
php artisan serve

Локальный сервер будет доступен по адресу http://localhost:8000 [9].

На всякий случай artisan — это скрипт для командной строки, который есть в Laravel. Он предоставляет ряд полезных команд для использования при разработке. Он работает поверх компонента консоли Symfony. (Artisan CLI [10]). Есть много полезных команд, с помощью которых в командной строке можно создавать разные полезные вещи. Для списка команд введите php artisan list в командной сроке.

Перейдя по адресу http://localhost:8000 [9] вы должны увидеть красивую заставку как в начале поста.

Настройка

Для соединения с бозой данных (далее БД) у Laravel есть конфигурационный файл database.php, находится он в папке app/config/.
Сначала создадим БД и пользователя в MySQL

mysql -u root -p 
# Введите свой пароль
> CREATE DATABASE `habr` CHARACTER SET utf8 COLLATE utf8_general_ci;
> CREATE USER 'habr'@'localhost' IDENTIFIED BY 'my_password';
> GRANT ALL PRIVILEGES ON habr.* TO 'habr'@'localhost';
> exit

Отлично! У нас есть все данные для доступа к MySQL: пользователь habr с паролем my_password и БД habr на хосте localhost. Перейдем в файл конфигурации БД и изменим наши настройки.

Laravel файл конфигурации БД

В Laravel есть отличные инструменты — Миграции [11] и Построитель Схем [12].

Миграции это тип управления версиями в базе данных. Они позволяют команде разработчиков изменять схему базы данных и оставаться в курсе о текущем состоянии схемы. Миграция, как правило, в паре с Построителем Схем позволют легко управлять схемой БД.
Миграции [11]
Построитель Схем — это класс Sheme. Он дает возможность манипулирования таблицами в БД. Он хорошо работает со всеми БД, которые поддерживаются Laravel, и имеет единый API для всех этих систем.
Построитель Схем [12]

Во первых создадим таблицу миграций:

php artisan migrate:install

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

Laravel 4 Generators

Мега полезный инструмент — generators от Jeffrey Way. GitHub [13].

Он добавляет в список artisan много полезных команд, таких как:

  • generate:model — создание моделей
  • generate:controller — создание контроллеров
  • generate:seed — создание файлов для наболнения БД фейковой информацией
  • generate:view — создание шаблонов
  • generate:migration — создание миграций
  • generate:resource — создание ресурсов
  • generate:scaffold — создание прототипов (самое интересное, его рассмотрим подробнее чуть позже!)
  • generate:form — создание форм
  • generate:test — создание тестов
  • generate:pivot — создание миграции сводной таблицы
Установка пакета

Установка пакетов с помощью Composer происходит достаточно просто. Нужно отредактировать файл composer.json в корне приложения, добавив строчку "way/generators": "dev-master" в список "require".

"require": {
	"laravel/framework": "4.0.*",
	"way/generators": "dev-master"
},

После этого нужно обновить зависимости проекта. Введите в терминале

composer update

Последним штрихом будет занесение в кофигурационный файл app/config/app.php в список провайдеров приложения строки

'WayGeneratorsGeneratorsServiceProvider'

Теперь список команд php artisan будет также содержать новые команды generate. В следующем разделе я покажу как использовать generate для создания приложения и ускорения разработки.

Создание приложения

Предположим, что мы создаем некий блог сайт со скидками. Для этого нам нужно:

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

Набросаем схему таблиц в БД. У меня получилось что-то такое:
Initial DB Schema
За это спасибо generator'у. Так как все, что я сделал — это прописал 10 строк, кстати, вот и они:

php artisan generate:migration create_users_table --fields="email:string:unique, password:string[60], username:string:unique"
php artisan generate:scaffold role --fields="role:string:unique"
php artisan generate:pivot users roles
php artisan generate:scaffold city --fields="name:string:unique"
php artisan generate:scaffold company --fields="title:string:unique"
php artisan generate:scaffold tag --fields="title:string:unique"
php artisan generate:scaffold offer --fields="title:string, description:text, city_id:integer:unsigned, company_id:integer:unsigned, off:integer:unsigned, image:string, expires:date"
php artisan generate:scaffold comment --fields="body:text, user_id:integer:unsigned, offer_id:integer:unsigned, mark:integer"
php artisan generate:pivot offers tags

# И сохраним схемы в БД
php artisan migrate 

С помощью последней команды в БД будут занесены все миграции, которые еще не были записаны. Важно то, что все новые миграции будут запущены одним стэком. Для того, чтобы откатить миграцию есть команда php artisan migrate:rollback, а для того, чтобы откатить все миграции до нуля migrate:reset, чтобы скатить до нуля и запустить все миграции migrate:refresh.

Подробнее о командах генератора:

  • generate:migration Принимает имя аргумент миграции, и создает соответсвующую схему. В имени схемы можно указать ключевые слова, например create — создание, далее идет имя таблицы и ключевое слово table. Так же можно указать какие поля добавить в таблицу через опцию --fields="", в которой через запятую перечислить поля с ихним типом данных. Создание миграции [14], Типы данных и прочее [15]
  • generate:scaffold Принимает как агрумент ресурс (к примеру role), и создает такие файлы:
    • app/models/Role.php — клас модели, наследуемый от Eloquent ORM [16] для работы с таблицей ролей (имя самой таблицы — это множественное число от имени ресурса)
    • app/controllers/RolesController.php — клас контроллера, который отвечает на запросы к сайту, так же является REST [17] контроллером
      Метод HTTP Путь (URL) Действие Имя маршрута
      GET /resource index resource.index
      GET /resource/create create resource.create
      POST /resource store resource.store
      GET /resource/{id} show resource.show
      GET /resource/{id}/edit edit resource.edit
      PUT/PATCH /resource/{id} update resource.update
      DELETE /resource/{id} destroy resource.destroy

    • app/views/roles/index.blade.php — шаблон, который отвечает за список всех ресурсов (обычно генерируется при GET запросе по URL /roles), про сам шаблонизатор я расскажу чуть позже
    • app/views/roles/show.blade.php — шаблон, который отвечает за отображение конкретного ресурса (GET запрос на URL /roles/{id})
    • app/views/roles/create.blade.php — шаблон, в котором находится форма для добавления ресурса (GET на URL /roles/create)
    • app/views/roles/edit.blade.php — шаблон, в котором находится форма для редактирования ресурса (GET на URL /roles/{id}/edit})
    • app/views/layouts/scaffold.blade.php — основной лейаут приложения (содержит базовый html + bootstrap + контейнер для вставляемого контента)
    • app/database/migrations/Create_roles_table.php — миграция
    • app/database/seeds/RolesTableSeeder.php — файл для тестового наполнения таблицы данными
    • app/tests/controllers/RolesTest.php — различные тесты

    а так же обновляет и добавляет данные в файлы

    • app/database/seeds/DatabaseSeeder.php — добавляет вызов RolesTableSeeder
    • app/routes.php — добавляет в регистр маршрутов все методы ресурса (REST [17])

  • generate:pivot Принимает 2 аргумента (имена таблиц). Создает сводную таблицу, которая содержит 2 foreign key

Я надеюсь этот пример использования генератора достаточно наглядно показал, каким образом его использовать и насколько он полезен.

Чего нам еще не хватает — так это некоторых связок между таблицами.

Важно знать! При добавлении foreign key к колонке в таблице нужно убедится, что колонка является unsigned.

Что ж, добавим их:

php artisan generate:migration add_foreign_user_id_and_offer_id_to_comments_table
php artisan generate:migration add_foreign_city_id_and_company_id_to_offers_table

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

...
class AddForeignUserIdAndOfferIdToCommentsTable extends Migration {
	...
	public function up()
	{
		Schema::table('comments', function(Blueprint $table) {
			$table->index('user_id');
			$table->index('offer_id');
			$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
			$table->foreign('offer_id')->references('id')->on('offers')->onDelete('cascade');
		});
	}
	...
	public function down()
	{
		Schema::table('comments', function(Blueprint $table) {
			$table->dropForeign('comments_user_id_foreign');
			$table->dropForeign('comments_offer_id_foreign');
			$table->dropIndex('comments_user_id_index');
			$table->dropIndex('comments_offer_id_index');
		});
	}
}
...
class AddForeignCityIdAndCompanyIdToOffersTable extends Migration {
	...
	public function up()
	{
		Schema::table('offers', function(Blueprint $table) {
			$table->index('city_id');
			$table->index('company_id');
			$table->foreign('city_id')->references('id')->on('cities')->onDelete('cascade');
			$table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade');
		});
	}
	...
	public function down()
	{
		Schema::table('offers', function(Blueprint $table) {
			$table->dropForeign('offers_city_id_foreign');
			$table->dropForeign('offers_company_id_foreign');
			$table->dropIndex('offers_city_id_index');
			$table->dropIndex('offers_company_id_index');
		});
	}
}

Взгянув на схему БД видим ситуацию по лучше
Cool DB Schema

На данный момент все ссылки на ресурсы являются открытыми, и по ним можно переходить всем кому угодно.
Допустим, добавим роль admin. По ссылке http://localhost:8000/roles [18] видим следующую картину:
Admin role added

Немного о шаблонах и шаблонизаторе Blade [19] в Laravel.
Для файлов шаблонов используется раширение .balde.php. Заглянув в файл app/views/layouts/scaffold.blade.php мы видим

// app/views/layouts/scaffold.blade.php
<!doctype html>
<html>
	<head>
		<meta charset="utf-8">
		<link href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.1/css/bootstrap-combined.min.css" rel="stylesheet">
		<style>
			table form { margin-bottom: 0; }
			form ul { margin-left: 0; list-style: none; }
			.error { color: red; font-style: italic; }
			body { padding-top: 20px; }
		</style>
	</head>

	<body>

		<div class="container">
			@if (Session::has('message'))
				<div class="flash alert">
					<p>{{ Session::get('message') }}</p>
				</div>
			@endif

			@yield('main')
		</div>

	</body>

</html>

Что здесь происходит? Сам файл является скелетом, лэйаутом, который можно расширить, добавив внутрь секции main какой-то контент, или еще один шаблон. Двойные фигурные скобки {{ $var }} являются аналогом <?php echo $var; ?>. Класс Session [20] используется здесь для вывода сообщений пользователю, если мы передадим какое-то сообщение. Сообщение является временным, и при обновлении страницы пропадет. Если мы откроем только что созданный шаблон app/views/roles/index.blade.php

// app/views/roles/index.blade.php
@extends('layouts.scaffold')

@section('main')

<h1>All Roles</h1>

<p>{{ link_to_route('roles.create', 'Add new role') }}</p>

@if ($roles->count())
	<table class="table table-striped table-bordered">
		<thead>
			<tr>
				<th>Role</th>
			</tr>
		</thead>

		<tbody>
			@foreach ($roles as $role)
				<tr>
					<td>{{{ $role->role }}}</td>
					<td>{{ link_to_route('roles.edit', 'Edit', array($role->id), array('class' => 'btn btn-info')) }}</td>
					<td>
						{{ Form::open(array('method' => 'DELETE', 'route' => array('roles.destroy', $role->id))) }}
							{{ Form::submit('Delete', array('class' => 'btn btn-danger')) }}
						{{ Form::close() }}
					</td>
				</tr>
			@endforeach
		</tbody>
	</table>
@else
	There are no roles
@endif

@stop

То нам станет ясно, что этот шаблон расширяет шаблон app/views/layouts/scaffold.blade.php, за это говорит код @extends('layouts.scaffold'). Заметьте, что тут для разделения между папками используется точка, хотя так же можно использовать и /.

Далее в секцию main будет записано все до первого появления @stop. Так же тут используются знакомые нам if - else - endif и foreach - endforeach, вспомогательная функция link_to_route, которую нам предоставляет Laravel (Helper Functions) и класс Form для создания форм (Предпочтительно нужно пользоваться им, хотя бы Form::open(), так как он создает дополнительный аттрибут формы _token [21] — защита от подделки кросс сайтовых запросов и _method [22] в случае PUT / PATCH или DELETE).

Первым делом подумаем о защите всех ресурсов. Для этого нам нужно ввести авторизацию.

Создадим новый контроллер LoginContoller в папке app/controllers

php artisan generate:controller LoginController

И добавим для него несколько шаблонов

mkdir app/views/login
php artisan generate:view index --path="app/views/login"
php artisan generate:view register --path="app/views/login"
php artisan generate:view dashboard --path="app/views/login"

Теперь изменим сам контроллер. Нам нужны 5 методов:

  • index — отвечает за генерацию формы входа
  • register — отвечает за генерацию форми регистрации
  • store — отвечает за регистрацию нового пользователя
  • login — отвечает за вход пользователя на сайт
  • logout — отвечает за выход пользователя

Измененный контроллер LoginController будет выглядеть так:

// app/controllers/LoginController.php
class LoginController extends BaseController {

	/**
	 * Login Form.
	 *
	 * @return Response
	 */
	public function index()
	{
		return View::make('login.index');
	}

	/**
	 * Registration form.
	 *
	 * @return Response
	 */
	public function create()
	{
		return View::make('login.register');
	}

	/**
	 * Registring new user and storing him to DB.
	 *
	 * @return Response
	 */
	public function store()
	{
		$rules = array(
			'email' 	=> 'required|email|unique:users,email',
			'password' 	=> 'required|alpha_num|between:4,50',
			'username'	=> 'required|alpha_num|between:2,20|unique:users,username'
		);

		$validator = Validator::make(Input::all(), $rules);

		if($validator->fails()){
			return Redirect::back()->withInput()->withErrors($validator);
		}

		$user = new User;
		$user->email = Input::get('email');
		$user->username = Input::get('username');
		$user->password = Hash::make(Input::get('password'));
		$user->save();

		Auth::loginUsingId($user->id);

		return Redirect::home()->with('message', 'Thank you for registration, now you can comment on offers!');
	}


	/**
	 * Log in to site.
	 *
	 * @return Response
	 */
	public function login()
	{
		if (Auth::attempt(array('email' => Input::get('email'), 'password' => Input::get('password')), true) ||
			Auth::attempt(array('username' => Input::get('email'), 'password' => Input::get('password')), true)) {
			return Redirect::intended('dashboard');
		}

		return Redirect::back()->withInput(Input::except('password'))->with('message', 'Wrong creadentials!');
	}


	/**
	 * Log out from site.
	 *
	 * @return Response
	 */
	public function logout()
	{
		Auth::logout();

		return Redirect::home()->with('message', 'See you again!');
	}

}

Первые два метода генерируют из шаблонов HTML.
Метод store сохраняет в нашу БД нового пользователя, принимая все входящие через POST данные от Input::all(). (Подробнее [23]).
В классе Input находятся данные, которые были отправлены при POST запросе. Он имеет ряд статичных методов, таких как all(), get(), has() и другие (Basic Input [24]).

Hash — это класс шифрования, который использует метод bcrypt [25], чтобы пароли в БД хранились в зашифрованом виде (Laravel Security [26]).

Но перед регистрацией нам нужно провести валидацию входящих данных.
Для этого в Laravel есть класс Validator [27]. Метод Validation::make принимает 2 или 3 аргумента:

  1. $input — обязательный, массив входящих данных, которые нужно проверить
  2. $rules — обязательный, массив с правилами к входящим данным
  3. $messages — опциональный, массив с сообщениями об ошибках

Полный список доступных правил можно посмотреть тут Available Validation Rules [28].

Метод fails() возвращает true или false в зависимости от того, прошли ли валидацию данные в соответствии с правилами, которые мы передали в метод make.

Класс Redirect [29] используется для перенаправления. Его методы:

  • back() — перенаправит на страницу, с которой был послан запрос
  • intended('fallback') — перенаправит на страницу, с которой пользователь попал под фильтр авторизации, если таковой не было, то отправит на URL, который передан в fallback
  • withInput() — передаст во временную сессию данные с Input
  • withErrors($validator) — передаст в переменную $errors данные с $validator (! Важно знать, что переменная $errors создается на всех страницах при GET запросах, поэтому она всегда доступна на всех страницах).
  • with('variable', 'Your message here') — передаст во временную сессию переменную 'variable' с сообщением, которое вы укажете

Класс Auth является классом авторизации, у него имется ряд методов, в том числе и loginUsingId($id), который авторизирует пользователя по указанному идентификатору из БД (Authenticating Users [30]). Так как после регисрации мы хотим автоматически авторизировать пользователя, то воспользуемся им.

Метод нашего Контроллера login() авторизирует пользователя по email или username и перенаправляет на страницу, с которой он попал под фильтр авторизации. В случае не совпадения данных, перенаправляет обратно с входящими данными, сообщением о ошибке, но без пароля.

Таким образом у нас есть Контроллер, который отвечает за авторизацию.

Следующим шагом для скрытия всех ресурсов от доступа будет изменение файла app/routes.php, который содержит маршруты приложения.

// app/routes.php
...
Route::get('/', array('as' => 'home', function()
{
	return View::make('hello');
}));

Route::get('logout', array('as' => 'login.logout', 'uses' => 'LoginController@logout'));

Route::group(array('before' => 'un_auth'), function()
{
	Route::get('login', array('as' => 'login.index', 'uses' => 'LoginController@index'));
	Route::get('register', array('as' => 'login.register', 'uses' => 'LoginController@register'));
	Route::post('login', array('uses' => 'LoginController@login'));
	Route::post('register', array('uses' => 'LoginController@store'));
});

Route::group(array('before' => 'admin.auth'), function()
{
	Route::get('dashboard', function()
	{
		return View::make('login.dashboard');
	});

	Route::resource('roles', 'RolesController');

	Route::resource('cities', 'CitiesController');

	Route::resource('companies', 'CompaniesController');

	Route::resource('tags', 'TagsController');

	Route::resource('offers', 'OffersController');

	Route::resource('comments', 'CommentsController');

});

Route::filter('admin.auth', function() 
{
	if (Auth::guest()) {
		return Redirect::to('login');
	}
});

Route::filter('un_auth', function() 
{
	if (!Auth::guest()) {
		Auth::logout();
	}
});

Перейдя теперь по ссылке, к примеру /roles нас будет перенаправлено на страницу /login, на которой пока отображается только стандартный текст «index.blade.php».

Ко всем маршрутам, заключенным в Route::group(array('before' => 'admin.auth')) будет применятся фильтр admin.auth, который проверяет, является ли пользователь гостем, или нет, и в случае, если является — отправит его на страницу входа. Про фильтры можно почитать тут [31], а про группировку маршрутов тут [32]. Другой фильтр Route::group(array('before' => 'un_auth')) будет проверять, является ло пользователь вашедшим на сайт, и если проверка выполнятся — то он его разлогинивает.

Для нормальной работы изменим файлы логина и регистрации:

// app/views/login/index.blade.php
@extends('layouts.scaffold')

@section('main')

<h1>Login</h1>

<p>{{ link_to_route('login.register', 'Register') }}</p>

{{ Form::open(array('route' => 'login.index')) }}
	<ul>
		<li>
			{{ Form::label('email', 'Email or Username:') }}
			{{ Form::text('email') }}
		</li>

		<li>
			{{ Form::label('password', 'Password:') }}
			{{ Form::password('password') }}
		</li>

		<li>
			{{ Form::submit('Submit', array('class' => 'btn btn-info')) }}
		</li>
	</ul>
{{ Form::close() }}

@include('partials.errors', $errors)

@stop

// app/views/login/register.blade.php
@extends('layouts.scaffold')

@section('main')

<h1>Register</h1>

<p>{{ link_to_route('login.index', 'Login') }}</p>

{{ Form::open(array('route' => 'login.register')) }}
	<ul>
		<li>
			{{ Form::label('email', 'Email:') }}
			{{ Form::text('email') }}
		</li>
		
		<li>
			{{ Form::label('username', 'Username:') }}
			{{ Form::text('username') }}
		</li>

		<li>
			{{ Form::label('password', 'Password:') }}
			{{ Form::password('password') }}
		</li>

		<li>
			{{ Form::submit('Submit', array('class' => 'btn btn-info')) }}
		</li>
	</ul>
{{ Form::close() }}

@include('partials.errors', $errors)

@stop
// app/views/login/dashboard.blade.php
@extends('layouts.scaffold')

@section('main')

<h1>Administrative Dashboard</h1>

<p>Nice to see you, <b>{{{ Auth::user()->username }}}</b></p>

@stop

// app/views/partials/errors.blade.php
@if ($errors->any())
	<ul>
		{{ implode('', $errors->all('<li class="error">:message</li>')) }}
	</ul>
@endif

Как вы заметили, тут я использовал новый прием в шаблонизаторе @include('view', $variable). В применении он весьма прост — передайте 2 аргумента:

  1. view — шаблон, который нужно включить в конкретный шаблон
  2. $variable — переменная, которую нужно передать для отрисовки шаблона

Зарегистрируйтесь на сайте, чтобы иметь доступ к сайту.

Что же, теперь можна заняться ресурсами. Начнем с городов. Первым делом изменим в Модели City правила валидации:

// app/models/City.php
class City extends Eloquent {
	protected $guarded = array();

	public static $rules = array(
		'name' => 'required|alpha|min:2|max:200|unique:cities,name'
	);
}

После нее изменим правила валидации так же и у Моделей Company, Role и Tag:

// app/models/Company.php
	...
	public static $rules = array(
		'name' => 'required|alpha|min:2|max:200|unique:companies,name'
	);
	...
// app/models/Role.php
	...
	public static $rules = array(
		'role' => 'required|alpha|min:2|max:200|unique:roles,role'
	);
	...
// app/models/Tag.php
	...
	public static $rules = array(
		'name' => 'required|min:2|max:200|unique:tags,name'
	);
	...

Для удобства перехода между ссылками добавим меню в app/views/layouts/scaffold.blade.php, а так же добавим jQuery и jQuery-UI для будующих нужд

// app/views/layouts/scaffold.blade.php
<!doctype html>
<html>
	<head>
		<meta charset="utf-8">
		<link href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.1/css/bootstrap-combined.min.css" rel="stylesheet">
		<link href="//code.jquery.com/ui/1.10.3/themes/smoothness/jquery-ui.css" rel="stylesheet">
		<style>
			table form { margin-bottom: 0; }
			form ul { margin-left: 0; list-style: none; }
			.error { color: red; font-style: italic; }
			body { padding-top: 20px; }
			input, textarea, .uneditable-input {width: 50%; min-width: 200px;}
		</style>
		@yield('styles')
	</head>

	<body>

		<div class="container">

			<ul class="nav nav-pills">
				<li>{{ link_to_route('offers.index', 'Offers') }}</li>
				<li>{{ link_to_route('tags.index', 'Tags') }}</li>
				<li>{{ link_to_route('roles.index', 'Roles') }}</li>
				<li>{{ link_to_route('cities.index', 'Cities') }}</li>
				<li>{{ link_to_route('comments.index', 'Comments') }}</li>
				<li>{{ link_to_route('companies.index', 'Companies') }}</li>
				<li class="pull-right">{{ link_to_route('login.logout', 'Logout') }}</li>
			</ul>

			@if (Session::has('message'))
				<div class="flash alert">
					<p>{{ Session::get('message') }}</p>
				</div>
			@endif

			@yield('main')
		</div>

		<script type="text/javascript" src="//code.jquery.com/jquery.min.js"></script>
		<script type="text/javascript" src="//code.jquery.com/ui/1.10.3/jquery-ui.min.js"></script>
		@yield('scripts')

	</body>

</html>

Далее перейдем к редактированию правил валидации в Модели Offer:

// app/models/Offer.php
	...
	public static $rules = array(
		'title' => 'required|between:5,200',
		'description' => 'required|min:10',
		'city_id' => 'required|exists:cities,id',
		'company_id' => 'required|exists:companies,id',
		'off' => 'required|numeric|min:1|max:100',
		'image' => 'required|regex://images/d{4}/d{2}/d{2}/([A-z0-9]){30}.jpg/', 
		// matches /images/2012/12/21/ThisIsTheEndOfTheWorldMaya2112.jpg
		'expires' => 'required|date'
	);

Здесь я использовал сложный паттерн для поля image, так как хочу воспользоваться средствами AJAX для загрузки картинок, и в саму валидацию передавать только путь к картинке на сервере. Значит начнем с изменения шаблона app/views/offers/create.blade.php и создания отдельного файла для скриптов.

// app/views/offers/create.blade.php
...
{{ Form::label('file', 'Image:') }}
{{ Form::file('file')}}
<img src="{{Input::old('image')}}" id="thumb" style="max-width:300px; max-height: 200px; display: block;">
{{ Form::hidden('image') }}
<div class="error"></div>
...
@section('scripts')
@include('offers.scripts')
@stop

// app/views/offers/scripts.blade.php
<script>
$(document).ready(function(){ 
	// Добавим красивый выбор даты
	$('#expires').datepicker({dateFormat: "yy-mm-dd"});

	var uploadInput = $('#file'), // Инпут с файлом
		imageInput = $('[name="image"]'), // Инпут с URL картинки
		thumb = document.getElementById('thumb'), // Превью картинки
		error = $('div.error'); // Вывод ошибки при загрузке файла

	uploadInput.on('change', function(){
		// Создадим новый объект типа FormData
		var data = new FormData();
		// Добавим в новую форму файл
		data.append('file', uploadInput[0].files[0]);

		// Создадим асинхронный запрос
		$.ajax({
			// На какой URL будет послан запрос
			url: '/upload',
			// Тип запроса
			type: 'POST',
			// Какие данные нужно передать
			data: data,
			// Эта опция не разрешает jQuery изменять данные
			processData: false,		
			// Эта опция не разрешает jQuery изменять типы данных
			contentType: false,		
			// Формат данных ответа с сервера
			dataType: 'json',
			// Функция удачного ответа с сервера
			success: function(result) { 	
				// Получили ответ с сервера (ответ содержится в переменной result)
				// Если в ответе есть объект filelink
				if (result.filelink) {		
					// Зададим сообтветсвующий URL нашему мини изображению
					thumb.setAttribute('src', result.filelink); 
					// Сохраним значение в input'е
					imageInput.val(result.filelink);
					// Скроем ошибку
					error.hide();
				} else {
					// Выведет текст ошибки с сервера
					error.text(result.message);
					error.show();
				}
			},
			// Что-то пошло не так
			error: function (result) {
				// Ошибка на стороне сервера
				error.text("Upload impossible");
				error.show();
			}
		});
	});

});
</script>

Здесь мы будем добавлять картинку по нажатию на input[name="file"] и отправлять ее с помощью AJAX по URL /upload. Ответом с этого URL будет ссылка на загруженное изображение. Эту ссылку мы вставим в атрибут src у картинки #thumb и сохраним в скрытом инпуте image. Дальше нам нужно в файле app/routes.php добавить маршут upload:

// app/routes.php
...
Route::group(array('before' => 'admin.auth'), function(){
	...

	Route::resource('comments', 'CommentsController');

	Route::post('upload', array('uses' => 'HomeController@uploadOfferImage'));
}
...

Отлично, URL мы зарегистрировали, осталось прописать логику в HomeController. Для этого в файле app/controllers/HomeController.php добавим метод uploadOfferImage
min:

// app/controllers/HomeController.php
class HomeController extends BaseController {
	...
	public function uploadOfferImage()
	{
		$rules = array('file' => 'mimes:jpeg,png');

		$validator = Validator::make(Input::all(), $rules);

		if ($validator->fails()) {
			return Response::json(array('message' => $validator->messages()->first('file')));
		}

		$dir = '/images'.date('/Y/m/d/');
		
		do {
			$filename = str_random(30).'.jpg';
		} while (File::exists(public_path().$dir.$filename));

		Input::file('file')->move(public_path().$dir, $filename);

		return Response::json(array('filelink' => $dir.$filename));
	}
}

Все достаточно просто: правила, валидация, ошибки, ответ. Что бы сохранить для начала мы зададим папку, в которую будем его сохранять — это public_path()/images/текущий год/месяц/дата/ (public_path() — это вспомогательная функция Laravel для пути к публичным файлам), далее создадим рандомное имя файла str_random(30) длиною 30 символов и расширением jpg. После этого воспользуемся классом Input и его методом file('file')->move('destination_path', 'filename'), где: 'file' — входящий файл, 'destination_path' — папка, в которую перемещаем файл, 'filename' — имя для файла, который будет сохранен.
Response::json выдаст ответ в формате json.
Отлично! Файлы у нас теперь загружаются с помощью AJAX.
AJAX upload Laravel
Следующим шагом будет изменение Form::input('number', 'city_id') и Form::input('number', 'company_id') на селекты с реальными данными.

// app/views/offers/create.blade.php
	...
	<?php $cities = array(0 => 'Choose city');
	foreach (City::get(array('id', 'name')) as $city) {
		$cities[$city->id] = $city->name;
	} ?>

	<li>
		{{ Form::label('city_id', 'City_id:') }}
		{{ Form::select('city_id', $cities) }}
	</li>

	<?php $companies = array(0 => 'Choose company');
	foreach (Company::get(array('id', 'name')) as $company) {
		$companies[$company->id] = $company->name;
	} ?>

	<li>
		{{ Form::label('company_id', 'Company_id:') }}
		{{ Form::select('company_id', $companies) }}
	</li>
	...

Как работают селекты можно глянуть тут Forms & Html (Dropdown Lists) [33]. Таким образом мы имеем возможность выбирать из существующих городов и компаний в БД.

Чего нам еще не хватает — так это добавление тегов к скидкам. Тут нам поможет jquery-ui с autocomplete [34] для добавления нескольких значений. Для этого расширим файл с скриптами app/views/offers/create.blade.php:

// app/views/offers/scripts.blade.php
<script>
$(document).ready(function(){ 
	...
	function split( val ) {
		return val.split( /,s*/ );
	}
	function extractLast( term ) {
		return split( term ).pop();
	}
 
	$( "#tags" )
	// don't navigate away from the field on tab when selecting an item
	.bind( "keydown", function( event ) {
		if ( event.keyCode === $.ui.keyCode.TAB &&
			$( this ).data( "ui-autocomplete" ).menu.active ) {
			event.preventDefault();
		}
	})
	.autocomplete({
		source: function( request, response ) {
			$.getJSON( "/tags", {
					term: extractLast( request.term ),
				}, 
				function( data ) {
					response($.map(data, function(item) {
						return {
							value: item.name
						}
					}))
				}
			);
		},
		search: function() {
			// custom minLength
			var term = extractLast( this.value );
			if ( term.length < 2 ) {
			return false;
			}
		},
		focus: function() {
			// prevent value inserted on focus
			return false;
		},
		select: function( event, ui ) {
			console.log(ui);
			console.log(this);
			var terms = split( this.value );
			// remove the current input
			terms.pop();
			// add the selected item
			terms.push( ui.item.value );
			// add placeholder to get the comma-and-space at the end
			terms.push( "" );
			this.value = terms.join( ", " );
			return false;
		}
	});
});
</script>

Это стандартный пример использования с сайта jqueryui.com [35], только немного модифицированный в точке ответа с сервера. Как вы видите, обращение идет по адресу /tags. Организуем логику ответа на AJAX запрос по этому URL.

// app/controllers/TagController.php
class TagsController extends BaseController {
	...
	/**
	 * Display a listing of the resource.
	 *
	 * @return Response
	 */
	public function index()
	{
		$tags = $this->tag->all();

		// Запрос является AJAX запросом
		if (Request::ajax()) {
			// Выберем только те теги, которые подходят по критериям поиска
			$tags = Tag::where('name', 'like', '%'.Input::get('term', '').'%')->get(array('name'));
			// Вернем ответ в формате json
			return $tags;
		}

		return View::make('tags.index', compact('tags'));
	}
	...

Интересно то, что Eloquent преобразуется в формат json, если мы ее возвращаем, поэтому здесь нет необходимости использовать Response::json(). И вот у нас автодополняются теги.

Последнее, что нам нужно сделать — это изменить логику создания скидок.

// app/controllers/OffersController.php
class OffersController extends BaseController {
	...
	/**
	 * Store a newly created resource in storage.
	 *
	 * @return Response
	 */
	public function store()
	{
		$rules = Offer::$rules;
		$rules['expires'] .= '|after:'.date('Y-m-d', strtotime('+1 day')).'|before:'.date('Y-m-d', strtotime('+1 month'));

		$validation = Validator::make(Input::all(), $rules);

		if ($validation->passes())
		{
			$tags = array();
			
			foreach (explode(', ', Input::get('tags')) as $tag_name) {
				if ($tag = Tag::where('name', '=', $tag_name)->first()) {
					$tags[] = $tag->id;
				}
			}

			if (count($tags) == 0) {
				return Redirect::route('offers.create')
					->withInput()
					->with('message', 'Insert at least one tag.');
			}
			
			$offer = $this->offer->create(Input::except('tags', 'file'));
			$offer->tags()->sync($tags);

			return Redirect::route('offers.index');
		}

		return Redirect::route('offers.create')
			->withInput()
			->withErrors($validation)
			->with('message', 'There were validation errors.');
	}
	...

Во первых, расширим правило expires, что бы скидка заканчивалась не раньше завтрашнего дня, и не позже, чем через 1 месяц. Далее выделим все id тегов в отдельный массив, проверив их наличие в БД. После идет небольшая проверка, введены ли теги. А под самый конец очень интересный прием: в Eloquent для связки таблиц можна использовать разные отношения (Eloquent Relationships [36]), к примеру, у Модели Offers может быть много тегов, соответсвенно пропишем это в Модели

// app/models/Offer.php
	...
	public function tags()
	{
		return $this->belongsToMany('Tag');
	}
	...

Таким образом мы создали связь между одной записью в таблице offers и многими записями в таблице tags. Теперь, обращаясь к методу $offer->tags() мы можем получить все теги, к которым привязана конкретная скидка. Но в данном примере у нас еще используется специальный метод для работы с промежуточными таблицами sync(array(1, 2, 3)), который запишет в промежуточную таблицу к offer_id нужные tag_id. Таблица offer_tag:
Pivot table offer to tag
Также нам нужно указать связь между записью в таблице offers и записями в таблицах cities и companies:

// app/models/Offer.php
	...
	public function city()
	{
		return $this->belongsTo('City');
	}

	public function company()
	{
		return $this->belongsTo('Company');
	}

	public function tags()
	{
		return $this->belongsToMany('Tag');
	}

	// Функция для сокращения текста с сохранением целосности слов + вывод с переносом строки
	public function webDescription($options = array())
	{
		$str = $this->description;

		if (isset($options['shorten'])) {
			$length = isset($options['length']) ? (int) $options['length'] : 250;
			$end = isset($options['end']) ? : '…';
			if (mb_strlen($str) > $length) {
				$str = mb_substr(trim($str), 0, $length);
				$str = mb_substr($str, 0, mb_strlen($str) - mb_strpos(strrev($str), ' '));
				$str = trim($str.$end);
			}
		}
		
		$str = str_replace("rn", '<br>', e($str));
		return $str;
	}
}

Осталось изменить файл app/views/offers/index.blade.php

// app/views/offers/index.blade.php
@if ($offers->count())
	<table class="table table-striped table-bordered">
		<thead>
			<tr>
				<th>Title</th>
				<th>Description</th>
				<th>City</th>
				<th>Company</th>
				<th>Off</th>
				<th>Image</th>
				<th>Tags</th>
				<th>Expires</th>
			</tr>
		</thead>

		<tbody>
			@foreach ($offers as $offer)
				<tr>
					<td>{{{ $offer->title }}}</td>
					<td>{{ $offer->webDescription(array('shorten' => true, 'length' => 60)) }}</td>
					<td>{{{ $offer->city->name }}}</td>
					<td>{{{ $offer->company->name }}}</td>
					<td>{{{ $offer->off }}}</td>
					<td><img src="{{{ $offer->image }}}" style="max-width: 200px; max-height:150px;"></td>
					<td>
						@foreach($offer->tags as $tag)
							<span class="badge">{{{$tag->name}}}</span>
						@endforeach
					</td>
					<td>{{{ $offer->expires }}}</td>
					<td>
						{{ link_to_route('offers.edit', 'Edit', array($offer->id), array('class' => 'btn btn-info')) }}
					</td>
					<td>
						{{ Form::open(array('method' => 'DELETE', 'route' => array('offers.destroy', $offer->id))) }}
							{{ Form::submit('Delete', array('class' => 'btn btn-danger')) }}
						{{ Form::close() }}
					</td>
				</tr>
			@endforeach
		</tbody>
	</table>
@else
	There are no offers
@endif

И мы видим отличную картину, которая полностью отображает структуру скидки:
All offers

{{{ $string }}} выводит содержимое $string, предварительно прогнав через htmlentities, то бишь конвертирует не безопасные символы, что защищает от XSS. Аналогом является <?php echo htmlentities($string); ?> или вспомогательной функции Laravel e($string)

Теперь осталось изменить app/views/offers/edit.blade.php, app/views/offers/show.blade.php и метод update в app/controllers/OfferController.php.

Код для app/views/edit.blade.php

// app/views/offers/edit.blade.php
@extends('layouts.scaffold')

@section('main')

<h1>Edit Offer</h1>
{{ Form::model($offer, array('method' => 'PATCH', 'route' => array('offers.update', $offer->id))) }}
	<ul>
		<li>
			{{ Form::label('title', 'Title:') }}
			{{ Form::text('title') }}
		</li>

		<li>
			{{ Form::label('description', 'Description:') }}
			{{ Form::textarea('description') }}
		</li>

		<?php $cities = array(0 => 'Choose city');
		foreach (City::get(array('id', 'name')) as $city) {
			$cities[$city->id] = $city->name;
		} ?>

		<li>
			{{ Form::label('city_id', 'City_id:') }}
			{{ Form::select('city_id', $cities) }}
		</li>

		<?php $companies = array(0 => 'Choose company');
		foreach (Company::get(array('id', 'name')) as $company) {
			$companies[$company->id] = $company->name;
		} ?>

		<li>
			{{ Form::label('company_id', 'Company_id:') }}
			{{ Form::select('company_id', $companies) }}
		</li>

		<li>
			{{ Form::label('off', 'Off:') }}
			{{ Form::input('number', 'off') }}
		</li>

		<li>
			{{ Form::label('file', 'Image:') }}
			{{ Form::file('file')}}
			<img src="{{Input::old('image', $offer->image)}}" id="thumb" style="max-width:300px; max-height: 200px; display:block; ">
			{{ Form::hidden('image') }}
			<div class="error"></div>
		</li>

		<li>
			{{ Form::label('expires', 'Expires:') }}
			{{ Form::text('expires') }}
		</li>

		<li>
			{{ Form::label('tags', 'Tags:') }}
			{{ Form::text('tags', Input::old('tags', implode(', ', array_fetch($offer->tags()->get(array('name'))->toArray(), 'name')))) }}
		</li>

		<li>
			{{ Form::submit('Update', array('class' => 'btn btn-info')) }}
			{{ link_to_route('offers.show', 'Cancel', $offer->id, array('class' => 'btn')) }}
		</li>
	</ul>
{{ Form::close() }}

@if ($errors->any())
	<ul>
		{{ implode('', $errors->all('<li class="error">:message</li>')) }}
	</ul>
@endif

@stop

@section('scripts')
@include('offers.scripts')
@stop

Изменения очень схожы с app/views/offers/create.blade.php, только есть небольшая разница в <img src="{{Input::old('image', $offer->image)}}"> и {{ Form::text('tags', ... }}. С картинкой все понятно: если есть старый инпут — заменяем на него, если его нет — то на значение image нашей скидки. В Form::text('tags', ... ) мы, во первых, взяли все теги, которые относятся к конкретной скидке $offer->tags() и выняли из БД только поля name. Далее воспользовались вспомогательной функцией от Laravel array_fetch [37], что бы у нас получился одномерный массив, а в конце соединили этот массив в строку, вставив запятую и пробел между ними.

Изменим метод update в OfferController:

// app/controllers/OfferController.php
class OffersController extends BaseController {
	...
	public function update($id)
	{
		$offer = $this->offer->findOrFail($id);

		$rules = Offer::$rules;
		$rules['expires'] .= '|after:'.date('Y-m-d', strtotime('+1 day')).'|before:'.date('Y-m-d', strtotime('+1 month'));

		$validation = Validator::make(Input::all(), $rules);

		if ($validation->passes())
		{
			$tags = array();

			foreach (explode(', ', Input::get('tags')) as $tag_name) {
				if ($tag = Tag::where('name', '=', $tag_name)->first()) {
					$tags[] = $tag->id;
				}
			}

			if (count($tags) == 0) {
				return Redirect::route('offers.create')
					->withInput()
					->withErrors($validation)
					->with('message', 'Insert at least one tag.');
			}
			
			$offer->update(Input::except('tags', 'file', '_method'));
			$offer->tags()->sync($tags);

			return Redirect::route('offers.show', $id);
		}

		return Redirect::route('offers.edit', $id)
			->withInput()
			->withErrors($validation)
			->with('message', 'There were validation errors.');
	}
	...

Различие с методом добавления минимальны. Во первых, выбросим 404 ошибку, если задан неправильный id, во вторых будем использовать метод update($id). Вот и все изменения.

Далее изменим файл app/views/offers/show.blade.php:

// app/views/offers/show.blade.php
...
<thead>
	<tr>
		<th>Title</th>
		<th>Description</th>
		<th>City_id</th>
		<th>Company_id</th>
		<th>Off</th>
		<th>Image</th>
		<th>Tags</th>
		<th>Expires</th>
	</tr>
</thead>

<tbody>
	<tr>
		<td>{{{ $offer->title }}}</td>
		<td>{{ $offer->webDescription(array('shorten' => true, 'length' => 60)) }}</td>
		<td>{{{ $offer->city->name }}}</td>
		<td>{{{ $offer->company->name }}}</td>
		<td>{{{ $offer->off }}}</td>
		<td><img src="{{{ $offer->image }}}" style="max-width: 200px; max-height:150px;"/></td>
		<td>
			@foreach($offer->tags as $tag)
				<span class="badge">{{{ $tag->name }}}</span>
			@endforeach
		</td>
		<td>{{{ $offer->expires }}}</td>
		...

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

Главная страница сайта

Настало время наконец то для создания главной страницы сайта.

Для начала создадим новый layout:

// app/views/layouts/main.blade.php
<!doctype html>
<html>
	<head>
		<meta charset="utf-8">
		<link href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.1/css/bootstrap-combined.min.css" rel="stylesheet">
		<link rel="stylesheet" type="text/css" href="{{ asset('css/main.css') }}">
		@yield('styles')
	</head>

	<body>

		<div class="navbar navbar-fixed-top">
			<div class="navbar-inner">
				<div class="container">
					<a class="brand" href="{{ route('home') }}">Habr Offers</a>
					<ul class="nav">
						<li><a href="{{ route('home') }}">Home</a></li>
					</ul>
				</div>
			</div>
		</div>

		<div class="container">

			@if (Session::has('message'))
				<div class="flash alert">
					<p>{{ Session::get('message') }}</p>
				</div>
			@endif

			@yield('main')
		</div>

		<script type="text/javascript" src="//code.jquery.com/jquery.min.js"></script>
		<script type="text/javascript" src="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.1/js/bootstrap.min.js"></script>
		@yield('scripts')

	</body>

</html>

А так же файл стилей:

// public/css/main.css
/* Так как у нас статичное верхнее меню - сделаем отступ от верха */
body {padding-top: 60px;}

/* Для ссылок, на которых не нужно подчеркивание */
.no_decoration:hover, .no_decoration:focus {text-decoration: none;} 

/* Выравнивание по высоте всех скидок вне зависимости от количества текста / изображения */
.thumbnail .image-container {width: 100%; max-height: 200px; overflow: hidden;}
.thumbnail .image-container img {min-width: 100%; min-height: 100%;}
.thumbnail h3 {height: 40px; overflow: hidden;}
.thumbnail .description {height: 100px; overflow: hidden;}

Потом переопределим маршрут главной страницы:

// app/routes.php
Route::get('/', array('as' => 'home', 'uses' => 'HomeController@index'));

Добавим в HomeController недостающий метод index:

// app/controllers/HomeController.php
	...
	/**
	 * Display a listing of offers.
	 *
	 * @return Response
	 */
	public function index()
	{
		$offers = Offer::orderBy('created_at', 'desc')->get();

		return View::make('home.index', compact('offers'));
	}
	...

Создадим папку app/views/homeи добавим туда файл index.blade.php, а так же создадим файл _preview.blade.php в папке app/views/offers

// app/views/home/index.blade.php
@extends('layouts.main')

@section('main')

<h1>{{ $title }}</h1>

@if ($offers->count())
	@foreach ($offers as $key => $offer)
		@if($key % 3 == 0)
			<div class="row-fluid">
				<ul class="thumbnails">
		@endif

		<li class="span4">
			<div class="thumbnail">
				@include('offers._preview', $offer)
			</div>
		</li>
			
		@if($key % 3 == 2 || $key == count($offers) - 1)
				</ul>
			</div>
		@endif
	@endforeach
@else
	There are no offers
@endif

@stop

// app/views/offers/_preview.blade.php
<div class="image-container">
	<img src="{{{ $offer->image }}}">
</div>
<div class="caption">
	<h3>{{{ $offer->title }}}</h3>
	<hr>
	<p class="description">{{ $offer->webDescription() }}</p>
	<hr>
	<p><span class="label label-important">{{{ $offer->off }}} % off</span></p>
	<p>Location: {{{ $offer->city->name }}}</p>
	<p>Offer by: {{{ $offer->company->name }}}</p>
	<p>Expires on: <span class="label label-warning">{{{ $offer->expires }}}</span></p>
	<p>Tags:
		@foreach($offer->tags as $tag)
			<span class="badge">{{{$tag->name}}}</span>
		@endforeach
	</p>
</div>

Далее нужно добавить поиск скидок по тегам, городам и компаниям. Для этого добавим 3 маршрута в файл app/routes.php сразу же за home:

// app/routes.php
...
Route::get('by_tag/{name}', array('as' => 'home.by_tag', 'uses' => 'HomeController@byTag'))->where('name', '[A-Za-z0-9 -_]+');
Route::get('by_city/{name}', array('as' => 'home.by_city', 'uses' => 'HomeController@byCity'))->where('name', '[A-Za-z0-9 -_]+');
Route::get('by_company/{name}', array('as' => 'home.by_company', 'uses' => 'HomeController@byCompany'))->where('name', '[A-Za-z0-9 -_]+');
...

Теперь добавим недостающие методы в HomeController:

// app/controllers/HomeController.php
	...
	/**
	 * Display a listing of offers that belongs to tag.
	 *
	 * @param  string  $name
	 * @return Response
	 */
	public function byTag($name)
	{
		$tag = Tag::whereName($name)->firstOrFail();

		$offers = $tag->offers;
		$title = "Offers tagged as: " . $tag->name;

		return View::make('home.index', compact('offers', 'title'));
	}

	/**
	 * Display a listing of offers that belongs to city.
	 *
	 * @param  string  $name
	 * @return Response
	 */
	public function byCity($name)
	{
		$city = City::whereName($name)->firstOrFail();

		$offers = $city->offers;
		$title = "Offers in: " . $city->name;

		return View::make('home.index', compact('offers', 'title'));
	}

	/**
	 * Display a listing of offers that belongs to company.
	 *
	 * @param  string  $name
	 * @return Response
	 */
	public function byCompany($name)
	{
		$company = Company::whereName($name)->firstOrFail();

		$offers = $company->offers;
		$title = "Offers by: " . $company->name;

		return View::make('home.index', compact('offers', 'title'));
	}
	...

Для корректной работы этих методов нам нужно задать связи в Моделях City, Company и Tag:

// app/models/City.php
	...
	public function offers()
	{
		return $this->hasMany('Offer');
	}

// app/models/Company.php
	...
	public function offers()
	{
		return $this->hasMany('Offer');
	}

// app/models/Tag.php
	...
	public function offers()
	{
		return $this->belongsToMany('Offer');
	}

Что бы все это дело заиграло, изменим файл app/views/offers/_preview.blade.php, добавив ссылок:

// app/views/offers/_preview.blade.php
<a class="image-container" href="{{ route('home.offer', $offer->id) }}">
	<img src="{{{ $offer->image }}}">
</a>
<div class="caption">
	<h3>{{{ $offer->title }}}</h3>
	<hr>
	<p class="description">{{ $offer->webDescription() }}</p>
	<hr>
	<p><span class="label label-important">{{{ $offer->off }}} % off</span></p>
	<p>Location: <a href="{{ route('home.by_city', $offer->city->name) }}">{{{ $offer->city->name }}}</a></p>
	<p>Offer by: <a href="{{ route('home.by_company', $offer->company->name) }}">{{{ $offer->company->name }}}</a></p>
	<p>Expires on: <span class="label label-warning">{{{ $offer->expires }}}</span></p>
	<p>Tags:
		@foreach($offer->tags as $tag)
			<a class="no_decoration" href="{{ route('home.by_tag', $tag->name) }}">
				<span class="badge">{{{$tag->name}}}</span>
			</a>
		@endforeach
	</p>
</div>

Кликаем, переходим, скидки сортируются и выводятся в соответствии с критериями.

Теперь сделаем представление для просмотра отдельной скидки:

// app/views/offers/_show.blade.php
@extends('layouts.main')

@section('main')

<div class="page-header">
	<h1>
		<span class="label label-important label-big">{{{ $offer->off }}}%</span>
		{{{ $offer->title }}} 
		<small> by
			<a href="{{{ route('home.by_company', $offer->company->name) }}}">{{{ $offer->company->name }}}</a>
		</small>
	</h1>
</div>

<div class="pull-left image-container-big">
	<img class="img-rounded" src="{{{ $offer->image }}}" alt="{{{ $offer->title }}}">
</div>

<div class="description">
	<p>{{ $offer->webDescription() }}</p>
</div>

<div class="clearfix"></div>
<hr>
<p>Location: 
	<a href="{{ route('home.by_city', $offer->city->name) }}">{{{ $offer->city->name }}}</a>
</p>
<p>Tags: 
	@foreach($offer->tags as $tag)
		<a class="no_decoration" href="{{ route('home.by_tag', $tag->name) }}">
			<span class="badge">{{{$tag->name}}}</span>
		</a>
	@endforeach
</p>

<hr>

<div class="page-header">
  <h3>User's comments <small>leave and yours one</small></h3>
</div>

{{ Form::open() }}
{{ Form::textarea('body', Input::old('body'), array('class' => 'input-block-level', 'style' => 'resize: vertical;'))}}
 <div class="input-append">
{{ Form::select('mark', array(0 => 5, 1 => 4, 2 => 3, 3 => 2, 4 => 1), Input::old('mark', 0)) }}
{{ Form::submit('Comment', array('class' => 'btn btn-success', 'style' => 'clear: both;')) }}
</div>
{{ Form::close() }}
@include('partials.errors', $errors)
@stop
// public/css/main.css Теперь выглядит так
body {padding-top: 60px;}
.error {color: red;}
.no_decoration:hover, .no_decoration:focus {text-decoration: none;}
.thumbnail .image-container {width: 100%; max-height: 200px; overflow: hidden; display: block;}
.thumbnail .image-container img {min-width: 100%; min-height: 100%;}
.thumbnail h3 {height: 40px; overflow: hidden;}
.thumbnail .description {height: 100px; overflow: hidden;}

.image-container-big {width: 500px; height: 300px; margin: 0 20px 20px 0; text-align: center;}
.image-container-big img {max-height: 300px; margin: 0 auto;}

.label.label-big {font-size: 32px; line-height: 1.5em; padding: 0 15px; margin-bottom: 5px;}

Для того, чтобы можно было просматривать скидку полностью, добавим маршрут и метод, а так же в конце я добавил форму для комментариев. Для ее работоспособности также нужно добавить маршрут и метод в нужном контроллере:

// app/routes.php
...
Route::get('offer_{id}', array('as' => 'home.offer', 'uses' => 'HomeController@showOffer'))->where('id', '[0-9]+');
Route::post('offer_{id}', array('before' => 'not_guest', 'uses' => 'HomeController@commentOnOffer'))->where('id', '[0-9]+');
...
Route::filter('not_guest', function(){
	if (Auth::guest()) {
		return Redirect::back()->withInput()->with('message', 'You should be logged in to provide this action.');
	}
});
// app/controllers/HomeController.php
	...
	/**
	 * Display an offer.
	 *
	 * @param  int  $id
	 * @return Response
	 */
	public function showOffer($id)
	{
		$offer = Offer::findOrFail($id);

		return View::make('offers._show', compact('offer'));
	}
	
	/**
	 * Storing comment on offer.
	 *
	 * @param  int  $id
	 * @return Response
	 */
	public function commentOnOffer($id)
	{
		$offer = Offer::findOrFail($id);

		if ($offer->usersComments->contains(Auth::user()->id)) {
			return Redirect::back()->withInput()->with('message', 'You have already commented on this Offer');
		}

		$rules = array('body' => 'required|alpha|min:10|max:500', 'mark' => 'required|numeric|between:1,5');
		$validator = Validator::make(Input::all(), $rules);

		if ($validator->passes()) {
			$offer->usersComments()->attach(Auth::user()->id, array('body' => Input::get('body'), 'mark' => Input::get('mark')));
			return Redirect::back();
		}

		return Redirect::back()->withInput()->withErrors($validator);
	}
	...

Разберемся со всем по порядку:

  • С представлением скидки, надеюсь, проблем нет — это все та же верстка + шаблонизатор.
  • В маршрутах тоже все просто, все по аналогии как и раньше: ссылка — контроллер@метод, разве что Route::post('/offer_{id}'...) использует новый фильтр, который без авторизации выдает кастомное сообщение.
  • showOffer($id) тоже ничего сложного из себя не представляет.
  • Интересен сам метод добавления комментариев. Во первых, проверим, правильный ли id нам передали.

    Далее идет работа с промежуточной таблицей offers для скидки и пользователя. Эту связь нужно указать в Модели Offer

    // app/models/Offer.php
    	...
    	public function usersComments()
    	{
    		return $this->belongsToMany('User', 'comments')->withPivot('body', 'mark')->withTimestamps();
    	}
    	...
    

    Как видите, мы тут явно задаем таблицу comments как промежуточную, и указываем, что так же в этой таблице содержатся дополнительные колонки body и mark + в этой таблице используются штампы времени (создания и обновления).

    Используя проверку, есть ли уже комментарий к конкретной скидке от текущего пользователя (метод contains() [38]), перенаправляем обратно. Если же нет — то прикрепляем новый комментарий от пользователя к скидке с его оценкой и текстом.

Для вывода комментариев на странице скидки изменим немного файл app/views/offers/_show.blade.php

// app/views/offers/_show.blade.php
...
@if(!$offer->usersComments->count())
<div class="well">You can be first to comment on this offer!</div>
@endif

@if(Auth::guest() || (!Auth::guest() && !$offer->usersComments->contains(Auth::user()->id)))
{{ Form::open() }}
{{ Form::textarea('body', Input::old('body'), array('class' => 'input-block-level', 'style' => 'resize: vertical;'))}}
 <div class="input-append">
{{ Form::select('mark', array(5 => 5, 4 => 4, 3 => 3, 2 => 2, 1 => 1), Input::old('mark', 5)) }}
{{ Form::submit('Comment', array('class' => 'btn btn-success', 'style' => 'clear: both;')) }}
</div>
{{ Form::close() }}
@include('partials.errors', $errors)
@endif

@foreach($offer->usersComments as $user)
<div class="media">
	<a class="pull-left" href="#">
		<img class="media-object" data-src="holder.js/64x64">
	</a>
	<div class="media-body">
		<h4 class="media-heading">{{{ $user->username }}} <span class="label label-success">mark: {{{ $user->pivot->mark }}}</span></h4>
	<p class="muted">{{ str_replace("rn", '<br>', e($user->pivot->body)) }}</p>
	</div>
</div>
@endforeach
@stop

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

Следующим шагом будет распределить права доступа к сайту. Для начала укажем связь между пользователями и ролями:

// app/models/User.php
	...
	public function roles()
	{
		return $this->belongsToMany('Role');
	}
	...

Далее добавим в админке управление ролями пользователей:

// app/routes.php
...
Route::group(array('before' => 'admin.auth'), function()
{
	...
	Route::resource('users', 'UsersController');

	Route::post('upload', array('uses' => 'HomeController@uploadOfferImage'));
});
...
// app/views/layouts/scaffold.blade.php
...
<li>{{ link_to_route('users.index', 'Users') }}</li>
<li class="pull-right">{{ link_to_route('login.logout', 'Logout') }}</li>
...

Помним, что в Модель User нужно добавить связь с ролями:

// app/models/User.php
	...
	public function roles()
	{
		return $this->belongsToMany('Role');
	}
	...

Создадим контроллер UserController:

// app/controllers/UsersController.php
class UsersController extends BaseController {

	/**
	 * User Repository
	 *
	 * @var User
	 */
	protected $user;

	public function __construct(User $user)
	{
		$this->user = $user;
	}

	/**
	 * Display a listing of the resource.
	 *
	 * @return Response
	 */
	public function index()
	{
		$users = $this->user->all();

		return View::make('users.index', compact('users'));
	}

	/**
	 * Display the specified resource.
	 *
	 * @param  int  $id
	 * @return Response
	 */
	public function show($id)
	{
		$user = $this->user->findOrFail($id);

		return View::make('users.show', compact('user'));
	}

	/**
	 * Show the form for editing the specified resource.
	 *
	 * @param  int  $id
	 * @return Response
	 */
	public function edit($id)
	{
		$user = $this->user->findOrFail($id);

		return View::make('users.edit', compact('user'));
	}

	/**
	 * Update the specified resource in storage.
	 *
	 * @param  int  $id
	 * @return Response
	 */
	public function update($id)
	{
		$user = $this->user->findOrFail($id);

		$roles = array();

		foreach (explode(', ', Input::get('roles')) as $role_name) {
			if ($role = Role::where('role', '=', $role_name)->first()) {
				$roles[] = $role->id;
			}
		}

		$user->roles()->sync($roles);

		return Redirect::route('users.show', $id);
	}

	/**
	 * Remove the specified resource from storage.
	 *
	 * @param  int  $id
	 * @return Response
	 */
	public function destroy($id)
	{
		$this->user->findOrFail($id)->delete();

		return Redirect::route('users.index');
	}

}

Создадим папку app/views/users и добавим туда 3 файла:

// app/views/users/index.blade.php
@extends('layouts.scaffold')

@section('main')

<h1>All Users</h1>

@if ($users->count())
	<table class="table table-striped table-bordered">
		<thead>
			<tr>
				<th>Username</th>
				<th>Email</th>
				<th>Roles</th>
			</tr>
		</thead>

		<tbody>
			@foreach ($users as $user)
				<tr>
					<td>{{{ $user->username }}}</td>
					<td>{{{ $user->email }}}</td>
					<td>
						@foreach($user->roles as $role)
							<span class="badge">{{{$role->role}}}</span>
						@endforeach
					</td>
					<td>{{ link_to_route('users.edit', 'Edit', array($user->id), array('class' => 'btn btn-info')) }}</td>
					<td>
						{{ Form::open(array('method' => 'DELETE', 'route' => array('users.destroy', $user->id))) }}
							{{ Form::submit('Delete', array('class' => 'btn btn-danger')) }}
						{{ Form::close() }}
					</td>
				</tr>
			@endforeach
		</tbody>
	</table>
@else
	There are no users
@endif

@stop
// app/views/users/show.blade.php
@extends('layouts.scaffold')

@section('main')

<h1>Show User</h1>

<p>{{ link_to_route('users.index', 'Return to all users') }}</p>

<table class="table table-striped table-bordered">
	<thead>
		<tr>
			<th>Username</th>
			<th>Email</th>
			<th>Roles</th>
		</tr>
	</thead>

	<tbody>
		<tr>
			<td>{{{ $user->username }}}</td>
			<td>{{{ $user->email }}}</td>
			<td>
				@foreach($user->roles as $role)
					<span class="badge">{{{ $role->role }}}</span>
				@endforeach
			</td>
			<td>{{ link_to_route('users.edit', 'Edit', array($user->id), array('class' => 'btn btn-info')) }}</td>
			<td>
				{{ Form::open(array('method' => 'DELETE', 'route' => array('users.destroy', $user->id))) }}
					{{ Form::submit('Delete', array('class' => 'btn btn-danger')) }}
				{{ Form::close() }}
			</td>
		</tr>
	</tbody>
</table>

@stop
// app/views/users/edit.blade.php
@extends('layouts.scaffold')

@section('main')

<h1>Edit User</h1>
{{ Form::model($user, array('method' => 'PATCH', 'route' => array('users.update', $user->id))) }}
	<ul>
		<li>
			{{ Form::label('username', 'Username:') }}
			{{ Form::text('username', $user->username, array('disabled')) }}
		</li>

		<li>
			{{ Form::label('email', 'Email:') }}
			{{ Form::text('email', $user->email, array('disabled')) }}
		</li>

		<li>
			{{ Form::label('roles', 'Roles:') }}
			{{ Form::text('roles', Input::old('roles', implode(', ', array_fetch($user->roles()->get(array('role'))->toArray(), 'role')))) }}
		</li>

		<li>
			{{ Form::submit('Update', array('class' => 'btn btn-info')) }}
			{{ link_to_route('users.show', 'Cancel', $user->id, array('class' => 'btn')) }}
		</li>
	</ul>
{{ Form::close() }}

@if ($errors->any())
	<ul>
		{{ implode('', $errors->all('<li class="error">:message</li>')) }}
	</ul>
@endif

@stop

@section('scripts')
<script>
$(document).ready(function(){ 
	function split( val ) {
		return val.split( /,s*/ );
	}
	function extractLast( term ) {
		return split( term ).pop();
	}

	$( "#roles" )
	// don't navigate away from the field on tab when selecting an item
	.bind( "keydown", function( event ) {
		if ( event.keyCode === $.ui.keyCode.TAB &&
			$( this ).data( "ui-autocomplete" ).menu.active ) {
			event.preventDefault();
		}
	})
	.autocomplete({
		source: function( request, response ) {
			$.getJSON( "/roles", {
					term: extractLast( request.term ),
				}, 
				function( data ) {
					response($.map(data, function(item) {
						return {
							value: item.role
						}
					}))
				}
			);
		},
		search: function() {
			// custom minLength
			var term = extractLast( this.value );
			if ( term.length < 2 ) {
			return false;
			}
		},
		focus: function() {
			// prevent value inserted on focus
			return false;
		},
		select: function( event, ui ) {
			console.log(ui);
			console.log(this);
			var terms = split( this.value );
			// remove the current input
			terms.pop();
			// add the selected item
			terms.push( ui.item.value );
			// add placeholder to get the comma-and-space at the end
			terms.push( "" );
			this.value = terms.join( ", " );
			return false;
		}
	});
});
</script>
@stop

А так же изменим немного метд index контроллера RolesController

	...
	public function index()
	{
		$roles = $this->role->all();

		if (Request::ajax()) {
			$roles = Role::where('role', 'like', '%'.Input::get('term', '').'%')->get(array('id', 'role'));
			return $roles;
		}

		return View::make('roles.index', compact('roles'));
	}
	...

Теперь автодополнение работает.

Далее, для того, что бы у нас с вами не было разбежностей, откатим все миграции и воспользуемся отличным инструментом, который нам предоставляет Laravel — это DatabaseSeeder [39]. С помощью него мы можем наполнить нашу БД какими-то конфигурационными, или стартовыми / тестовыми данными. Для этого сначала создадим класс UsersTableSeeder в папке app/database/seeds:

// app/database/seeds/UsersTableSeeder.php
class UsersTableSeeder extends Seeder {

	public function run()
	{
		$users = array(
			array(
				'username' => 'habrahabr',
				'email'	=> 'habrahabr@habr.com',
				'password' => Hash::make('habr'),
				'updated_at' => DB::raw('NOW()'),
				'created_at' => DB::raw('NOW()'),
				)
		);

		DB::table('users')->insert($users);
	}

}

Логика такова: очищаем таблицу, создаем массив данных и вставляем в БД.

Проделаем то же самое с RolesTableSeeder:

// app/database/seeds/RolesTableSeeder.php
class RolesTableSeeder extends Seeder {

	public function run()
	{
		$roles = array(
			array(
				'role' => 'admin', 
				'updated_at' => DB::raw('NOW()'),
				'created_at' => DB::raw('NOW()')
				),
			array(
				'role' => 'manager', 
				'updated_at' => DB::raw('NOW()'),
				'created_at' => DB::raw('NOW()')
				),
			array(
				'role' => 'moderator', 
				'updated_at' => DB::raw('NOW()'),
				'created_at' => DB::raw('NOW()')
				)

		);

		DB::table('roles')->insert($roles);
	}

}

Здесь я так же добавил роли manager и moderator, что бы давать пользователям с этими ролями доступ к отдельным ресурсам в админ панели.

Далее создадим еще один класс Seeder:

// app/database/seeds/RoleUserTableSeeder.php
class RoleUserTableSeeder extends Seeder {

	public function run()
	{
		// Uncomment the below to wipe the table clean before populating
		DB::table('role_user')->truncate();

		$role_user = array(
			array('user_id' => 1, 'role_id' => 1)
		);

		// Uncomment the below to run the seeder
		DB::table('role_user')->insert($role_user);
	}

}

Таким образом мы добавили роль admin нашему первому пользователю.

Чтобы очистить БД и заполнить ее нашими начальными данными сначала изменим файл app/database/seeds/DatabaseSeeder.php таким образом:

// app/database/seeds/DatabaseSeeder
class DatabaseSeeder extends Seeder {

	/**
	 * Run the database seeds.
	 *
	 * @return void
	 */
	public function run()
	{
		Eloquent::unguard();

		// Вызовы на выполнение конкретных классов для наполнения БД
		$this->call('UsersTableSeeder');
		$this->call('RolesTableSeeder');
		$this->call('RoleUserTableSeeder');
	}

}

И для принятия всех изменений запустим через консоль команду (находясь в папке /workspace/php/habr/):

php artisan migrate:refresh --seed

migrate:refresh откатит все миграции, а потом их снова запустит, а опция --seed укажет на то, что так же нужно запустить DatabaseSeeder.

Далее выстроим логику на права. Внесем изменения в Модель User:

// app/models/User.php
	...
	public function isAdmin()
	{
		$admin_role = Role::whereRole('admin')->first();
		return $this->roles->contains($admin_role->id);
	}
	...
	public function isManager()
	{
		$manager_role = Role::whereRole('manager')->first();
		return $this->roles->contains($manager_role->id) || $this->isAdmin();
	}
	...
	public function isModerator()
	{
		$admin_role = Role::whereRole('admin')->first();
		return $this->roles->contains($admin_role->id) || $this->isAdmin();
	}
	...
	public function isRegular()
	{
		$roles = array_filter($this->roles->toArray());
		return empty($roles);
	}
}

Далее изменим файл маршрутов, что бы он соответствовал правам пользования сайтом:

// app/routes.php
...
Route::post('offer_{id}', array('before' => 'not_guest|regular_user', 'uses' => 'HomeController@commentOnOffer'))->where('id', '[0-9]+');
...
Route::group(array('before' => 'admin.auth'), function()
{
	Route::get('dashboard', function()
	{
		return View::make('dasboard');
	});

	Route::group(array('before' => 'manager_role_only'), function()
	{
		Route::resource('cities', 'CitiesController');

		Route::resource('companies', 'CompaniesController');

		Route::resource('tags', 'TagsController');

		Route::resource('offers', 'OffersController');
		
		Route::post('upload', array('uses' => 'HomeController@uploadOfferImage'));
	});

	Route::resource('comments', 'CommentsController');

	Route::group(array('before' => 'manager_role_only'), function()
	{
		Route::resource('roles', 'RolesController');

		Route::resource('users', 'UsersController');	
	});
});

Route::when('comments*', 'moderator_role_only');

Route::filter('admin_role_only', function()
{
	if (Auth::user()->isAdmin()) {
		return Redirect::intended('/')->withMessage('You don't have enough permissions to do that.');
	}
});

Route::filter('manager_role_only', function() 
{
	if (!Auth::user()->isManager()) {
		return Redirect::intended('/')->withMessage('You don't have enough permissions to do that.');
	}
});

Route::filter('moderator_role_only', function() 
{
	if (!Auth::user()->isModerator()) {
		return Redirect::intended('/')->withMessage('YYou don't have enough permissions to do that.');
	}
});

Route::filter('admin.auth', function() 
{
	if (Auth::guest()) {
		return Redirect::to('login');
	}
});

Route::filter('un_auth', function()
{
	if (!Auth::guest()) {
		Auth::logout();
	}
});

Route::filter('not_guest', function(){
	if (Auth::guest()) {
		return Redirect::intended('/')->withInput()->with('message', 'You should be logged in to provide this action.');
	}
});

Route::filter('regular_user', function(){
	if (!Auth::guest()) {
		if (!Auth::user()->isRegular()) {
			return Redirect::back()->with('message', 'You cannot do that due to your role.');
		}
	}
});

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

Также тут был использован маршрут Route::when() — это так называемый шаблонный фильтр (Pattern Filter [31]). Он позволяет первым параметром передать шаблон URL, вторым — сам фильтр, который нужно применить, а третьим параметром он может принимать массив из HTTP запросов, к которым нужно применить фильтр.

Изменим метод login() контроллера LoginController:

// app/controllers/LoginController.php
	...
	public function login()
	{
		if (Auth::attempt(array('email' => Input::get('email'), 'password' => Input::get('password')), true)
			|| Auth::attempt(array('username' => Input::get('email'), 'password' => Input::get('password')), true))	{
			
			if (!Auth::user()->isRegular()) {
				return Redirect::to('dashboard');
			}
			
			return Redirect::intended('/');
		}

		return Redirect::back()->withInput(Input::except('password'))->with('message', 'Wrong creadentials!');
	}

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

Изменим немного навигационное меню для администрации:

// app/views/layouts/scaffold.blade.php
@if(!Auth::guest())
	<ul class="nav nav-pills">
		@if(Auth::user()->isManager())
		<li>{{ link_to_route('offers.index', 'Offers') }}</li>
		<li>{{ link_to_route('companies.index', 'Companies') }}</li>
		<li>{{ link_to_route('tags.index', 'Tags') }}</li>
		<li>{{ link_to_route('cities.index', 'Cities') }}</li>
		@endif
		@if(Auth::user()->isModerator())
		<li>{{ link_to_route('comments.index', 'Comments') }}</li>
		@endif
		@if(Auth::user()->isAdmin())
		<li>{{ link_to_route('roles.index', 'Roles') }}</li>
		<li>{{ link_to_route('users.index', 'Users') }}</li>
		@endif
		<li class="pull-right">{{ link_to_route('login.logout', 'Logout') }}</li>
	</ul>
@endif

Отлично — теперь каждой роли будут видны те ресурсы, к которым у них есть доступ.

Emails

Важным аспектом для web приложения является отправка почты.

Laravel использует SwiftMailer для создания писем (Laravel Mail [40]).

Для начала нужно сконфигурировать настройки отправки почты. В качестве демонстрации для отправки писем я буду использовать свой аккаунт на gmail, но вы можете пользоваться по сути любым сервисом, который предоставляет возможность отправки почты с его серверов (к примеру Postmarkapp [41]).

Настройка почты:

// app/config/mail.php
...
return array(
	...
	'driver' => 'smtp',
	...
	'host' => 'smtp.gmail.com',
	...
	'port' => 587,
	...
	'from' => array('address' => 'habrahabr@habr.com', 'name' => 'Habra Offers'),
	...
	'encryption' => 'tls',
	...
	'username' => 'mygmailaccount@gmail.com',
	...
	'password' => 'mypassword',
	...
	'pretend' => false
);

Параметр pretend отвечает за то, нужно ли отправлять письма. Если его выставить в true, то оправка писем происходить не будет, но в логах сайта (app/storage/logs) будут сохраняться отчеты об отправке.

Первым делом я хочу, чтобы при регистрации пользователю отправлялось письмо с приветствием, для этого создам шаблон в папке app/views/emails:

// app/views/emails/welcome.blade.php
<!DOCTYPE html>
<html lang="en-US">
	<head>
		<meta charset="utf-8">
	</head>
	<body>
		<h1>Welcome to Habra Offers!</h1>

		<div>
			We are glad that you are interested in us, {{{ $username }}}!
		</div>
	</body>
</html>

Далее изменим метод store() нашего LoginController:

// app/controllers/LoginController.php
...
$user->save();

Mail::send('emails.welcome', array('username' => $user->username), function($message) use ($user)
{
	$message->to($user->email, $user->username)->subject('Welcome to Habra Offers!');
});

Auth::loginUsingId($user->id);
...

Класс Mail [42] для отправки почты использует метод send(), который принимает три аргумента:

  • $view — шаблон, который нужно использовать (или массив из двух шаблонов, первый — html шаблон, второй — plaintext)
  • $data — массив данных, ключи которого будут переменными в шаблоне
  • $callback — функцию, которая будет запущена для настройки параметров письма

Но приветственное письмо — это не единственный тип писем, который нам нужен. Что если пользователь забыл свой пароль и хочет его восстановить? Для этого Laravel предоставляет Password Reminders & Reset [43].
Что нам нужно сделать:

cd /workspace/php/habr
php artisan auth:reminders
php artisan migrate

Для восстановления пароля достаточно вызова Password::remind(array('email' => $email)) и письмо с ссылкой на восстановление пароля будет отправлено.

Нам потребуется создать 2 шаблона:

  • app/views/auth/remind.blade.php — для отправки email на восстановление пароля
    // app/views/auth/remind.blade.php
    @extends('layouts.scaffold')
    
    @section('main')
    
    @if (Session::has('error'))
    	<div class="alert alert-error">
    		{{ trans(Session::get('reason')) }}
    	</div>
    @elseif (Session::has('success'))
    	<div class="alert alert-success">
    		An e-mail with the password reset has been sent.
    	</div>
    @endif
    
    <h1>Forgot your password?</h1>
    
    <p>{{ link_to_route('login.index', 'No') }}</p>
    
    {{ Form::open() }}
    	<ul>
    		<li>
    			{{ Form::label('email', 'Your email')}}
    			{{ Form::email('email') }}
    		</li>
    
    		<li>
    		{{ Form::submit('Send reminder', array('class' => 'btn')) }}
    		</li>
    	</ul>
    {{ Form::close() }}
    
    @stop
    

  • app/views/auth/reset.blade.php — форма восстановления пароля
    // app/views/auth/reset.blade.php
    @extends('layouts.scaffold')
    
    @section('main')
    
    @if (Session::has('error'))
    	<div class="alert alert-error">
        	{{ trans(Session::get('reason')) }}
    	</div>
    @endif
    
    <h1>Reset your password</h1>
    
    {{ Form::open() }}
    {{ Form::hidden('token', $token) }}
    	<ul>
    		<li>
    			{{ Form::label('email', 'Email')}}
    			{{ Form::email('email', Input::old('email')) }}
    		</li>
    
    		<li>
    			{{Form::label('password', 'New password')}}
    			{{ Form::password('password')}}
    		</li>
    
    		<li>
    			{{Form::label('password', 'New password confirmation')}}
    			{{ Form::password('password_confirmation')}}
    		</li>
    
    	</ul>
    {{ Form::submit('Reset', array('class' => 'btn'))}}
    {{ Form::close() }}
    @stop
    

Функция trans() — вспомогательная функция, которая выводит локализированную строку из конфигурации. Можете заглянуть в папку app/lang/en/reminders.php и увидить какие ошибки могут выводиться. Для смены локализации на, допустим, русский язык вам понадобится изменить в файле app/config/app.php значение locale с en на ru и добавить папку app/lang/ru, в которой воссоздать файлы как в папке app/lang/en.

Далее добавим 4 маршрута:

// app/routes.php
...
Route::group(array('before' => 'un_auth'), function()
{
	...
	Route::get('password/remind', array('as' => 'password.remind', 'uses' => 'LoginController@showReminderForm'));
	Route::post('password/remind', array('uses' => 'LoginController@sendReminder'));
	Route::get('password/reset/{token}', array('as' => 'password.reset', 'uses' => 'LoginController@showResetForm'));
	Route::post('password/reset/{token}', array('uses' => 'LoginController@resetPassword'));
});
...

Для перехода на восстановление так же добавим ссылку на странице логина:

// app/views/login/index.blade.php
...
{{ Form::close() }}

<p>{{ link_to_route('password.remind', 'Forgot password?') }}</p>
...

А так же недостающие методы в LoginController:

// app/controllers/LoginController.php
	...
	/**
	 * Show reminder form.
	 *
	 * @return Response
	 */
	public function showReminderForm()
	{
		return View::make('auth.remind');
	}


	/**
	 * Send reminder email.
	 *
	 * @return Response
	 */
	public function sendReminder()
	{
		$credentials = array('email' => Input::get('email'));

		return Password::remind($credentials, function($message, $user)
		{
		    $message->subject('Password Reminder on Habra Offers');
		});
	}


	/**
	 * Show reset password form.
	 *
	 * @return Response
	 */
	public function showResetForm($token)
	{
		return View::make('auth.reset')->with('token', $token);
	}


	/**
	 * Reset password.
	 *
	 * @return Response
	 */
	public function resetPassword($token)
	{
		$credentials = array('email' => Input::get('email'));

		return Password::reset($credentials, function($user, $password)
		{
			$user->password = Hash::make($password);

			$user->save();

			Auth::loginUsingId($user->id);

			return Redirect::home()->with('message', 'Your password has been successfully reseted.');
	    });
	}

Теперь любой пользователь может восстановить свой пароль.

Добавим еще ссылку для входа и регистрации на сайт на главной странице:

// app/views/layouts/main.blade.php
...
<a class="brand" href="{{ route('home') }}">Habr Offers</a>
<ul class="nav">
	<li><a href="{{ route('home') }}">Home</a></li>
</ul>
<div class="btn-group pull-right">
	@if(Auth::guest())
		<a href="{{ route('login.index') }}" class="btn">Login</a>
		<a href="{{ route('login.register') }}" class="btn">Register</a>
	@else
		<a href="{{ route('login.logout') }}" class="btn">Logout</a>
	@endif
</div>
...

Для того, что бы ограничить вывод на страницах только тех скидок, которые еще не закончились нам понадобится добавить еще один метод в Модель Offer:

// app/controllers/Offer.php
	...
	public function scopeActive($query)
	{
		return $query->where('expires', '>', DB::raw('NOW()'));
	}
	public function scopeSortLatest($query, $desc = true)
	{
		$order = $desc ? 'desc' : 'asc';
		return $query->orderBy('created_at', $order);
	}
	...

Таким образом, мы можем в методе HomeController@index всего лишь изменить Offer::orderBy('created_at', 'desc')->get() на Offer::active()->sortLatest()->get(). Наш новосозданный метод будет добавлять в цепочку условий нужные нам условия. Сделаем так же для методов сортировки по тегам, городам и компаниям.

// app/controllers/HomeController.php
	...
	public function byTag($name)
	{
		...
		$offers = $tag->offers()->active()->sortLatest()->get();
		...
	}
Пагинация

Немаловажным аспектом является пагинация. Да, конечно можно слать запросы в БД, получать тысячи строк ответов, и потом их все пихать на страницу. Но это вряд ли чей либо подход. Ограничить количество возвращаемых результатов из БД достаточно просто — в конце запроса нужно использовать метод paginate() вместо get(), или all(). Простой пример:

// app/controllers/HomeController.php
	...
	public function index()
	{
		$offers = Offer::active()->sortLatest()->paginate();
		...
	}
	...
// app/views/home/index.blade.php
...
@if ($offers->count())
	{{ $offers->links() }}
	...
	{{ $offers->links() }}
@else
	There are no offers
@endif
...

Таким образом на одной странице будут выводиться только 15 результатов, и внизу будут переходы по страницам. Количество результатов легко изменяемо — достаточно передать нужное число в метод, например paginate(1) даст 1 результат на страницу.

// app/controllers/HomeController.php
	...
	public function byTag($name)
	{
		$tag = Tag::whereName($name)->firstOrFail();

		$offers = $tag->offers()->active()->sortLatest()->paginate();

		$title = "Offers tagged as: " . $tag->name;

		return View::make('home.index', compact('offers', 'title'));
	}
	...
	public function byCity($name)
	{
		$city = City::whereName($name)->firstOrFail();

		$offers = $city->offersr()->active()->sortLatest()->paginate();

		$title = "Offers in: " . $city->name;

		return View::make('home.index', compact('offers', 'title'));
	}
	...
	public function byCompany($name)
	{
		$company = Company::whereName($name)->firstOrFail();

		$offers = $company->offers()->active()->sortLatest()->paginate();

		$title = "Offers by: " . $company->name;

		return View::make('home.index', compact('offers', 'title'));
	}
	...

Ничего вроде сложного в этом нет.

Для удобства так же сделаем и в админ панели.

// app/controllers/OffersController
	...
	/**
	 * Display a listing of the resource.
	 *
	 * @return Response
	 */
	public function index()
	{
		$offers = $this->offer->sortLatest()->paginate();

		return View::make('offers.index', compact('offers'));
	}
	...

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

Начнем с добавления комментариев в каркасе страницы:

// app/views/layouts/main.blade.php
<div class="container">

	@if (Session::has('message'))
		<div class="flash alert">
			{{ Session::get('message') }}
		</div>
	@endif
	
	<div class="row-fluid">
		<div class="span3">
			<h2>Last Comments</h2>
		
			@if (count($comments = Comment::take(5)->get()) > 0)
				@foreach ($comments as $comment)
					@include('partials.comment', $comment)
				@endforeach
			@else
				There are no comments yet
			@endif
		</div>

		<div class="span9">
			@yield('main')
		</div>
	</div>
</div>

А так же создадим сам шаблон comment:

// app/views/partials/comment.blade.php
<div class="well">
	<a href="{{ route('home.offer', $comment->offer_id) }}">
		{{ $comment->user->username }} 
		<span class="label label-success pull-right">mark: {{ $comment->mark }}</span>
	</a>
	<div>{{ $comment->webBody() }}</div>	
</div>

Не забываем добавлять связь между Моделью Comment User и Offer:

// app/models/Comment.php
	...
	public function user()
	{
		return $this->belongsTo('User');
	}

	public function offer()
	{
		return $this->belongsTo('Offer');
	}

	public function webBody($options = array())
	{
		$str = $this->body;

		if (isset($options['shorten'])) {
			$length = isset($options['length']) ? (int) $options['length'] : 50;
			$end = isset($options['end']) ? : '…';
			if (mb_strlen($str) > $length) {
				$str = mb_substr(trim($str), 0, $length);
				$str = mb_substr($str, 0, mb_strlen($str) - mb_strpos(strrev($str), ' '));
				$str = trim($str.$end);
			}
		}
		
		$str = str_replace("rn", '<br>', e($str));
		return $str;
	}
	...

А так же вспомогательная функция для сокращения и избавлением от html-тегов комментария.

Осталось добавить закладки для пользователя:

// app/routes.php
Route::get('/', array('as' => 'home', 'uses' => 'HomeController@index'));
Route::get('bookmarks', array('before' => 'auth', 'as' => 'home.bookmarks', 'uses' => 'HomeController@bookmarks'));
...
// app/views/layouts/main.blade.php
...
@if(Auth::guest())
	<a href="{{ route('login.index') }}" class="btn">Login</a>
	<a href="{{ route('login.register') }}" class="btn">Register</a>
@else
	<a href="{{ route('home.bookmarks') }}" class="btn">My Bookmarks</a>
	<a href="{{ route('login.logout') }}" class="btn">Logout</a>
@endif
...
// app/models/User.php
	...
	public function usersOffers()
	{
		return $this->belongsToMany('Offer', 'comments')->withPivot('body', 'mark')->withTimestamps();
	}
	...
// app/controllers/HomeController.php
	...
	/**
	 * Display a listing of bookmarked offers.
	 *
	 * @return Response
	 */
	public function bookmarks()
	{
		$offers = Auth::user()->usersOffers()->paginate();

		$title = "My Bookmarked Offers";
		
		return View::make('home.index', compact('offers', 'title'));
	}
	...

Для начала мы добавили маршрут в app/route.php, потом добавили ссылку на него в app/views/layouts/main.blade.php, задали связь между Моделью User и Offer, а в конце реализовали метод bookmarks в HomeController.

Деплой

Настал час деплоя! Для этого я выбрал fortrabbit.com [44]хостинг [45] для приложений на PHP. Он поддерживает Git, SSH, Memcached, Composer, MySQL и другое.

Процес регистрации там довольно прост.

Laravel. Установка, настройка, создание и деплой приложения

Далее создаем новое приложение.

Laravel. Установка, настройка, создание и деплой приложения

Назовем его habr. Именем проекта будет ссылка на него habr.eu1.frbit.net/ [3]. Добавим заметку (Habra Offers), и добавим ssh ключ со своей машины. Чтобы посмотреть свой ssh ключ введите в терминале:

cat ~/.ssh/id_rsa.pub

Laravel. Установка, настройка, создание и деплой приложения

Последним этапом будет ожидание конфигурации окружения. Вам сформируются данные для доступа к репозиторию Git, SSH и SFTP, MySQL настройки и ReSync доступ.

Окружение запущено и работает.

Laravel. Установка, настройка, создание и деплой приложения

fortrabbit замораживает не активные приложения. То, как разморозить приложение можно почитать тут [46].
Теперь для того, чтобы залить наше приложение на fortrabbit идем в терминал:

cd && cd workspace/php/
git clone git@git1.eu1.frbit.com:habr.git fort_habr

Будет создан клон пустого репозитория с fortrabbit'a. Далее просто перенесем весь проект с папки workspace/php/habr в папку workspace/php/fort_habr. Зайдем в файл конфигурации БД и исправим на новые данные MySQL. Теперь мы готовы заливать наше приложение:

cd fort_habr
git add .
git commit -am "Initial Commit"
git push -u origin master

После всего, осталось зайти через ssh и запустить миграции. Итак:

ssh u-habr@ssh1.eu1.frbit.com

Потом введите свой пароль и вы на сервере.
Перейдите в папку htdocs и выполните:

cd htdocs
php artisan migrate:install
php artisan migrate --seed

Если настройка БД была правильной — никаких проблем возникнуть не должно.

Для работы с Composer на хостинге [45] можно даже не использовать ssh — достаточно в коммите добавить такой триггер:

git commit --allow-empty -am "Update dependencies [trigger:composer:update]"
git push -u origin master

Опция --allow-empty здесь для того, чтобы мы могли запустить сделать коммит, не внося каких-либо изменений в файлах. Как бы пустой коммит. Но увидев в комментарии [trigger:composer:update], хостинг [45] автоматически запустит команду composer update, и все зависимости проекта будут обновлены.

Кстати, в своем репозитории на GitHub [2] я добавил еще seeds и картинки для скидок.

И последнее: прежде, чем переходить на свой сайт убедитесь, что в Domains на сервере Root Path соответсвует значению public. Так как именно таким образом устроен Laravel.

Поиграться можно тут: Habra Offers [3].

Заключение

Надеюсь вам было интересно это читать, и полезно это делать. Laravel — отличный фреймворк для разработки веб приложений разной сложности.

Основные, и даже больше, аспекты я постарался объяснить. И для интереса дам домашнее задание:

  • Добавьте в главное меню ссылку, чтобы можно было посмотреть только те предложения, которые истекают в течении недели/дня.
  • Добавьте в админку блокировку комментариев, чтобы они скрывались в списке комментариев.
  • Добавьте подсчет оценок для скидки (средняя оценка).
  • Добавьте пакет по управлению изображений.
  • Добавьте возможность пользователю заливать свою аватарку.
  • Добавьте WYSIWYG редактор в админке.

Пожалуй неплохие таски, как считаете?

Об авторе

  • Мне 24 года, женат.
  • Первое высшее: УЭП «КРОК». Специальность: Международная Экономика, магистр.
  • На данный момент студент 3 курса НТУУ КПИ, Факультет Прикладной Математики. Специальность: Программная Инженерия.
  • Работаю веб-разработчиком 15 месяцев на пол ставки.
  • Изучаю Laravel с версии 3.
Сбор статистики

  • На написание статьи с разработкой ушло чуть больше недели.
  • Статья содержит 3040 строк (в текстовом редакторе).
  • Статья содержит 100500 символов (в текстовом редакторе).

Все грамматические ошибки пишите, пожалуйста в личку.

Haters gonna die (Поспорил, что напишу это).

Автор: adacenko

Источник [47]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/framework/45613

Ссылки в тексте:

[1] Laravel: http://laravel.com/

[2] GitHub: https://github.com/andrewdacenko/habrahabr

[3] Приложение: http://habr.eu1.frbit.net/

[4] Composer: http://getcomposer.org/

[5] Composer: http://getcomposer.org/doc/00-intro.md#introduction

[6] Windows: http://getcomposer.org/doc/00-intro.md#installation-windows

[7] WAMP: http://www.wampserver.com/ru/

[8] Git: http://windows.github.com/

[9] http://localhost:8000: http://localhost:8000

[10] Artisan CLI: http://laravel.com/docs/artisan

[11] Миграции: http://laravel.com/docs/migrations

[12] Построитель Схем: http://laravel.com/docs/schema

[13] GitHub: https://github.com/JeffreyWay/Laravel-4-GeneratotHub

[14] Создание миграции: https://github.com/JeffreyWay/Laravel-4-Generators#generating-schema

[15] Типы данных и прочее: http://laravel.com/docs/schema#adding-columns

[16] Eloquent ORM: http://laravel.com/docs/eloquent

[17] REST: http://laravel.com/docs/controllers#resource-controllers

[18] http://localhost:8000/roles: http://localhost:8000/roles

[19] Blade: http://laravel.com/docs/templates#blade-templating

[20] Session: http://laravel.com/docs/session

[21] _token: http://laravel.com/docs/html#csrf-protection

[22] _method: http://laravel.com/docs/html#opening-a-form

[23] Подробнее: http://laravel.com/docs/requests

[24] Basic Input: http://laravel.com/docs/requests#basic-input

[25] bcrypt: http://ru.wikipedia.org/wiki/Bcrypt

[26] Laravel Security: http://laravel.com/docs/security#storing-passwords

[27] Validator: http://laravel.com/docs/validation

[28] Available Validation Rules: http://laravel.com/docs/validation#available-validation-rules

[29] Redirect: http://laravel.com/docs/responses#redirects

[30] Authenticating Users: http://laravel.com/docs/security#authenticating-users

[31] тут: http://laravel.com/docs/routing#route-filters

[32] тут: http://laravel.com/docs/routing#route-groups

[33] Forms & Html (Dropdown Lists): http://laravel.com/docs/html#drop-down-lists

[34] jquery-ui с autocomplete: http://jqueryui.com/autocomplete/#multiple-remote

[35] jqueryui.com: http://jqueryui.com/autocomplete/#multiple

[36] Eloquent Relationships: http://laravel.com/docs/eloquent#relationships

[37] array_fetch: http://laravel.com/docs/helpers#arrays

[38] contains(): http://laravel.com/docs/eloquent#collections

[39] DatabaseSeeder: http://laravel.com/docs/migrations#database-seeding

[40] Laravel Mail: http://laravel.com/docs/mail

[41] Postmarkapp: https://postmarkapp.com/

[42] Mail: http://laravel.com/docs/mail#basic-usage

[43] Password Reminders & Reset: http://laravel.com/docs/security#password-reminders-and-reset

[44] fortrabbit.com: http://fortrabbit.com

[45] хостинг: https://www.reg.ru/?rlink=reflink-717

[46] тут: http://fortrabbit.com/docs/essentials/freeze

[47] Источник: http://habrahabr.ru/post/197454/