Pull to refresh

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

Reading time 55 min
Views 180K
Итак, у вас есть желание попробовать или узнать о фреймворке Laravel.

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

Laravel - PHP framework for artisans!

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

Для ленивых:
GitHub
Приложение



Установка


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

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

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

# Установка недостающих компонентов
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 

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

# Перезапустим apache для принятия изменений
sudo /etc/init.d/apache2 restart

# Глобально установим 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.

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

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

Настройка


Для соединения с базой данных (далее БД) у 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 есть отличные инструменты — Миграции и Построитель Схем.
Миграции это тип управления версиями в базе данных. Они позволяют команде разработчиков изменять схему базы данных и оставаться в курсе о текущем состоянии схемы. Миграция, как правило, в паре с Построителем Схем позволют легко управлять схемой БД.
Миграции
Построитель Схем — это класс Schema. Он дает возможность манипулирования таблицами в БД. Он хорошо работает со всеми БД, которые поддерживаются Laravel, и имеет единый API для всех этих систем.
Построитель Схем

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

php artisan migrate:install

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

Laravel 4 Generators

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

Он добавляет в список 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": "1.*" в список "require".

"require": {
	"laravel/framework": "4.1.*",
	"way/generators": "1.*"
},

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

composer update

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

'Way\Generators\GeneratorsServiceProvider'

Теперь список команд 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, remember_token:string:nullable"
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.

В Laravel версии выше 4.1.25 произошло обновление безопасности, где закрывали дыру с похищенными куками. Подробности обновления и инструкцию можно посмотреть тут: http://laravel.com/docs/upgrade для тех, у кого версия Laravel < 4.1.26. Или просто прочтите коммент от vlom88 http://habrahabr.ru/post/197454/#comment_7510479.


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

  • generate:migration Принимает имя аргумент миграции, и создает соответсвующую схему. В имени схемы можно указать ключевые слова, например create — создание, далее идет имя таблицы и ключевое слово table. Так же можно указать какие поля добавить в таблицу через опцию --fields="", в которой через запятую перечислить поля с ихним типом данных. Создание миграции, Типы данных и прочее
  • generate:scaffold Принимает как агрумент ресурс (к примеру role), и создает такие файлы:
    • app/models/Role.php — клас модели, наследуемый от Eloquent ORM для работы с таблицей ролей (имя самой таблицы — это множественное число от имени ресурса)
    • app/controllers/RolesController.php — клас контроллера, который отвечает на запросы к сайту, так же является REST контроллером
      Метод 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)

  • 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 видим следующую картину:
Admin role added

Немного о шаблонах и шаблонизаторе Blade в Laravel.
Для файлов шаблонов используется раширение .blade.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 используется здесь для вывода сообщений пользователю, если мы передадим какое-то сообщение. Сообщение является временным, и при обновлении страницы пропадет. Если мы откроем только что созданный шаблон 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 — защита от подделки кросс сайтовых запросов и _method в случае 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 register()
	{
		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(). (Подробнее).
В классе Input находятся данные, которые были отправлены при POST запросе. Он имеет ряд статичных методов, таких как all(), get(), has() и другие (Basic Input).

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

Но перед регистрацией нам нужно провести валидацию входящих данных.
Для этого в Laravel есть класс Validator. Метод Validation::make принимает 2 или 3 аргумента:
  1. $input — обязательный, массив входящих данных, которые нужно проверить
  2. $rules — обязательный, массив с правилами к входящим данным
  3. $messages — опциональный, массив с сообщениями об ошибках

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

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

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


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

Метод нашего Контроллера 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, который проверяет, является ли пользователь гостем, или нет, и в случае, если является — отправит его на страницу входа. Про фильтры можно почитать тут, а про группировку маршрутов тут. Другой фильтр 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="" 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). Таким образом мы имеем возможность выбирать из существующих городов и компаний в БД.

Чего нам еще не хватает — так это добавление тегов к скидкам. Тут нам поможет jquery-ui с autocomplete для добавления нескольких значений. Для этого расширим файл с скриптами 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, только немного модифицированный в точке ответа с сервера. Как вы видите, обращение идет по адресу /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), к примеру, у Модели 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("\r\n", '<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="" 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="" 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, только есть небольшая разница в и {{ Form::text('tags', ... }}. С картинкой все понятно: если есть старый инпут — заменяем на него, если его нет — то на значение image нашей скидки. В Form::text('tags', ... ) мы, во первых, взяли все теги, которые относятся к конкретной скидке $offer->tags() и выняли из БД только поля name. Далее воспользовались вспомогательной функцией от Laravel array_fetch, что бы у нас получился одномерный массив, а в конце соединили этот массив в строку, вставив запятую и пробел между ними.

Изменим метод 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="" 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="">
</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="">
</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="" 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()), перенаправляем обратно. Если же нет — то прикрепляем новый комментарий от пользователя к скидке с его оценкой и текстом.

Для вывода комментариев на странице скидки изменим немного файл 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("\r\n", '<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. С помощью него мы можем наполнить нашу БД какими-то конфигурационными, или стартовыми / тестовыми данными. Для этого сначала создадим класс 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). Он позволяет первым параметром передать шаблон 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).

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

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

// 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 для отправки почты использует метод send(), который принимает три аргумента:
  • $view — шаблон, который нужно использовать (или массив из двух шаблонов, первый — html шаблон, второй — plaintext)
  • $data — массив данных, ключи которого будут переменными в шаблоне
  • $callback — функцию, которая будет запущена для настройки параметров письма

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

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("\r\n", '<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 — хостинг для приложений на PHP. Он поддерживает Git, SSH, Memcached, Composer, MySQL и другое.

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



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



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

cat ~/.ssh/id_rsa.pub




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

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



fortrabbit замораживает не активные приложения. То, как разморозить приложение можно почитать тут.
Теперь для того, чтобы залить наше приложение на 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 на хостинге можно даже не использовать ssh — достаточно в коммите добавить такой триггер:

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

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

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

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

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

Заключение


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

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

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


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

Об авторе

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


Сбор статистики

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


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

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

UPD: Полезные ссылки
Tags:
Hubs:
+29
Comments 68
Comments Comments 68

Articles