Пользователь
14 октября 2013 в 14:35

Разработка → Laravel. Установка, настройка, создание и деплой приложения из песочницы tutorial

Итак, у вас есть желание попробовать или узнать о фреймворке 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: Полезные ссылки
Андрей Даценко @adacenko
карма
11,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (68)

  • +5
    Весь Laravel похож на один огромный велосипед. Кучу вещей которые сделаны не логично, много лишнего кода, нет какого-то единого стиля.
    • 0
      Хотелось бы узнать, что в Laravel выглядит не логично?
      • +4
        Например шаблонизатор, я даже не говорю о том, что есть множество достойных шаблонизаторов, которые можно было бы использовать: синтаксис вывода переменных и условных операторов отличается кардинально, каких-то «фишек» шаблонизатор особо не дает, по сути это чистый PHP, так зачем же мне изучать совершенно новый синтаксис, чтобы вывести пару переменных в шаблоне?
        • +2
          Я так понимаю, Вы не совсем углублялись в Blade?

          Знаете, что есть extend / yield / section / include / parent в нем?

          Вывод с применением htmlentities? ({{{ $string }}})
        • 0
          Laravel в этом плане дает гибкость.
          Никто же не запрещает в шаблонах (даже some.blade.php) использовать нейтив пхп аля <?=$var ?>
  • +5
    Давайте рассмотрим подробно каждый из ужасов:
    1) Скаффолдинг:

    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"

    Если уж делать скаффолдинг то давайте уже диалогом, как например у Yii. Вы нарисовали схему с внешними ключами, но ваш скаффолдинг самых то ключей не создал. Для этого вы потом еще одну команду пишете для генерации ключей. Так для чего мне это? Я БЫСТРЕЕ напишу для каждой таблички 1 строку SQL и создам сразу и табличку и связь.

    2) Валидация.

    'required|email|unique:users,email'
    Компактно конечно, но никак не понятно как например добавить свою проверку. Нельзя вот так делать все на стрингах, парсить вперед назад. Где ООП?

    3) Blade. А зачем он написан вообще? Полностью то же самое что Твиг. Если и так использовать Симфони компоненты используйте сразу и твиг а не велосипед.

    4) Статик методы везде!!! И да я уже слышал что это только фасад и на самом деле там все ООП в глубине, но ведь программист то работает как раз со статическим интерфейсом. А потом гляди и сам начнет статику повсюду писать (ведь если фреймворк так делает, значит так надо).

    5) Где динамический роутинг? Мне это все что руками прописывать надо?

    6) Выключайте дебаг на продакшене!!! habr.eu1.frbit.net/offer_4.%D0%BF%D0%BF%D0%BF

    • 0
      1) Кроме самого файла миграции будут созданы так же другие необходимые файлы (вьюшки, модель, контроллер)
      2) Добавить свою проверку / свои сообщения об ошибках можно! Validator::make($input, $rules, $message) — все кастомно. Можно и свои правила придумывать: custom-validation-rules.
      3) Это уже прихоть Taylor Otwell.
      4) Не считаю это неудобным. Или минусом. Вполне удобно пользоваться.
      5) Проясните что конкретно имеется в виду.
      6) Сделал. Спасибо за поправку. Три дня модерацию статья проходила, совсем забыл убрать это.
      • 0
        Лично мне статика не нравится ощущением отсутствия структуры. То есть я могу что угодно и отукуда угодно сделать. Вы можете сказать что это плюс, так как вы сами управляете тем как устроено ваше приложение, но на самом деле это дает поводов добавлять костыли откуда хочешь, когда хочешь и как хочешь.
        • 0
          Просили передать (RO на хабре): «в ларевеле не статика, а скорее статическая делегация к сервисам»
          • +2
            Ага, а в yii App::app() — доступ до контейнера)
            Так или иначе я могу в любой момент сделать что хочу и откуда хочу. Так быть не должно
            • +1
              То что плохо — спорный вопрос, всё же предоставлять доступ к публичным частям системы без геммороя — лучше, имхо, чем устраивать карусель с делегацией, хотя с другой стороны — публичность в кривых руках… То что доступ через синглтон App::** лучше — соглашусь, мне это больше нравится (уже корячился с ларавелью в разделении на несколько проектов, которые используют одно ядро — это, мммм, немного трудновато, скажем так).
              • 0
                Вы меня не поняли)
                Я не считаю, что App::* лучше, на мой взгляд это наоборот хуже. Я не знаю в каких случаях это нужно. Уже есть общепризнанный паттерн внедрения зависимостей, зачем контроллеру знать о том что он часть приложения — не понятно. Он должен лишь знать что ему нужно это и то (желательно не конкретно, а такого-то интерфейса) и получать. Тогда все прекрасно мокается, заменяется и тд и тп.
      • +2
        1) Это все мусор. Зачем мне эти вюшки и контроллер? Итак все будет переделываться так как мне нужно. Я не видел ни одного сайта который бы использовал контроллеры которые сгенерил скаффолдинг. Генерация моделей это еще куда не шло, но контроллер это мусор.

        2) Можно, но непрозрачно. Если я смотрю на какой-то стринг, мне нифига не ясно что он делает и кто его обрабатывает.

        4) Есть много причин почему статика зло. Можете погуглить =)

        5) Ну например в кохане есть дефолтный роут /controller/action, тоесть если я зайду на /comments/add, то это будет Comments контроллер и action_add() метод.

        P.S. Статья довольно хорошо написанная. Критика в сторону фреймворка а не автора
        • 0
          1) Здесь скаффолдинг применен для того, чтобы быстрого прототипирования.

          2) Можно в модели правила валидации для прозрачности. Полный набор доступных с коробки правил есть тут: Available Validation Rules. Стринга легко читается и понятна 'field' => 'rule1|rule2|rule3:option1,option2,etc...': каждое правило разделяется |, опции добавляются после двоеточия и разделяются запятой.

          4) Почитаю.

          5) Такое есть и Laravel, это называется RESTful Controllers.

          Спасибо за комментарий.
        • –4
          1) Не нужно, не генерируйте, в конце концов это только сторонние расширение.

          2) А как бы вы сделали валидацию? Вот где не понятно, так это в Yii, там объединение по правилам идет, а не по полям.

          3) Вы наверное плохо поняли, там нет статики.

          4) Дефолтных роутов нет, но для работы с каждым контролером нужно написать всего 1 строчку.
          • +1
            3) Есть там статика. И она там везде. Глядя на всю эту простыню из вызовов Some::method() даже грустно стало. С таким же успехом могли наопределять в процедур-стайле кучу функций some_method() а потом попросить передать «это не процедур-стайл, а скорее процедурная делегация к сервисам»
    • +1
      2. Пишете свой класс «CustomValidator», наследуетесь от Illuminate\Validation\Validator.
      Например, требуется правило для подсчета кол-ва элементов в массиве с min/max. Пишете метод в классе:

      class CustomValidator extends Validator
      {
      public function validateArrayConut($attribute, array $array, $parameters) {
      if(count($array) < $parameters[0] OR count($array) > $parameters[1]) {
      return false;
      }
      }
      }

      потом в правилах
      'required|array_count:3,10', где 3 — минимальное кол-во элементов, а 10 — максимальное. Кстати, правила так же могут быть массивом:

      $rules = array('required', 'array_count:3,10');

      3. Я вообще против шаблонизаторов

      4. So what?

      5. Очень удобная штука. Избавляет от надоедливых префиксов action и вы задаете только те роуты, которые должны работать. Никто не попадет «куда не надо».
      • +1
        Почему-то мне написать префикс action_ проще чем руками весь рут прописать
        • 0
          Тут дело эстетики. Мне лично не нравится префикс action. Я пишу что-то, хочу прописать какой-то роут (в любом стиле), я не хочу чтобы фреймворк сам роутил до моего контроллера. + artisan routes выведет полный список роутов, где я смотрю посмотреть какие фильтры применены и как собственно роутинг проводится.

          Попробуйте написать какой-нибудь проект на Laravel, но только в Laravel-style и тогда поймете смак этого фреймворка. Еще рекомендую к прочтению leanpub.com/laravel от Taylor Otwell, который пишет как стоит писать на Laravel.
        • 0
          Да не вопрос. Пишем Route::controller('users', 'UserController');
          И будет вам счастье. Даже больше.
          /user/ — getIndex()
          /user/profile/ — getProfile()
          и т.д.
          Как бонус можно разделить обработку get, post отдельными методами getIndex() postIndex()
          или использовать общий anyIndex() — полный аналог вашего любимого action_*
          • 0
            Немного ошибся. Конечно-же не /user/ а /users/
          • 0
            Все одно надо писать для каждого контроллера?
            • 0
              Ну у вас же их не тысяча :)
            • 0
              Да, нужно указывать явно для каждого контроллера. Но если хочется полной «автоматики» не сложно дописать 3 строчки кода (используя scandir()) и забыть про явное указание маршрутов.
  • +3
    [offtop]
    Если вы фамильярны с другими PHP фреймворками

    В русском языке слово фамильярный имеет «немного» другое значение :)
    [/offtop]
    • +1
      Спасибо за замечание. Исправлю на «хорошо знаком».
  • 0
    Кстати недавно узнал о rad-bundle. Очень интересная штука, не такая мощная, как ларавел, но добавляет «синтаксического сахара» symfony. И самое главное без обилия статики. Любителям laravel советовал бы взглянуть)
    • 0
      Статика в ларавеле для синтаксического сахара, да и со времён php 5.4 и доступности static она стала законной и не влечёт за собой проблем с расширением и переобределением, тем более там юзается паттерн facade.
      Лучшее доказательство — прекрасная тестабилити фреймворка и проектов созданных на нём.
      Ложка дёштя — автокомплит, но это в основном из-за популярности сублайма среди разработчиков, которые ленятся прописать правильный phpdoc
      • 0
        Автокомплит лечится =)
        • 0
          Ну да, это сильно помогает, но местами проблемы остаются. Особенно, когда в компонентах криво phpdoc прописан, вечно забывают начальный \ в доке, хотя находятся в неймспейсе.
  • +2
    Спасибо за проделанную работу.
    • +4
      Очень приятно, что оказался полезен.
  • 0
    думал, что это огромное скопище перевода medium (типа medium.com/on-coding/3bed5d0e645e, medium.com/on-coding/c643022433ad и medium.com/on-coding/e8d93c9ce0e2) и codeforest (4 части www.codeforest.net/laravel4-simple-website-with-backend-1), но вроде как ошибся.

    на недельку бы раньше этот пост и я бы еще больше благодарен, спасибо)

    еще для меня полезным оказался данный сборник cheats.jesse-obrien.ca/
    • 0
      Спасибо за ссылки. Последнюю, пожалуй, добавлю в статью.
    • 0
      Стоит добавить еще laracasts.com/
      Недавно открытый одним из разработчиков Laravel. Всего за 9$ в месяц постоянно добавляю новые видеоуроки по Laravel. + если вы зарегистрируетесь в течении 2х месяцев бесплатно прилагается книга «Laravel: Testing Decoded», в которой описывается как нужно тестировать приложения, Mockery, TDD и как все это круто тестируется в Laravel при правильной архитектуре
  • +1
    Зачем нужен при установке Laravel build-essential, если L4 ставится из composer?
    Причем здесь python-software-properties?
    А что, в Ubuntu php нет?
    Тогда зачем ppa:ondrej/php5?
    Если уж sudo apt-get update, то следует и upgrade сделать.
    Все нужно вносить в apache2.conf?
    # Хак для 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
    Директиву Include в apache уже отменили?
    Включать модуль в apache можно только после рестарта apache?
    И зачем вам флаг yes при установке? А если нет?

    P.S.
    Я не критикую Laravel, в 3-й версии это был очень интересный фреймворк.
    Мой ник на российском сайте Laravel — oleg578.
    • 0
      Инструкцию по настройке LAMP сервера я брал отсюда. (к build-essential и python-software-properties), мало ли кому захочется не только в Laravel покопаться, но и другие фреймворки/еще что-то попробовать.

      Перезапуск apache для принятия изменения конфига. И правильно вы заметили, что перезапуск нужно сделать после включения mod_rewrite.

      Флаг — для ускорения процесса установки.
      • –5
        Эту «инструкцию» — в топку!
        Хабр — достаточно весомый ресурс, чтобы использовать на нем сомнительные материалы.
        Эта «инструкция» как раз из таких.
        • +4
          Считаете данную инструкцию недостойной?

          Предложите свою. Я могу внести ее в статью.
          • –1
            Я уже давно не пользуюсь Ubuntu. Равно как и apache.
            На серверах использую debian + lighttpd, до недавнего времени nginx.
            Тем не менее…
            Для установки готовых пакетов, в том числе и в Ubuntu, нет смысла устанавливать build--essential.
            Установка composer более, чем точно, описана на getcomposer.org.
            Я ставлю composer в ~/.composer/bin/composer и включаю в PATH.
            Больше с ним хлопот нет.
            Наиболее удобное конфигурирование apache у debian.
            Тут даже инструкции никакие не нужны. Достаточно посмотреть документацию и файловую структуру пакета.
            Смысл моего замечания в том, что нужно тщательно проверять информацию перед тем, как ее подавать. Вы должны быть уверены в точности поданого материала. Вы же его представляете читателям.
            В конце кноцов, если вам это не по-плечу, можете просто установить весь стек LAMP посредством tasksel. Пакет PHPMyAdmin также имеет возможность автоматической настройки apache для использования при установке пакета.
            Т.е.:

            sudo apt-get install tasksel
            sudo tasksel
            sudo apt-get install phpmyadmin
            sudo invoke-rc.d apache2 restart

            Должно быть достаточно для конфигурации по умолчанию
            Меня этому в КПИ учили. Но немножко раньше.
            • +1
              справедливости ради стоит отметить, что топик-то вообще никак не про убунту, апач и не про то, как их устанавливать и конфигурировать.
              топик про laravel и все вводные части про сервера и т.д. можно вынести за статью.
              ума не приложу что вас так тревожит (в интернете кто-то неправ, полагаю?), ибо я, например, эту часть вообще пропустил как лишнюю, ибо основная мысль здесь — laravel, php и примеры их использования. yet another quick start, если хотите.

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

              1) Что делать после команды sudo tasksel?
              Здесь, как по мне, должна прилагаться (комментариями или картинками) последовательность действий после запуска этой команды. Что в принципе сложнее, чем в «инструкции», которая есть.

              2) После установки phpmyadmin и рестарта apache будет открываться сам phpmyadmin? На моей машине (Ubuntu 12.04), к примеру без записи в апаче конфиг (#Хака, который присутсвует в инструкции) и рестарта он не открывался.

              3) Свой вариант я протестировал, и он работает.

              Ваш добавлю как альтернативный — только допишите, пожалуйста подробный порядок действий и протестируйте его работоспособность.
    • 0
      > А что, в Ubuntu php нет?
      Частично.
      php -v

      > Директиву Include в apache уже отменили?
      Директива это лучше сделает, чем a2enmod? Расскажите побыстрее, почему.
      • 0
        a2enmod — apache2 enable module.
        Директива Include служит для включения дополнительных настроек в сервер apache. Для этого-же служит и директория conf.d в /etc/apache2. Т.е. для включения phpmyadmin достаточно сделать ссылку в /etc/apache2/conf.d на конфиг phpmyadmin.
        Суть разные понятия.
        L4 требует php 5.3.7 > — в Ubuntu (LTS) 5.3.10 + mcrypt.
        Т.е использование в Laravel PHP 5.5 просто бесполезно.
        Теперь касательно apache, Ubuntu и Laravel.
        Вот вы ставите полный стек LAMP, Composer, настраивайте сервер apache.
        Создаете приложение…
        И, торжественно:
        Перейдем в созданный проект и убедимся, что все работает, запустив команду php artisan serve

        cd habr
        php artisan serve

        Вопрос — какой сервер вы запускаете командой artisan serve?
        Сразу задам вам следующий вопрос, предполагая правильный ответ на предыдущий —
        ЗАЧЕМ вам apache для девелопинга Laravel на localhost, если вы его не используете?
        True Way?
        И я абсолютно согласен, при чем здесь Ubuntu. Ubuntu здесь совершенно ни при чем.
        Для справки;
        vendor/laravel/framework/src/Illuminate/Foundation/Console/ServeCommand.php
        41 passthru(«php -S {$host}:{$port} -t \»{$public}\" server.php");
        • 0
          > ЗАЧЕМ вам apache для девелопинга Laravel на localhost, если вы его не используете?

          Ответ прост: для phpmyadmin.
          • 0
            Ответ, конечно, тронул до души.
            Справедливости ради следует заметить, что L4 требует php 5.3.7, но при этом использует встроенный сервер php, который появился только в 5.4 (специально документацию посмотрел, потому что уже работаю в php 5.5, а на серверах использую 5.4.). Может — это не так?
            • 0
              Да, все правильно, Laravel требует php >= 5.3.7.

              А вот artisan serve — это всего лишь приятный бонус. При версии php < 5.4 встроенный сервер не будет работать, но проект на Laravel будет работоспособным.
          • 0
            Вы меня заинтриговали, и я проверил специально.
            Для phpmyadmin apache не нужен. Достаточно встроенного php сервера.
            Пришлось сделать проброс с виртуалки, конечно, но занятно.
            • 0
              Спасибо, буду знать )
              • 0
                Конечно, нужно установить Mysql, или MariaDB
                Команда для запуска phpmyadmin:

                php -S localhost:9000 -t /usr/share/phpmyadmin/

                Порт можно указать по своему усмотрению.
                В некоторых дистрибутивах конструкция localhost подразумевает роутинг IPv6.
                Некоторые браузеры это не «понимают», поэтому localhost можно заменить на 127.0.0.1
        • 0
          L4 требует php 5.3.7 > — в Ubuntu (LTS) 5.3.10 + mcrypt.
          Т.е использование в Laravel PHP 5.5 просто бесполезно.
          Покажите мне, как вы используете трейты в PHP 5.3. Мне это очень интересно.
          Или вы до сих пор пишете на PHP 5.3 и не хотите пользоваться новыми удобными возможностями PHP?
          • 0
            Я работаю с php 5.4.4-14+deb7u4. Хотя на десктопе у меня Arch и, соответственно php 5.5. С трейтами сейчас не работаю, т.к. использую PhalconPHP.
            Хотел по поводу трейтов заметку написать, руки не дошли. Если интересно, можете мой github посмотреть github.com/oleg578/DependencyInjectionPHP
            • 0
              Это совершенно не вариант для использования в продакшене — с ним не работает статический анализ в IDE.
              С нормальными 5.4 трейтами работает.
  • 0
    Лично мне кажется, что здесь уж много велосипедов наново создавались…
    Я не могу критиковать данный фраемворк, так как с ним не работал, но по посту было замечено:

    1. Создания сущностей немного сложноватое. Если посмотреть в сторону Doctrine 2 (Symfony2/DoctrineBundle), то там все намного проще и лаконичней. Все связи определяются в одном классе, при этом будут созданы все необходимые ключи.

    2. Миграции. А зачем они были вообще созданы? Чтобы во время разработки приложения создавать связи? habrahabr.ru/post/121265/
    Если я не ошибаюсь, то «миграции» создаются в целях «без болезненного» перехода на новую архитектуру (Изменился тип поля в БД, либо добавлено новое)

    3. Темизатор. Не один раз уже вижу в инете куча новых возможно и классных темизаторов. Но зачем наново создают велосипеды? Чтобы потом сказать: «ЭТО Я СДЕЛАЛ, но у меня времени не было, чтобы его сделать так как Twig, или Smarty»

    4. Все на статике. Лично я не поклонник статики в таком объеме, по той причине, что нету возможности хорошо указать зависимости среди сервисов, сделать override того или инного сервиса, не наступая на грабли и не создавая своих костылей.

    P.S. Круто, супер, новая система, новый фремворк, новые знания, достижения!!! Но задайте себя вопросом, бедет ли он уместен на ОЧЕНЬ больших проектах, где архитектурное решение — самое главное в разработке?

    P.S.S. Автору большое спасибо за статью, познавательно!
    • 0
      1. Пример создания сущности:

      class Offer extends Eloquent {
      	protected $guarded = array();
      
      	public function city()
      	{
      		return $this->belongsTo('City');
      	}
      
      	public function company()
      	{
      		return $this->belongsTo('Company');
      	}
      
      	public function tags()
      	{
      		return $this->belongsToMany('Tag');
      	}
      
      	public function usersComments()
      	{
      		return $this->belongsToMany('User', 'comments')->withPivot('body', 'mark')->withTimestamps();
      	}
      }
      

      Тут все связи заданы. Или я понял не так?

      2. К примеру во время dev вся схема БД создается постепенно, а на production можно сразу прогнать все миграции вместо экспорта БД из дева.

      3. Ничего не могу тут ответить )) Дела автора. Меня, в принципе, устраивает.

      4. По этому поводу есть много мнений и даже статей. Кому что )

      И вам спасибо за комментарий!
      • 0
        1. Да, возможно этот момент и хорош, но давайте посмотрим на Doctrine: docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/association-mapping.html Как по мне, лучше, чем в методах писать вызовы доп. функций, тем более унаследовать. Это ведь просто сущность?

        2. Здесь наверное нужно четко распределить, что же такое «схема БД» и «миграция»!

        3. Ну главное, чтобы разработчику было уютно в своем коде! :)

        4. Верно, кому как. Но этот подход намного тяжелее поддерживать, тестировать. Вот к примеру мы захотим протестить сервис, который вызывает другой сервис через статику…
        • 0
          1. Интересный подход. А там есть Eager Loading / Lazy Eager Loading. Всю документацию из Doctrine не прочел, но заметил, что там используется __construct() для 6.5 — 6.10.

          2. Миграции это своеобразный git для БД, с помощью которого мы создаем/изменяем/удаляем таблицы с полями/индексами/и т.д.

          4. У них есть документация по Unit Testing. И достаточно литературы (пример) по этому поводу. Многое уже ранее в комментариях было сказано по поводу «статики».
          • 0
            1. docs.doctrine-project.org/en/latest/reference/working-with-objects.html#by-eager-loading

            2. Это спорный вопрос, но в большинстве ответят: Зачем это делать, если это умеет делать ORM. Если фраемворк так устроен, то значит так нужно :)

            4. Ну а Вы как считаете, что лучше тестировать? Статику или объекты созданные с конструктора?
            • 0
              2. Ну, к примеру, взять мою статью. Заходим на github, клоним проект, изменяем конфиг БД на свой, далее 2 строчки в терминале:

              php atrisan migrate:install
              php artisan migrate --seed

              И все таблицы созданы + если есть seeds — то и конфигурационные данные будут внесены.

              4. Ответить на этот вопрос я вам пока что не могу, так как у меня еще слишком мало опыта.
    • 0
      Вконтактик, конечно, на нем не напишешь… что в Вашем понимании большой проект? Это посещаемость 1000/сутки? 200 000 сутки? 1 млн/сутки? Или, к примеру, 200-250 человек онлайн достаточно будет? (у меня проект небольшой, как я считаю, крутится на средненьком сервере на хетцнере, 200-250 менеджеров в онлайн, «менеджерят» свои данные, постоянно лопатят базу и добавляют всякую фиговину туда)… Laravel 4 — отлично себя уже показал.
  • +3
    Если кого заинтересовала тема Laravel — могу написать еще пару статей по возможностям данного фреймворка.
    • +1
      Там не так много чего-то описывать есть, фреймворк простенький. Но да, интересно было бы всеравно… :)
      • 0
        тоже было бы интересно
    • 0
      Да-да, напишите, пожалуйста! :)

      А какие минусы по-вашему мнению в Laravel? Работали ли вы с другими фреймворками? Ну, сравнение сделать можете (опять же, на ваш взгляд)?
  • 0
    В ларе версии выше 4.1.25 при авторизации, если делать все следуя вашей статье, появится ошибка
    Class User contains 3 abstract methods and must therefore be declared abstract or implement the remaining methods (Illuminate\Auth\UserInterface::getRememberToken, Illuminate\Auth\UserInterface::setRememberToken, Illuminate\Auth\UserInterface::getRememberTokenName)

    Связанно это с обновлением безопасности в версии 4.1.26 (http://laravel.com/docs/upgrade), где закрывали дыру с похищенными куками. Исправить очень легко:
    1) Надо в модели пользователей реализовать три метода, которые определены в интерфейсе UserInterface
        public function getRememberToken()
        {
            return $this->remember_token;
        }
        
        public function setRememberToken($value)
        {
            $this->remember_token = $value;
        }
        
        public function getRememberTokenName()
        {
            return 'remember_token';
        }
    

    В миграцию для пользователей добавить поле куда будет писаться токен.
    $table->string('remember_token', 100);
    
    • 0
      Спасибо, написал ремарку возле первого скаффолда.
  • 0
    Понекропощу немного: Зачем вы на гитхаб залили все, включая папочку вендор и composer.lock? Это ужасно, композер создан именно для того чтоб мусор не держать на цвс серверах, все зависимости можно подтянуть в любом месте одной командой.

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