Главная
Блог разработчиков phpBB
 
+ 17 предустановленных модов
+ SEO-оптимизация форума
+ авторизация через соц. сети
+ защита от спама

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

Anna | 31.05.2014 | нет комментариев

Если вы фамильярны с другими 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 
# Перезапустим 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

Сотворим план laravelcode> в папке 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 есть конфигурационный файл <i>database.php</i>, находится он в папке <i>app/config/</i>. Вначале сделаем БД и пользователя в <code>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 есть хорошие инструменты — Миграции и Построитель Схем.

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

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

php artisan migrate:install

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

Laravel 4 Generators

Мега пригодный инструмент — generators от Jeffrey WayGitHub.

Он добавляет в список 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=”", в которой через запятую перечислить поля с ихним типом данных. Создание миграцииТипы данных и другое
  • 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 довода (имена таблиц). Создает сводную таблицу, которая содержит 2foreign 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.
Для файлов образцов применяется раширение .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>
			@if (Session::has('message'))
				<div>
					<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>
		<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 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(). (Подробнее).
В классе 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>: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'
	);
}

Позже нее изменим правила валидации так же и у Моделей CompanyRole и 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>

			<ul>
				<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>{{ link_to_route('login.logout', 'Logout') }}</li>
			</ul>

			@if (Session::has('message'))
				<div>
					<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></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 и записями в таблиц{{ 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></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>: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. Дальше воспользовались вспомогательной функцией от Laravelarray_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="{{{ $offer->image }}}" style="max-width: 200px; max-height:150px;"/></td>
		<td>
			@foreach($offer->tags as $tag)
				<span>{{{ $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>
			<div>
				<div>
					<a href="{{ route('home') }}">Habr Offers</a>
					<ul>
						<li><a href="{{ route('home') }}">Home</a></li>
					</ul>
				</div>
			</div>
		</div>

		<div>

			@if (Session::has('message'))
				<div>
					<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>
				<ul>
		@endif

		<li>
			<div>
				@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>
	<img src="{{{ $offer->image }}}">
</div>
<div>
	<h3>{{{ $offer->title }}}</h3>
	<hr>
	<p>{{ $offer->webDescription() }}</p>
	<hr>
	<p><span>{{{ $offer->off }}} % off</span></p>
	<p>Location: {{{ $offer->city->name }}}</p>
	<p>Offer by: {{{ $offer->company->name }}}</p>
	<p>Expires on: <span>{{{ $offer->expires }}}</span></p>
	<p>Tags:
		@foreach($offer->tags as $tag)
			<span>{{{$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'));
	}
	...

Для правильной работы этих способов нам необходимо задать связи в Моделях CityCompany и 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 href="{{ route('home.offer', $offer->id) }}">
	<img src="{{{ $offer->image }}}">
</a>
<div>
	<h3>{{{ $offer->title }}}</h3>
	<hr>
	<p>{{ $offer->webDescription() }}</p>
	<hr>
	<p><span>{{{ $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>{{{ $offer->expires }}}</span></p>
	<p>Tags:
		@foreach($offer->tags as $tag)
			<a href="{{ route('home.by_tag', $tag->name) }}">
				<span>{{{$tag->name}}}</span>
			</a>
		@endforeach
	</p>
</div>

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

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

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

@section('main')

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

<div>
	<img src="{{{ $offer->image }}}" alt="{{{ $offer->title }}}">
</div>

<div>
	<p>{{ $offer->webDescription() }}</p>
</div>

<div></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 href="{{ route('home.by_tag', $tag->name) }}">
			<span>{{{$tag->name}}}</span>
		</a>
	@endforeach
</p>

<hr>

<div>
  <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>
{{ 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>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>
{{ 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>
	<a href="#">
		<img data-src="holder.js/64x64">
	</a>
	<div>
		<h4>{{{ $user->username }}} <span>mark: {{{ $user->pivot->mark }}}</span></h4>
	<p>{{ 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>{{ 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>
		<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>{{{$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>
	<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>{{{ $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>: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 haveenough 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>
		@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>{{ 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 — образец, тот, что необходимо применять (либо массив из 2-х образцов, 1-й — html образец, 2-й — 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>
    		{{ trans(Session::get('reason')) }}
    	</div>
    @elseif (Session::has('success'))
    	<div>
    		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>
        	{{ 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 href="{{ route('home') }}">Habr Offers</a>
<ul>
	<li><a href="{{ route('home') }}">Home</a></li>
</ul>
<div>
	@if(Auth::guest())
		<a href="{{ route('login.index') }}">Login</a>
		<a href="{{ route('login.register') }}">Register</a>
	@else
		<a href="{{ route('login.logout') }}">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>

	@if (Session::has('message'))
		<div>
			{{ Session::get('message') }}
		</div>
	@endif

	<div>
		<div>
			<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>
			@yield('main')
		</div>
	</div>
</div>

А так же сделаем сам образец comment:

// app/views/partials/comment.blade.php
<div>
	<a href="{{ route('home.offer', $comment->offer_id) }}">
		{{ $comment->user->username }} 
		<span>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') }}">Login</a>
	<a href="{{ route('login.register') }}">Register</a>
@else
	<a href="{{ route('home.bookmarks') }}">My Bookmarks</a>
	<a href="{{ route('login.logout') }}">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. Он поддерживаетGitSSHMemcachedComposerMySQL и другое.

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

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

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

cat ~/.ssh/id_rsa.pub

Последним этапом будет ожидание конфигурации окружения. Вам сформируются данные для доступа к репозиторию GitSSH и SFTPMySQL настройки и 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 (Поспорил, что напишу это).

Источник: programmingmaster.ru
Оставить комментарий
Форум phpBB, русская поддержка форума phpBB
Рейтинг@Mail.ru 2008 - 2017 © BB3x.ru - русская поддержка форума phpBB