- PVSM.RU - https://www.pvsm.ru -
Итак, у вас есть желание попробовать или узнать о фреймворке Laravel [1].
Если вы фамильярны с другими PHP
фреймворками — для вас это не составит особого труда, если же нет — это отличный выбор для первого фреймворка.
Статья очень большая. Рекомендую читать ее полностью во время выходных.
Для ленивых:
GitHub [2]
Приложение [3]
Для установки Laravel нам потребуется Composer [4]
Composer является инструментом для управления зависимостями в
PHP
. Он позволяет объявлять зависимые библиотеки, необходимые для проекта, и устанавливать их в проект.
— Composer [5]
Установка окружения будет происходить в среде *nix
(на сайте так же есть мануал по установке на Windows [6], плюс к этому вам нужен будет сервер, например WAMP [7] и Git [8]).
Предположим, что у Вас совсем чистенькая ОС. Тогда откройте терминал и введите эти строчки скопируйте и вставьте
# Установка недостающих компонентов
sudo apt-get update
sudo apt-get install -y build-essential
sudo apt-get install -y python-software-properties
# Добавление в репозиторий php 5.5
sudo add-apt-repository ppa:ondrej/php5
sudo apt-get update
# Установка сервера
sudo apt-get install -y php5
sudo apt-get install -y apache2
sudo apt-get install -y libapache2-mod-php5
sudo apt-get install -y mysql-server
sudo apt-get install -y php5-mysql
sudo apt-get install -y php5-curl
sudo apt-get install -y php5-gd
sudo apt-get install -y php5-mcrypt
sudo apt-get install -y git-core
sudo apt-get install -y phpmyadmin
# Хак для phpmyadmin
echo "Include /etc/phpmyadmin/apache.conf" | sudo tee -a /etc/apache2/apache2.conf
# Перезапустим apache
sudo /etc/init.d/apache2 restart
# Включение mod_rewrite
sudo a2enmod rewrite
# Глобально установим Composer
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
Через некоторое время у вас будут установлены все необходимые инструменты.
Перейдем непосредственно к установке Laravel
.
# Предпочитаемая мной структура папок
cd # перейдем в директорию /home/%user%
mkdir workspace #создадим папку workspace
cd workspace # перейдем в нее
mkdir php # создадим папку php
cd php # перейдем в папку php
Создадим проект laravel
в папке habr
composer create-project laravel/laravel habr --prefer-dist
# .... тут будет долгий процес создания проекта ....
Перейдем в созданный проект и убедимся, что все работает, запустив команду php artisan serve
cd habr
php artisan serve
Локальный сервер будет доступен по адресу http://localhost:8000 [9].
На всякий случай artisan — это скрипт для командной строки, который есть в
Laravel
. Он предоставляет ряд полезных команд для использования при разработке. Он работает поверх компонента консолиSymfony
. (Artisan CLI [10]). Есть много полезных команд, с помощью которых в командной строке можно создавать разные полезные вещи. Для списка команд введитеphp artisan list
в командной сроке.
Перейдя по адресу http://localhost:8000 [9] вы должны увидеть красивую заставку как в начале поста.
Для соединения с бозой данных (далее БД) у Laravel
есть конфигурационный файл database.php, находится он в папке app/config/.
Сначала создадим БД и пользователя в MySQL
mysql -u root -p
# Введите свой пароль
> CREATE DATABASE `habr` CHARACTER SET utf8 COLLATE utf8_general_ci;
> CREATE USER 'habr'@'localhost' IDENTIFIED BY 'my_password';
> GRANT ALL PRIVILEGES ON habr.* TO 'habr'@'localhost';
> exit
Отлично! У нас есть все данные для доступа к MySQL
: пользователь habr с паролем my_password и БД habr на хосте localhost. Перейдем в файл конфигурации БД и изменим наши настройки.
В Laravel
есть отличные инструменты — Миграции [11] и Построитель Схем [12].
Миграции это тип управления версиями в базе данных. Они позволяют команде разработчиков изменять схему базы данных и оставаться в курсе о текущем состоянии схемы. Миграция, как правило, в паре с Построителем Схем позволют легко управлять схемой БД.
— Миграции [11]
Построитель Схем — это класс Sheme. Он дает возможность манипулирования таблицами в БД. Он хорошо работает со всеми БД, которые поддерживаютсяLaravel
, и имеет единыйAPI
для всех этих систем.
— Построитель Схем [12]
Во первых создадим таблицу миграций:
php artisan migrate:install
Если настройки соединения с БД правильны, то мы готовы создавать миграции и таблицы.
Но перед этим хочу вас познакомить с установкой дополнительных пакетов, которые можно использовать для более эффективного и быстрого создания веб приложения.
Мега полезный инструмент — generators от Jeffrey Way. GitHub [13].
Он добавляет в список artisan много полезных команд, таких как:
Установка пакетов с помощью Composer
происходит достаточно просто. Нужно отредактировать файл composer.json в корне приложения, добавив строчку "way/generators": "dev-master"
в список "require"
.
"require": {
"laravel/framework": "4.0.*",
"way/generators": "dev-master"
},
После этого нужно обновить зависимости проекта. Введите в терминале
composer update
Последним штрихом будет занесение в кофигурационный файл app/config/app.php в список провайдеров приложения строки
'WayGeneratorsGeneratorsServiceProvider'
Теперь список команд php artisan
будет также содержать новые команды generate
. В следующем разделе я покажу как использовать generate
для создания приложения и ускорения разработки.
Предположим, что мы создаем некий блог сайт со скидками. Для этого нам нужно:
Набросаем схему таблиц в БД. У меня получилось что-то такое:
За это спасибо generator'у. Так как все, что я сделал — это прописал 10 строк, кстати, вот и они:
php artisan generate:migration create_users_table --fields="email:string:unique, password:string[60], username:string:unique"
php artisan generate:scaffold role --fields="role:string:unique"
php artisan generate:pivot users roles
php artisan generate:scaffold city --fields="name:string:unique"
php artisan generate:scaffold company --fields="title:string:unique"
php artisan generate:scaffold tag --fields="title:string:unique"
php artisan generate:scaffold offer --fields="title:string, description:text, city_id:integer:unsigned, company_id:integer:unsigned, off:integer:unsigned, image:string, expires:date"
php artisan generate:scaffold comment --fields="body:text, user_id:integer:unsigned, offer_id:integer:unsigned, mark:integer"
php artisan generate:pivot offers tags
# И сохраним схемы в БД
php artisan migrate
С помощью последней команды в БД будут занесены все миграции, которые еще не были записаны. Важно то, что все новые миграции будут запущены одним стэком. Для того, чтобы откатить миграцию есть команда php artisan migrate:rollback
, а для того, чтобы откатить все миграции до нуля migrate:reset
, чтобы скатить до нуля и запустить все миграции migrate:refresh
.
Подробнее о командах генератора:
Метод 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 |
GET
запросе по URL
/roles), про сам шаблонизатор я расскажу чуть позжеGET
запрос на URL
/roles/{id})GET
на URL
/roles/create)GET
на URL
/roles/{id}/edit})а так же обновляет и добавляет данные в файлы
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');
});
}
}
Взгянув на схему БД видим ситуацию по лучше
На данный момент все ссылки на ресурсы являются открытыми, и по ним можно переходить всем кому угодно.
Допустим, добавим роль admin. По ссылке http://localhost:8000/roles [18] видим следующую картину:
Немного о шаблонах и шаблонизаторе Blade [19] в Laravel.
Для файлов шаблонов используется раширение .balde.php. Заглянув в файл app/views/layouts/scaffold.blade.php мы видим
// app/views/layouts/scaffold.blade.php
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.1/css/bootstrap-combined.min.css" rel="stylesheet">
<style>
table form { margin-bottom: 0; }
form ul { margin-left: 0; list-style: none; }
.error { color: red; font-style: italic; }
body { padding-top: 20px; }
</style>
</head>
<body>
<div class="container">
@if (Session::has('message'))
<div class="flash alert">
<p>{{ Session::get('message') }}</p>
</div>
@endif
@yield('main')
</div>
</body>
</html>
Что здесь происходит? Сам файл является скелетом, лэйаутом, который можно расширить, добавив внутрь секции main
какой-то контент, или еще один шаблон. Двойные фигурные скобки {{ $var }} являются аналогом <?php echo $var; ?>. Класс Session [20] используется здесь для вывода сообщений пользователю, если мы передадим какое-то сообщение. Сообщение является временным, и при обновлении страницы пропадет. Если мы откроем только что созданный шаблон app/views/roles/index.blade.php
// app/views/roles/index.blade.php
@extends('layouts.scaffold')
@section('main')
<h1>All Roles</h1>
<p>{{ link_to_route('roles.create', 'Add new role') }}</p>
@if ($roles->count())
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>Role</th>
</tr>
</thead>
<tbody>
@foreach ($roles as $role)
<tr>
<td>{{{ $role->role }}}</td>
<td>{{ link_to_route('roles.edit', 'Edit', array($role->id), array('class' => 'btn btn-info')) }}</td>
<td>
{{ Form::open(array('method' => 'DELETE', 'route' => array('roles.destroy', $role->id))) }}
{{ Form::submit('Delete', array('class' => 'btn btn-danger')) }}
{{ Form::close() }}
</td>
</tr>
@endforeach
</tbody>
</table>
@else
There are no roles
@endif
@stop
То нам станет ясно, что этот шаблон расширяет шаблон app/views/layouts/scaffold.blade.php, за это говорит код @extends('layouts.scaffold')
. Заметьте, что тут для разделения между папками используется точка, хотя так же можно использовать и /.
Далее в секцию main
будет записано все до первого появления @stop
. Так же тут используются знакомые нам if - else - endif
и foreach - endforeach
, вспомогательная функция link_to_route
, которую нам предоставляет Laravel (Helper Functions) и класс Form
для создания форм (Предпочтительно нужно пользоваться им, хотя бы Form::open(), так как он создает дополнительный аттрибут формы _token [21] — защита от подделки кросс сайтовых запросов и _method [22] в случае PUT / PATCH или DELETE).
Первым делом подумаем о защите всех ресурсов. Для этого нам нужно ввести авторизацию.
Создадим новый контроллер LoginContoller
в папке app/controllers
php artisan generate:controller LoginController
И добавим для него несколько шаблонов
mkdir app/views/login
php artisan generate:view index --path="app/views/login"
php artisan generate:view register --path="app/views/login"
php artisan generate:view dashboard --path="app/views/login"
Теперь изменим сам контроллер. Нам нужны 5 методов:
Измененный контроллер LoginController будет выглядеть так:
// app/controllers/LoginController.php
class LoginController extends BaseController {
/**
* Login Form.
*
* @return Response
*/
public function index()
{
return View::make('login.index');
}
/**
* Registration form.
*
* @return Response
*/
public function create()
{
return View::make('login.register');
}
/**
* Registring new user and storing him to DB.
*
* @return Response
*/
public function store()
{
$rules = array(
'email' => 'required|email|unique:users,email',
'password' => 'required|alpha_num|between:4,50',
'username' => 'required|alpha_num|between:2,20|unique:users,username'
);
$validator = Validator::make(Input::all(), $rules);
if($validator->fails()){
return Redirect::back()->withInput()->withErrors($validator);
}
$user = new User;
$user->email = Input::get('email');
$user->username = Input::get('username');
$user->password = Hash::make(Input::get('password'));
$user->save();
Auth::loginUsingId($user->id);
return Redirect::home()->with('message', 'Thank you for registration, now you can comment on offers!');
}
/**
* Log in to site.
*
* @return Response
*/
public function login()
{
if (Auth::attempt(array('email' => Input::get('email'), 'password' => Input::get('password')), true) ||
Auth::attempt(array('username' => Input::get('email'), 'password' => Input::get('password')), true)) {
return Redirect::intended('dashboard');
}
return Redirect::back()->withInput(Input::except('password'))->with('message', 'Wrong creadentials!');
}
/**
* Log out from site.
*
* @return Response
*/
public function logout()
{
Auth::logout();
return Redirect::home()->with('message', 'See you again!');
}
}
Первые два метода генерируют из шаблонов HTML.
Метод store сохраняет в нашу БД нового пользователя, принимая все входящие через POST
данные от Input::all()
. (Подробнее [23]).
В классе Input
находятся данные, которые были отправлены при POST запросе. Он имеет ряд статичных методов, таких как all()
, get()
, has()
и другие (Basic Input [24]).
Hash
— это класс шифрования, который использует метод bcrypt [25], чтобы пароли в БД хранились в зашифрованом виде (Laravel Security [26]).
Но перед регистрацией нам нужно провести валидацию входящих данных.
Для этого в Laravel
есть класс Validator [27]. Метод Validation::make
принимает 2 или 3 аргумента:
$input
— обязательный, массив входящих данных, которые нужно проверить$rules
— обязательный, массив с правилами к входящим данным$messages
— опциональный, массив с сообщениями об ошибкахПолный список доступных правил можно посмотреть тут Available Validation Rules [28].
Метод fails()
возвращает true или false в зависимости от того, прошли ли валидацию данные в соответствии с правилами, которые мы передали в метод make
.
Класс Redirect [29] используется для перенаправления. Его методы:
fallback
$validator
(! Важно знать, что переменная $errors создается на всех страницах при GET запросах, поэтому она всегда доступна на всех страницах).Класс Auth
является классом авторизации, у него имется ряд методов, в том числе и loginUsingId($id)
, который авторизирует пользователя по указанному идентификатору из БД (Authenticating Users [30]). Так как после регисрации мы хотим автоматически авторизировать пользователя, то воспользуемся им.
Метод нашего Контроллера login()
авторизирует пользователя по email
или username
и перенаправляет на страницу, с которой он попал под фильтр авторизации. В случае не совпадения данных, перенаправляет обратно с входящими данными, сообщением о ошибке, но без пароля.
Таким образом у нас есть Контроллер, который отвечает за авторизацию.
Следующим шагом для скрытия всех ресурсов от доступа будет изменение файла app/routes.php, который содержит маршруты приложения.
// app/routes.php
...
Route::get('/', array('as' => 'home', function()
{
return View::make('hello');
}));
Route::get('logout', array('as' => 'login.logout', 'uses' => 'LoginController@logout'));
Route::group(array('before' => 'un_auth'), function()
{
Route::get('login', array('as' => 'login.index', 'uses' => 'LoginController@index'));
Route::get('register', array('as' => 'login.register', 'uses' => 'LoginController@register'));
Route::post('login', array('uses' => 'LoginController@login'));
Route::post('register', array('uses' => 'LoginController@store'));
});
Route::group(array('before' => 'admin.auth'), function()
{
Route::get('dashboard', function()
{
return View::make('login.dashboard');
});
Route::resource('roles', 'RolesController');
Route::resource('cities', 'CitiesController');
Route::resource('companies', 'CompaniesController');
Route::resource('tags', 'TagsController');
Route::resource('offers', 'OffersController');
Route::resource('comments', 'CommentsController');
});
Route::filter('admin.auth', function()
{
if (Auth::guest()) {
return Redirect::to('login');
}
});
Route::filter('un_auth', function()
{
if (!Auth::guest()) {
Auth::logout();
}
});
Перейдя теперь по ссылке, к примеру /roles нас будет перенаправлено на страницу /login, на которой пока отображается только стандартный текст «index.blade.php».
Ко всем маршрутам, заключенным в Route::group(array('before' => 'admin.auth'))
будет применятся фильтр admin.auth, который проверяет, является ли пользователь гостем, или нет, и в случае, если является — отправит его на страницу входа. Про фильтры можно почитать тут [31], а про группировку маршрутов тут [32]. Другой фильтр Route::group(array('before' => 'un_auth'))
будет проверять, является ло пользователь вашедшим на сайт, и если проверка выполнятся — то он его разлогинивает.
Для нормальной работы изменим файлы логина и регистрации:
// app/views/login/index.blade.php
@extends('layouts.scaffold')
@section('main')
<h1>Login</h1>
<p>{{ link_to_route('login.register', 'Register') }}</p>
{{ Form::open(array('route' => 'login.index')) }}
<ul>
<li>
{{ Form::label('email', 'Email or Username:') }}
{{ Form::text('email') }}
</li>
<li>
{{ Form::label('password', 'Password:') }}
{{ Form::password('password') }}
</li>
<li>
{{ Form::submit('Submit', array('class' => 'btn btn-info')) }}
</li>
</ul>
{{ Form::close() }}
@include('partials.errors', $errors)
@stop
// app/views/login/register.blade.php
@extends('layouts.scaffold')
@section('main')
<h1>Register</h1>
<p>{{ link_to_route('login.index', 'Login') }}</p>
{{ Form::open(array('route' => 'login.register')) }}
<ul>
<li>
{{ Form::label('email', 'Email:') }}
{{ Form::text('email') }}
</li>
<li>
{{ Form::label('username', 'Username:') }}
{{ Form::text('username') }}
</li>
<li>
{{ Form::label('password', 'Password:') }}
{{ Form::password('password') }}
</li>
<li>
{{ Form::submit('Submit', array('class' => 'btn btn-info')) }}
</li>
</ul>
{{ Form::close() }}
@include('partials.errors', $errors)
@stop
// app/views/login/dashboard.blade.php
@extends('layouts.scaffold')
@section('main')
<h1>Administrative Dashboard</h1>
<p>Nice to see you, <b>{{{ Auth::user()->username }}}</b></p>
@stop
// app/views/partials/errors.blade.php
@if ($errors->any())
<ul>
{{ implode('', $errors->all('<li class="error">:message</li>')) }}
</ul>
@endif
Как вы заметили, тут я использовал новый прием в шаблонизаторе @include('view', $variable)
. В применении он весьма прост — передайте 2 аргумента:
Зарегистрируйтесь на сайте, чтобы иметь доступ к сайту.
Что же, теперь можна заняться ресурсами. Начнем с городов. Первым делом изменим в Модели City
правила валидации:
// app/models/City.php
class City extends Eloquent {
protected $guarded = array();
public static $rules = array(
'name' => 'required|alpha|min:2|max:200|unique:cities,name'
);
}
После нее изменим правила валидации так же и у Моделей Company
, Role
и Tag
:
// app/models/Company.php
...
public static $rules = array(
'name' => 'required|alpha|min:2|max:200|unique:companies,name'
);
...
// app/models/Role.php
...
public static $rules = array(
'role' => 'required|alpha|min:2|max:200|unique:roles,role'
);
...
// app/models/Tag.php
...
public static $rules = array(
'name' => 'required|min:2|max:200|unique:tags,name'
);
...
Для удобства перехода между ссылками добавим меню в app/views/layouts/scaffold.blade.php, а так же добавим jQuery и jQuery-UI для будующих нужд
// app/views/layouts/scaffold.blade.php
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.1/css/bootstrap-combined.min.css" rel="stylesheet">
<link href="//code.jquery.com/ui/1.10.3/themes/smoothness/jquery-ui.css" rel="stylesheet">
<style>
table form { margin-bottom: 0; }
form ul { margin-left: 0; list-style: none; }
.error { color: red; font-style: italic; }
body { padding-top: 20px; }
input, textarea, .uneditable-input {width: 50%; min-width: 200px;}
</style>
@yield('styles')
</head>
<body>
<div class="container">
<ul class="nav nav-pills">
<li>{{ link_to_route('offers.index', 'Offers') }}</li>
<li>{{ link_to_route('tags.index', 'Tags') }}</li>
<li>{{ link_to_route('roles.index', 'Roles') }}</li>
<li>{{ link_to_route('cities.index', 'Cities') }}</li>
<li>{{ link_to_route('comments.index', 'Comments') }}</li>
<li>{{ link_to_route('companies.index', 'Companies') }}</li>
<li class="pull-right">{{ link_to_route('login.logout', 'Logout') }}</li>
</ul>
@if (Session::has('message'))
<div class="flash alert">
<p>{{ Session::get('message') }}</p>
</div>
@endif
@yield('main')
</div>
<script type="text/javascript" src="//code.jquery.com/jquery.min.js"></script>
<script type="text/javascript" src="//code.jquery.com/ui/1.10.3/jquery-ui.min.js"></script>
@yield('scripts')
</body>
</html>
Далее перейдем к редактированию правил валидации в Модели Offer
:
// app/models/Offer.php
...
public static $rules = array(
'title' => 'required|between:5,200',
'description' => 'required|min:10',
'city_id' => 'required|exists:cities,id',
'company_id' => 'required|exists:companies,id',
'off' => 'required|numeric|min:1|max:100',
'image' => 'required|regex://images/d{4}/d{2}/d{2}/([A-z0-9]){30}.jpg/',
// matches /images/2012/12/21/ThisIsTheEndOfTheWorldMaya2112.jpg
'expires' => 'required|date'
);
Здесь я использовал сложный паттерн для поля image
, так как хочу воспользоваться средствами AJAX
для загрузки картинок, и в саму валидацию передавать только путь к картинке на сервере. Значит начнем с изменения шаблона app/views/offers/create.blade.php и создания отдельного файла для скриптов.
// app/views/offers/create.blade.php
...
{{ Form::label('file', 'Image:') }}
{{ Form::file('file')}}
<img src="{{Input::old('image')}}" id="thumb" style="max-width:300px; max-height: 200px; display: block;">
{{ Form::hidden('image') }}
<div class="error"></div>
...
@section('scripts')
@include('offers.scripts')
@stop
// app/views/offers/scripts.blade.php
<script>
$(document).ready(function(){
// Добавим красивый выбор даты
$('#expires').datepicker({dateFormat: "yy-mm-dd"});
var uploadInput = $('#file'), // Инпут с файлом
imageInput = $('[name="image"]'), // Инпут с URL картинки
thumb = document.getElementById('thumb'), // Превью картинки
error = $('div.error'); // Вывод ошибки при загрузке файла
uploadInput.on('change', function(){
// Создадим новый объект типа FormData
var data = new FormData();
// Добавим в новую форму файл
data.append('file', uploadInput[0].files[0]);
// Создадим асинхронный запрос
$.ajax({
// На какой URL будет послан запрос
url: '/upload',
// Тип запроса
type: 'POST',
// Какие данные нужно передать
data: data,
// Эта опция не разрешает jQuery изменять данные
processData: false,
// Эта опция не разрешает jQuery изменять типы данных
contentType: false,
// Формат данных ответа с сервера
dataType: 'json',
// Функция удачного ответа с сервера
success: function(result) {
// Получили ответ с сервера (ответ содержится в переменной result)
// Если в ответе есть объект filelink
if (result.filelink) {
// Зададим сообтветсвующий URL нашему мини изображению
thumb.setAttribute('src', result.filelink);
// Сохраним значение в input'е
imageInput.val(result.filelink);
// Скроем ошибку
error.hide();
} else {
// Выведет текст ошибки с сервера
error.text(result.message);
error.show();
}
},
// Что-то пошло не так
error: function (result) {
// Ошибка на стороне сервера
error.text("Upload impossible");
error.show();
}
});
});
});
</script>
Здесь мы будем добавлять картинку по нажатию на input[name="file"]
и отправлять ее с помощью AJAX
по URL
/upload. Ответом с этого URL будет ссылка на загруженное изображение. Эту ссылку мы вставим в атрибут src у картинки #thumb и сохраним в скрытом инпуте image
. Дальше нам нужно в файле app/routes.php добавить маршут upload
:
// app/routes.php
...
Route::group(array('before' => 'admin.auth'), function(){
...
Route::resource('comments', 'CommentsController');
Route::post('upload', array('uses' => 'HomeController@uploadOfferImage'));
}
...
Отлично, URL мы зарегистрировали, осталось прописать логику в HomeController
. Для этого в файле app/controllers/HomeController.php добавим метод uploadOfferImage
min:
// app/controllers/HomeController.php
class HomeController extends BaseController {
...
public function uploadOfferImage()
{
$rules = array('file' => 'mimes:jpeg,png');
$validator = Validator::make(Input::all(), $rules);
if ($validator->fails()) {
return Response::json(array('message' => $validator->messages()->first('file')));
}
$dir = '/images'.date('/Y/m/d/');
do {
$filename = str_random(30).'.jpg';
} while (File::exists(public_path().$dir.$filename));
Input::file('file')->move(public_path().$dir, $filename);
return Response::json(array('filelink' => $dir.$filename));
}
}
Все достаточно просто: правила, валидация, ошибки, ответ. Что бы сохранить для начала мы зададим папку, в которую будем его сохранять — это public_path()/images/текущий год/месяц/дата/ (public_path()
— это вспомогательная функция Laravel
для пути к публичным файлам), далее создадим рандомное имя файла str_random(30)
длиною 30 символов и расширением jpg
. После этого воспользуемся классом Input
и его методом file('file')->move('destination_path', 'filename')
, где: 'file' — входящий файл, 'destination_path' — папка, в которую перемещаем файл, 'filename' — имя для файла, который будет сохранен.
Response::json
выдаст ответ в формате json
.
Отлично! Файлы у нас теперь загружаются с помощью AJAX
.
Следующим шагом будет изменение Form::input('number', 'city_id')
и Form::input('number', 'company_id')
на селекты с реальными данными.
// app/views/offers/create.blade.php
...
<?php $cities = array(0 => 'Choose city');
foreach (City::get(array('id', 'name')) as $city) {
$cities[$city->id] = $city->name;
} ?>
<li>
{{ Form::label('city_id', 'City_id:') }}
{{ Form::select('city_id', $cities) }}
</li>
<?php $companies = array(0 => 'Choose company');
foreach (Company::get(array('id', 'name')) as $company) {
$companies[$company->id] = $company->name;
} ?>
<li>
{{ Form::label('company_id', 'Company_id:') }}
{{ Form::select('company_id', $companies) }}
</li>
...
Как работают селекты можно глянуть тут Forms & Html (Dropdown Lists) [33]. Таким образом мы имеем возможность выбирать из существующих городов и компаний в БД.
Чего нам еще не хватает — так это добавление тегов к скидкам. Тут нам поможет jquery-ui с autocomplete [34] для добавления нескольких значений. Для этого расширим файл с скриптами app/views/offers/create.blade.php:
// app/views/offers/scripts.blade.php
<script>
$(document).ready(function(){
...
function split( val ) {
return val.split( /,s*/ );
}
function extractLast( term ) {
return split( term ).pop();
}
$( "#tags" )
// don't navigate away from the field on tab when selecting an item
.bind( "keydown", function( event ) {
if ( event.keyCode === $.ui.keyCode.TAB &&
$( this ).data( "ui-autocomplete" ).menu.active ) {
event.preventDefault();
}
})
.autocomplete({
source: function( request, response ) {
$.getJSON( "/tags", {
term: extractLast( request.term ),
},
function( data ) {
response($.map(data, function(item) {
return {
value: item.name
}
}))
}
);
},
search: function() {
// custom minLength
var term = extractLast( this.value );
if ( term.length < 2 ) {
return false;
}
},
focus: function() {
// prevent value inserted on focus
return false;
},
select: function( event, ui ) {
console.log(ui);
console.log(this);
var terms = split( this.value );
// remove the current input
terms.pop();
// add the selected item
terms.push( ui.item.value );
// add placeholder to get the comma-and-space at the end
terms.push( "" );
this.value = terms.join( ", " );
return false;
}
});
});
</script>
Это стандартный пример использования с сайта jqueryui.com [35], только немного модифицированный в точке ответа с сервера. Как вы видите, обращение идет по адресу /tags
. Организуем логику ответа на AJAX
запрос по этому URL
.
// app/controllers/TagController.php
class TagsController extends BaseController {
...
/**
* Display a listing of the resource.
*
* @return Response
*/
public function index()
{
$tags = $this->tag->all();
// Запрос является AJAX запросом
if (Request::ajax()) {
// Выберем только те теги, которые подходят по критериям поиска
$tags = Tag::where('name', 'like', '%'.Input::get('term', '').'%')->get(array('name'));
// Вернем ответ в формате json
return $tags;
}
return View::make('tags.index', compact('tags'));
}
...
Интересно то, что Eloquent
преобразуется в формат json
, если мы ее возвращаем, поэтому здесь нет необходимости использовать Response::json()
. И вот у нас автодополняются теги.
Последнее, что нам нужно сделать — это изменить логику создания скидок.
// app/controllers/OffersController.php
class OffersController extends BaseController {
...
/**
* Store a newly created resource in storage.
*
* @return Response
*/
public function store()
{
$rules = Offer::$rules;
$rules['expires'] .= '|after:'.date('Y-m-d', strtotime('+1 day')).'|before:'.date('Y-m-d', strtotime('+1 month'));
$validation = Validator::make(Input::all(), $rules);
if ($validation->passes())
{
$tags = array();
foreach (explode(', ', Input::get('tags')) as $tag_name) {
if ($tag = Tag::where('name', '=', $tag_name)->first()) {
$tags[] = $tag->id;
}
}
if (count($tags) == 0) {
return Redirect::route('offers.create')
->withInput()
->with('message', 'Insert at least one tag.');
}
$offer = $this->offer->create(Input::except('tags', 'file'));
$offer->tags()->sync($tags);
return Redirect::route('offers.index');
}
return Redirect::route('offers.create')
->withInput()
->withErrors($validation)
->with('message', 'There were validation errors.');
}
...
Во первых, расширим правило expires, что бы скидка заканчивалась не раньше завтрашнего дня, и не позже, чем через 1 месяц. Далее выделим все id
тегов в отдельный массив, проверив их наличие в БД. После идет небольшая проверка, введены ли теги. А под самый конец очень интересный прием: в Eloquent для связки таблиц можна использовать разные отношения (Eloquent Relationships [36]), к примеру, у Модели Offers может быть много тегов, соответсвенно пропишем это в Модели
// app/models/Offer.php
...
public function tags()
{
return $this->belongsToMany('Tag');
}
...
Таким образом мы создали связь между одной записью в таблице offers и многими записями в таблице tags. Теперь, обращаясь к методу $offer->tags()
мы можем получить все теги, к которым привязана конкретная скидка. Но в данном примере у нас еще используется специальный метод для работы с промежуточными таблицами sync(array(1, 2, 3))
, который запишет в промежуточную таблицу к offer_id
нужные tag_id
. Таблица offer_tag:
Также нам нужно указать связь между записью в таблице offers и записями в таблицах cities и companies:
// app/models/Offer.php
...
public function city()
{
return $this->belongsTo('City');
}
public function company()
{
return $this->belongsTo('Company');
}
public function tags()
{
return $this->belongsToMany('Tag');
}
// Функция для сокращения текста с сохранением целосности слов + вывод с переносом строки
public function webDescription($options = array())
{
$str = $this->description;
if (isset($options['shorten'])) {
$length = isset($options['length']) ? (int) $options['length'] : 250;
$end = isset($options['end']) ? : '…';
if (mb_strlen($str) > $length) {
$str = mb_substr(trim($str), 0, $length);
$str = mb_substr($str, 0, mb_strlen($str) - mb_strpos(strrev($str), ' '));
$str = trim($str.$end);
}
}
$str = str_replace("rn", '<br>', e($str));
return $str;
}
}
Осталось изменить файл app/views/offers/index.blade.php
// app/views/offers/index.blade.php
@if ($offers->count())
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>Title</th>
<th>Description</th>
<th>City</th>
<th>Company</th>
<th>Off</th>
<th>Image</th>
<th>Tags</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
@foreach ($offers as $offer)
<tr>
<td>{{{ $offer->title }}}</td>
<td>{{ $offer->webDescription(array('shorten' => true, 'length' => 60)) }}</td>
<td>{{{ $offer->city->name }}}</td>
<td>{{{ $offer->company->name }}}</td>
<td>{{{ $offer->off }}}</td>
<td><img src="{{{ $offer->image }}}" style="max-width: 200px; max-height:150px;"></td>
<td>
@foreach($offer->tags as $tag)
<span class="badge">{{{$tag->name}}}</span>
@endforeach
</td>
<td>{{{ $offer->expires }}}</td>
<td>
{{ link_to_route('offers.edit', 'Edit', array($offer->id), array('class' => 'btn btn-info')) }}
</td>
<td>
{{ Form::open(array('method' => 'DELETE', 'route' => array('offers.destroy', $offer->id))) }}
{{ Form::submit('Delete', array('class' => 'btn btn-danger')) }}
{{ Form::close() }}
</td>
</tr>
@endforeach
</tbody>
</table>
@else
There are no offers
@endif
И мы видим отличную картину, которая полностью отображает структуру скидки:
{{{ $string }}} выводит содержимое $string, предварительно прогнав через
htmlentities
, то бишь конвертирует не безопасные символы, что защищает от XSS. Аналогом является<?php echo htmlentities($string); ?>
или вспомогательной функцииLaravel
e($string)
Теперь осталось изменить app/views/offers/edit.blade.php, app/views/offers/show.blade.php и метод update
в app/controllers/OfferController.php.
Код для app/views/edit.blade.php
// app/views/offers/edit.blade.php
@extends('layouts.scaffold')
@section('main')
<h1>Edit Offer</h1>
{{ Form::model($offer, array('method' => 'PATCH', 'route' => array('offers.update', $offer->id))) }}
<ul>
<li>
{{ Form::label('title', 'Title:') }}
{{ Form::text('title') }}
</li>
<li>
{{ Form::label('description', 'Description:') }}
{{ Form::textarea('description') }}
</li>
<?php $cities = array(0 => 'Choose city');
foreach (City::get(array('id', 'name')) as $city) {
$cities[$city->id] = $city->name;
} ?>
<li>
{{ Form::label('city_id', 'City_id:') }}
{{ Form::select('city_id', $cities) }}
</li>
<?php $companies = array(0 => 'Choose company');
foreach (Company::get(array('id', 'name')) as $company) {
$companies[$company->id] = $company->name;
} ?>
<li>
{{ Form::label('company_id', 'Company_id:') }}
{{ Form::select('company_id', $companies) }}
</li>
<li>
{{ Form::label('off', 'Off:') }}
{{ Form::input('number', 'off') }}
</li>
<li>
{{ Form::label('file', 'Image:') }}
{{ Form::file('file')}}
<img src="{{Input::old('image', $offer->image)}}" id="thumb" style="max-width:300px; max-height: 200px; display:block; ">
{{ Form::hidden('image') }}
<div class="error"></div>
</li>
<li>
{{ Form::label('expires', 'Expires:') }}
{{ Form::text('expires') }}
</li>
<li>
{{ Form::label('tags', 'Tags:') }}
{{ Form::text('tags', Input::old('tags', implode(', ', array_fetch($offer->tags()->get(array('name'))->toArray(), 'name')))) }}
</li>
<li>
{{ Form::submit('Update', array('class' => 'btn btn-info')) }}
{{ link_to_route('offers.show', 'Cancel', $offer->id, array('class' => 'btn')) }}
</li>
</ul>
{{ Form::close() }}
@if ($errors->any())
<ul>
{{ implode('', $errors->all('<li class="error">:message</li>')) }}
</ul>
@endif
@stop
@section('scripts')
@include('offers.scripts')
@stop
Изменения очень схожы с app/views/offers/create.blade.php, только есть небольшая разница в <img src="{{Input::old('image', $offer->image)}}">
и {{ Form::text('tags', ... }}
. С картинкой все понятно: если есть старый инпут — заменяем на него, если его нет — то на значение image
нашей скидки. В Form::text('tags', ... )
мы, во первых, взяли все теги, которые относятся к конкретной скидке $offer->tags()
и выняли из БД только поля name
. Далее воспользовались вспомогательной функцией от Laravel
array_fetch [37], что бы у нас получился одномерный массив, а в конце соединили этот массив в строку, вставив запятую и пробел между ними.
Изменим метод update
в OfferController
:
// app/controllers/OfferController.php
class OffersController extends BaseController {
...
public function update($id)
{
$offer = $this->offer->findOrFail($id);
$rules = Offer::$rules;
$rules['expires'] .= '|after:'.date('Y-m-d', strtotime('+1 day')).'|before:'.date('Y-m-d', strtotime('+1 month'));
$validation = Validator::make(Input::all(), $rules);
if ($validation->passes())
{
$tags = array();
foreach (explode(', ', Input::get('tags')) as $tag_name) {
if ($tag = Tag::where('name', '=', $tag_name)->first()) {
$tags[] = $tag->id;
}
}
if (count($tags) == 0) {
return Redirect::route('offers.create')
->withInput()
->withErrors($validation)
->with('message', 'Insert at least one tag.');
}
$offer->update(Input::except('tags', 'file', '_method'));
$offer->tags()->sync($tags);
return Redirect::route('offers.show', $id);
}
return Redirect::route('offers.edit', $id)
->withInput()
->withErrors($validation)
->with('message', 'There were validation errors.');
}
...
Различие с методом добавления минимальны. Во первых, выбросим 404 ошибку, если задан неправильный id
, во вторых будем использовать метод update($id)
. Вот и все изменения.
Далее изменим файл app/views/offers/show.blade.php:
// app/views/offers/show.blade.php
...
<thead>
<tr>
<th>Title</th>
<th>Description</th>
<th>City_id</th>
<th>Company_id</th>
<th>Off</th>
<th>Image</th>
<th>Tags</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{{ $offer->title }}}</td>
<td>{{ $offer->webDescription(array('shorten' => true, 'length' => 60)) }}</td>
<td>{{{ $offer->city->name }}}</td>
<td>{{{ $offer->company->name }}}</td>
<td>{{{ $offer->off }}}</td>
<td><img src="{{{ $offer->image }}}" style="max-width: 200px; max-height:150px;"/></td>
<td>
@foreach($offer->tags as $tag)
<span class="badge">{{{ $tag->name }}}</span>
@endforeach
</td>
<td>{{{ $offer->expires }}}</td>
...
Теперь и после изменения скидки у нас будет красиво выводится ее структура с изображением и всеми реляционными данными.
Настало время наконец то для создания главной страницы сайта.
Для начала создадим новый layout
:
// app/views/layouts/main.blade.php
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.1/css/bootstrap-combined.min.css" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="{{ asset('css/main.css') }}">
@yield('styles')
</head>
<body>
<div class="navbar navbar-fixed-top">
<div class="navbar-inner">
<div class="container">
<a class="brand" href="{{ route('home') }}">Habr Offers</a>
<ul class="nav">
<li><a href="{{ route('home') }}">Home</a></li>
</ul>
</div>
</div>
</div>
<div class="container">
@if (Session::has('message'))
<div class="flash alert">
<p>{{ Session::get('message') }}</p>
</div>
@endif
@yield('main')
</div>
<script type="text/javascript" src="//code.jquery.com/jquery.min.js"></script>
<script type="text/javascript" src="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.1/js/bootstrap.min.js"></script>
@yield('scripts')
</body>
</html>
А так же файл стилей:
// public/css/main.css
/* Так как у нас статичное верхнее меню - сделаем отступ от верха */
body {padding-top: 60px;}
/* Для ссылок, на которых не нужно подчеркивание */
.no_decoration:hover, .no_decoration:focus {text-decoration: none;}
/* Выравнивание по высоте всех скидок вне зависимости от количества текста / изображения */
.thumbnail .image-container {width: 100%; max-height: 200px; overflow: hidden;}
.thumbnail .image-container img {min-width: 100%; min-height: 100%;}
.thumbnail h3 {height: 40px; overflow: hidden;}
.thumbnail .description {height: 100px; overflow: hidden;}
Потом переопределим маршрут главной страницы:
// app/routes.php
Route::get('/', array('as' => 'home', 'uses' => 'HomeController@index'));
Добавим в HomeController
недостающий метод index
:
// app/controllers/HomeController.php
...
/**
* Display a listing of offers.
*
* @return Response
*/
public function index()
{
$offers = Offer::orderBy('created_at', 'desc')->get();
return View::make('home.index', compact('offers'));
}
...
Создадим папку app/views/homeи добавим туда файл index.blade.php, а так же создадим файл _preview.blade.php в папке app/views/offers
// app/views/home/index.blade.php
@extends('layouts.main')
@section('main')
<h1>{{ $title }}</h1>
@if ($offers->count())
@foreach ($offers as $key => $offer)
@if($key % 3 == 0)
<div class="row-fluid">
<ul class="thumbnails">
@endif
<li class="span4">
<div class="thumbnail">
@include('offers._preview', $offer)
</div>
</li>
@if($key % 3 == 2 || $key == count($offers) - 1)
</ul>
</div>
@endif
@endforeach
@else
There are no offers
@endif
@stop
// app/views/offers/_preview.blade.php
<div class="image-container">
<img src="{{{ $offer->image }}}">
</div>
<div class="caption">
<h3>{{{ $offer->title }}}</h3>
<hr>
<p class="description">{{ $offer->webDescription() }}</p>
<hr>
<p><span class="label label-important">{{{ $offer->off }}} % off</span></p>
<p>Location: {{{ $offer->city->name }}}</p>
<p>Offer by: {{{ $offer->company->name }}}</p>
<p>Expires on: <span class="label label-warning">{{{ $offer->expires }}}</span></p>
<p>Tags:
@foreach($offer->tags as $tag)
<span class="badge">{{{$tag->name}}}</span>
@endforeach
</p>
</div>
Далее нужно добавить поиск скидок по тегам, городам и компаниям. Для этого добавим 3 маршрута в файл app/routes.php сразу же за home
:
// app/routes.php
...
Route::get('by_tag/{name}', array('as' => 'home.by_tag', 'uses' => 'HomeController@byTag'))->where('name', '[A-Za-z0-9 -_]+');
Route::get('by_city/{name}', array('as' => 'home.by_city', 'uses' => 'HomeController@byCity'))->where('name', '[A-Za-z0-9 -_]+');
Route::get('by_company/{name}', array('as' => 'home.by_company', 'uses' => 'HomeController@byCompany'))->where('name', '[A-Za-z0-9 -_]+');
...
Теперь добавим недостающие методы в HomeController
:
// app/controllers/HomeController.php
...
/**
* Display a listing of offers that belongs to tag.
*
* @param string $name
* @return Response
*/
public function byTag($name)
{
$tag = Tag::whereName($name)->firstOrFail();
$offers = $tag->offers;
$title = "Offers tagged as: " . $tag->name;
return View::make('home.index', compact('offers', 'title'));
}
/**
* Display a listing of offers that belongs to city.
*
* @param string $name
* @return Response
*/
public function byCity($name)
{
$city = City::whereName($name)->firstOrFail();
$offers = $city->offers;
$title = "Offers in: " . $city->name;
return View::make('home.index', compact('offers', 'title'));
}
/**
* Display a listing of offers that belongs to company.
*
* @param string $name
* @return Response
*/
public function byCompany($name)
{
$company = Company::whereName($name)->firstOrFail();
$offers = $company->offers;
$title = "Offers by: " . $company->name;
return View::make('home.index', compact('offers', 'title'));
}
...
Для корректной работы этих методов нам нужно задать связи в Моделях City
, Company
и Tag
:
// app/models/City.php
...
public function offers()
{
return $this->hasMany('Offer');
}
// app/models/Company.php
...
public function offers()
{
return $this->hasMany('Offer');
}
// app/models/Tag.php
...
public function offers()
{
return $this->belongsToMany('Offer');
}
Что бы все это дело заиграло, изменим файл app/views/offers/_preview.blade.php, добавив ссылок:
// app/views/offers/_preview.blade.php
<a class="image-container" href="{{ route('home.offer', $offer->id) }}">
<img src="{{{ $offer->image }}}">
</a>
<div class="caption">
<h3>{{{ $offer->title }}}</h3>
<hr>
<p class="description">{{ $offer->webDescription() }}</p>
<hr>
<p><span class="label label-important">{{{ $offer->off }}} % off</span></p>
<p>Location: <a href="{{ route('home.by_city', $offer->city->name) }}">{{{ $offer->city->name }}}</a></p>
<p>Offer by: <a href="{{ route('home.by_company', $offer->company->name) }}">{{{ $offer->company->name }}}</a></p>
<p>Expires on: <span class="label label-warning">{{{ $offer->expires }}}</span></p>
<p>Tags:
@foreach($offer->tags as $tag)
<a class="no_decoration" href="{{ route('home.by_tag', $tag->name) }}">
<span class="badge">{{{$tag->name}}}</span>
</a>
@endforeach
</p>
</div>
Кликаем, переходим, скидки сортируются и выводятся в соответствии с критериями.
Теперь сделаем представление для просмотра отдельной скидки:
// app/views/offers/_show.blade.php
@extends('layouts.main')
@section('main')
<div class="page-header">
<h1>
<span class="label label-important label-big">{{{ $offer->off }}}%</span>
{{{ $offer->title }}}
<small> by
<a href="{{{ route('home.by_company', $offer->company->name) }}}">{{{ $offer->company->name }}}</a>
</small>
</h1>
</div>
<div class="pull-left image-container-big">
<img class="img-rounded" src="{{{ $offer->image }}}" alt="{{{ $offer->title }}}">
</div>
<div class="description">
<p>{{ $offer->webDescription() }}</p>
</div>
<div class="clearfix"></div>
<hr>
<p>Location:
<a href="{{ route('home.by_city', $offer->city->name) }}">{{{ $offer->city->name }}}</a>
</p>
<p>Tags:
@foreach($offer->tags as $tag)
<a class="no_decoration" href="{{ route('home.by_tag', $tag->name) }}">
<span class="badge">{{{$tag->name}}}</span>
</a>
@endforeach
</p>
<hr>
<div class="page-header">
<h3>User's comments <small>leave and yours one</small></h3>
</div>
{{ Form::open() }}
{{ Form::textarea('body', Input::old('body'), array('class' => 'input-block-level', 'style' => 'resize: vertical;'))}}
<div class="input-append">
{{ Form::select('mark', array(0 => 5, 1 => 4, 2 => 3, 3 => 2, 4 => 1), Input::old('mark', 0)) }}
{{ Form::submit('Comment', array('class' => 'btn btn-success', 'style' => 'clear: both;')) }}
</div>
{{ Form::close() }}
@include('partials.errors', $errors)
@stop
// public/css/main.css Теперь выглядит так
body {padding-top: 60px;}
.error {color: red;}
.no_decoration:hover, .no_decoration:focus {text-decoration: none;}
.thumbnail .image-container {width: 100%; max-height: 200px; overflow: hidden; display: block;}
.thumbnail .image-container img {min-width: 100%; min-height: 100%;}
.thumbnail h3 {height: 40px; overflow: hidden;}
.thumbnail .description {height: 100px; overflow: hidden;}
.image-container-big {width: 500px; height: 300px; margin: 0 20px 20px 0; text-align: center;}
.image-container-big img {max-height: 300px; margin: 0 auto;}
.label.label-big {font-size: 32px; line-height: 1.5em; padding: 0 15px; margin-bottom: 5px;}
Для того, чтобы можно было просматривать скидку полностью, добавим маршрут и метод, а так же в конце я добавил форму для комментариев. Для ее работоспособности также нужно добавить маршрут и метод в нужном контроллере:
// app/routes.php
...
Route::get('offer_{id}', array('as' => 'home.offer', 'uses' => 'HomeController@showOffer'))->where('id', '[0-9]+');
Route::post('offer_{id}', array('before' => 'not_guest', 'uses' => 'HomeController@commentOnOffer'))->where('id', '[0-9]+');
...
Route::filter('not_guest', function(){
if (Auth::guest()) {
return Redirect::back()->withInput()->with('message', 'You should be logged in to provide this action.');
}
});
// app/controllers/HomeController.php
...
/**
* Display an offer.
*
* @param int $id
* @return Response
*/
public function showOffer($id)
{
$offer = Offer::findOrFail($id);
return View::make('offers._show', compact('offer'));
}
/**
* Storing comment on offer.
*
* @param int $id
* @return Response
*/
public function commentOnOffer($id)
{
$offer = Offer::findOrFail($id);
if ($offer->usersComments->contains(Auth::user()->id)) {
return Redirect::back()->withInput()->with('message', 'You have already commented on this Offer');
}
$rules = array('body' => 'required|alpha|min:10|max:500', 'mark' => 'required|numeric|between:1,5');
$validator = Validator::make(Input::all(), $rules);
if ($validator->passes()) {
$offer->usersComments()->attach(Auth::user()->id, array('body' => Input::get('body'), 'mark' => Input::get('mark')));
return Redirect::back();
}
return Redirect::back()->withInput()->withErrors($validator);
}
...
Разберемся со всем по порядку:
Route::post('/offer_{id}'...)
использует новый фильтр, который без авторизации выдает кастомное сообщение.showOffer($id)
тоже ничего сложного из себя не представляет.id
нам передали.
Далее идет работа с промежуточной таблицей offers
для скидки и пользователя. Эту связь нужно указать в Модели Offer
// app/models/Offer.php
...
public function usersComments()
{
return $this->belongsToMany('User', 'comments')->withPivot('body', 'mark')->withTimestamps();
}
...
Как видите, мы тут явно задаем таблицу comments
как промежуточную, и указываем, что так же в этой таблице содержатся дополнительные колонки body
и mark
+ в этой таблице используются штампы времени (создания и обновления).
Используя проверку, есть ли уже комментарий к конкретной скидке от текущего пользователя (метод contains() [38]), перенаправляем обратно. Если же нет — то прикрепляем новый комментарий от пользователя к скидке с его оценкой и текстом.
Для вывода комментариев на странице скидки изменим немного файл app/views/offers/_show.blade.php
// app/views/offers/_show.blade.php
...
@if(!$offer->usersComments->count())
<div class="well">You can be first to comment on this offer!</div>
@endif
@if(Auth::guest() || (!Auth::guest() && !$offer->usersComments->contains(Auth::user()->id)))
{{ Form::open() }}
{{ Form::textarea('body', Input::old('body'), array('class' => 'input-block-level', 'style' => 'resize: vertical;'))}}
<div class="input-append">
{{ Form::select('mark', array(5 => 5, 4 => 4, 3 => 3, 2 => 2, 1 => 1), Input::old('mark', 5)) }}
{{ Form::submit('Comment', array('class' => 'btn btn-success', 'style' => 'clear: both;')) }}
</div>
{{ Form::close() }}
@include('partials.errors', $errors)
@endif
@foreach($offer->usersComments as $user)
<div class="media">
<a class="pull-left" href="#">
<img class="media-object" data-src="holder.js/64x64">
</a>
<div class="media-body">
<h4 class="media-heading">{{{ $user->username }}} <span class="label label-success">mark: {{{ $user->pivot->mark }}}</span></h4>
<p class="muted">{{ str_replace("rn", '<br>', e($user->pivot->body)) }}</p>
</div>
</div>
@endforeach
@stop
Теперь под скидками пользователи могут оставлять свои комментарии, каждый по одному, и если пользователь уже оставил комментарий — то форма не будет для него выводиться.
Следующим шагом будет распределить права доступа к сайту. Для начала укажем связь между пользователями и ролями:
// app/models/User.php
...
public function roles()
{
return $this->belongsToMany('Role');
}
...
Далее добавим в админке управление ролями пользователей:
// app/routes.php
...
Route::group(array('before' => 'admin.auth'), function()
{
...
Route::resource('users', 'UsersController');
Route::post('upload', array('uses' => 'HomeController@uploadOfferImage'));
});
...
// app/views/layouts/scaffold.blade.php
...
<li>{{ link_to_route('users.index', 'Users') }}</li>
<li class="pull-right">{{ link_to_route('login.logout', 'Logout') }}</li>
...
Помним, что в Модель User
нужно добавить связь с ролями:
// app/models/User.php
...
public function roles()
{
return $this->belongsToMany('Role');
}
...
Создадим контроллер UserController
:
// app/controllers/UsersController.php
class UsersController extends BaseController {
/**
* User Repository
*
* @var User
*/
protected $user;
public function __construct(User $user)
{
$this->user = $user;
}
/**
* Display a listing of the resource.
*
* @return Response
*/
public function index()
{
$users = $this->user->all();
return View::make('users.index', compact('users'));
}
/**
* Display the specified resource.
*
* @param int $id
* @return Response
*/
public function show($id)
{
$user = $this->user->findOrFail($id);
return View::make('users.show', compact('user'));
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return Response
*/
public function edit($id)
{
$user = $this->user->findOrFail($id);
return View::make('users.edit', compact('user'));
}
/**
* Update the specified resource in storage.
*
* @param int $id
* @return Response
*/
public function update($id)
{
$user = $this->user->findOrFail($id);
$roles = array();
foreach (explode(', ', Input::get('roles')) as $role_name) {
if ($role = Role::where('role', '=', $role_name)->first()) {
$roles[] = $role->id;
}
}
$user->roles()->sync($roles);
return Redirect::route('users.show', $id);
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return Response
*/
public function destroy($id)
{
$this->user->findOrFail($id)->delete();
return Redirect::route('users.index');
}
}
Создадим папку app/views/users и добавим туда 3 файла:
// app/views/users/index.blade.php
@extends('layouts.scaffold')
@section('main')
<h1>All Users</h1>
@if ($users->count())
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Roles</th>
</tr>
</thead>
<tbody>
@foreach ($users as $user)
<tr>
<td>{{{ $user->username }}}</td>
<td>{{{ $user->email }}}</td>
<td>
@foreach($user->roles as $role)
<span class="badge">{{{$role->role}}}</span>
@endforeach
</td>
<td>{{ link_to_route('users.edit', 'Edit', array($user->id), array('class' => 'btn btn-info')) }}</td>
<td>
{{ Form::open(array('method' => 'DELETE', 'route' => array('users.destroy', $user->id))) }}
{{ Form::submit('Delete', array('class' => 'btn btn-danger')) }}
{{ Form::close() }}
</td>
</tr>
@endforeach
</tbody>
</table>
@else
There are no users
@endif
@stop
// app/views/users/show.blade.php
@extends('layouts.scaffold')
@section('main')
<h1>Show User</h1>
<p>{{ link_to_route('users.index', 'Return to all users') }}</p>
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Roles</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{{ $user->username }}}</td>
<td>{{{ $user->email }}}</td>
<td>
@foreach($user->roles as $role)
<span class="badge">{{{ $role->role }}}</span>
@endforeach
</td>
<td>{{ link_to_route('users.edit', 'Edit', array($user->id), array('class' => 'btn btn-info')) }}</td>
<td>
{{ Form::open(array('method' => 'DELETE', 'route' => array('users.destroy', $user->id))) }}
{{ Form::submit('Delete', array('class' => 'btn btn-danger')) }}
{{ Form::close() }}
</td>
</tr>
</tbody>
</table>
@stop
// app/views/users/edit.blade.php
@extends('layouts.scaffold')
@section('main')
<h1>Edit User</h1>
{{ Form::model($user, array('method' => 'PATCH', 'route' => array('users.update', $user->id))) }}
<ul>
<li>
{{ Form::label('username', 'Username:') }}
{{ Form::text('username', $user->username, array('disabled')) }}
</li>
<li>
{{ Form::label('email', 'Email:') }}
{{ Form::text('email', $user->email, array('disabled')) }}
</li>
<li>
{{ Form::label('roles', 'Roles:') }}
{{ Form::text('roles', Input::old('roles', implode(', ', array_fetch($user->roles()->get(array('role'))->toArray(), 'role')))) }}
</li>
<li>
{{ Form::submit('Update', array('class' => 'btn btn-info')) }}
{{ link_to_route('users.show', 'Cancel', $user->id, array('class' => 'btn')) }}
</li>
</ul>
{{ Form::close() }}
@if ($errors->any())
<ul>
{{ implode('', $errors->all('<li class="error">:message</li>')) }}
</ul>
@endif
@stop
@section('scripts')
<script>
$(document).ready(function(){
function split( val ) {
return val.split( /,s*/ );
}
function extractLast( term ) {
return split( term ).pop();
}
$( "#roles" )
// don't navigate away from the field on tab when selecting an item
.bind( "keydown", function( event ) {
if ( event.keyCode === $.ui.keyCode.TAB &&
$( this ).data( "ui-autocomplete" ).menu.active ) {
event.preventDefault();
}
})
.autocomplete({
source: function( request, response ) {
$.getJSON( "/roles", {
term: extractLast( request.term ),
},
function( data ) {
response($.map(data, function(item) {
return {
value: item.role
}
}))
}
);
},
search: function() {
// custom minLength
var term = extractLast( this.value );
if ( term.length < 2 ) {
return false;
}
},
focus: function() {
// prevent value inserted on focus
return false;
},
select: function( event, ui ) {
console.log(ui);
console.log(this);
var terms = split( this.value );
// remove the current input
terms.pop();
// add the selected item
terms.push( ui.item.value );
// add placeholder to get the comma-and-space at the end
terms.push( "" );
this.value = terms.join( ", " );
return false;
}
});
});
</script>
@stop
А так же изменим немного метд index
контроллера RolesController
...
public function index()
{
$roles = $this->role->all();
if (Request::ajax()) {
$roles = Role::where('role', 'like', '%'.Input::get('term', '').'%')->get(array('id', 'role'));
return $roles;
}
return View::make('roles.index', compact('roles'));
}
...
Теперь автодополнение работает.
Далее, для того, что бы у нас с вами не было разбежностей, откатим все миграции и воспользуемся отличным инструментом, который нам предоставляет Laravel
— это DatabaseSeeder [39]. С помощью него мы можем наполнить нашу БД какими-то конфигурационными, или стартовыми / тестовыми данными. Для этого сначала создадим класс UsersTableSeeder
в папке app/database/seeds:
// app/database/seeds/UsersTableSeeder.php
class UsersTableSeeder extends Seeder {
public function run()
{
$users = array(
array(
'username' => 'habrahabr',
'email' => 'habrahabr@habr.com',
'password' => Hash::make('habr'),
'updated_at' => DB::raw('NOW()'),
'created_at' => DB::raw('NOW()'),
)
);
DB::table('users')->insert($users);
}
}
Логика такова: очищаем таблицу, создаем массив данных и вставляем в БД.
Проделаем то же самое с RolesTableSeeder
:
// app/database/seeds/RolesTableSeeder.php
class RolesTableSeeder extends Seeder {
public function run()
{
$roles = array(
array(
'role' => 'admin',
'updated_at' => DB::raw('NOW()'),
'created_at' => DB::raw('NOW()')
),
array(
'role' => 'manager',
'updated_at' => DB::raw('NOW()'),
'created_at' => DB::raw('NOW()')
),
array(
'role' => 'moderator',
'updated_at' => DB::raw('NOW()'),
'created_at' => DB::raw('NOW()')
)
);
DB::table('roles')->insert($roles);
}
}
Здесь я так же добавил роли manager
и moderator
, что бы давать пользователям с этими ролями доступ к отдельным ресурсам в админ панели.
Далее создадим еще один класс Seeder
:
// app/database/seeds/RoleUserTableSeeder.php
class RoleUserTableSeeder extends Seeder {
public function run()
{
// Uncomment the below to wipe the table clean before populating
DB::table('role_user')->truncate();
$role_user = array(
array('user_id' => 1, 'role_id' => 1)
);
// Uncomment the below to run the seeder
DB::table('role_user')->insert($role_user);
}
}
Таким образом мы добавили роль admin
нашему первому пользователю.
Чтобы очистить БД и заполнить ее нашими начальными данными сначала изменим файл app/database/seeds/DatabaseSeeder.php таким образом:
// app/database/seeds/DatabaseSeeder
class DatabaseSeeder extends Seeder {
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Eloquent::unguard();
// Вызовы на выполнение конкретных классов для наполнения БД
$this->call('UsersTableSeeder');
$this->call('RolesTableSeeder');
$this->call('RoleUserTableSeeder');
}
}
И для принятия всех изменений запустим через консоль команду (находясь в папке /workspace/php/habr/):
php artisan migrate:refresh --seed
migrate:refresh
откатит все миграции, а потом их снова запустит, а опция --seed
укажет на то, что так же нужно запустить DatabaseSeeder
.
Далее выстроим логику на права. Внесем изменения в Модель User
:
// app/models/User.php
...
public function isAdmin()
{
$admin_role = Role::whereRole('admin')->first();
return $this->roles->contains($admin_role->id);
}
...
public function isManager()
{
$manager_role = Role::whereRole('manager')->first();
return $this->roles->contains($manager_role->id) || $this->isAdmin();
}
...
public function isModerator()
{
$admin_role = Role::whereRole('admin')->first();
return $this->roles->contains($admin_role->id) || $this->isAdmin();
}
...
public function isRegular()
{
$roles = array_filter($this->roles->toArray());
return empty($roles);
}
}
Далее изменим файл маршрутов, что бы он соответствовал правам пользования сайтом:
// app/routes.php
...
Route::post('offer_{id}', array('before' => 'not_guest|regular_user', 'uses' => 'HomeController@commentOnOffer'))->where('id', '[0-9]+');
...
Route::group(array('before' => 'admin.auth'), function()
{
Route::get('dashboard', function()
{
return View::make('dasboard');
});
Route::group(array('before' => 'manager_role_only'), function()
{
Route::resource('cities', 'CitiesController');
Route::resource('companies', 'CompaniesController');
Route::resource('tags', 'TagsController');
Route::resource('offers', 'OffersController');
Route::post('upload', array('uses' => 'HomeController@uploadOfferImage'));
});
Route::resource('comments', 'CommentsController');
Route::group(array('before' => 'manager_role_only'), function()
{
Route::resource('roles', 'RolesController');
Route::resource('users', 'UsersController');
});
});
Route::when('comments*', 'moderator_role_only');
Route::filter('admin_role_only', function()
{
if (Auth::user()->isAdmin()) {
return Redirect::intended('/')->withMessage('You don't have enough permissions to do that.');
}
});
Route::filter('manager_role_only', function()
{
if (!Auth::user()->isManager()) {
return Redirect::intended('/')->withMessage('You don't have enough permissions to do that.');
}
});
Route::filter('moderator_role_only', function()
{
if (!Auth::user()->isModerator()) {
return Redirect::intended('/')->withMessage('YYou don't have enough permissions to do that.');
}
});
Route::filter('admin.auth', function()
{
if (Auth::guest()) {
return Redirect::to('login');
}
});
Route::filter('un_auth', function()
{
if (!Auth::guest()) {
Auth::logout();
}
});
Route::filter('not_guest', function(){
if (Auth::guest()) {
return Redirect::intended('/')->withInput()->with('message', 'You should be logged in to provide this action.');
}
});
Route::filter('regular_user', function(){
if (!Auth::guest()) {
if (!Auth::user()->isRegular()) {
return Redirect::back()->with('message', 'You cannot do that due to your role.');
}
}
});
Как вы заметили, я добавил к маршруту комментирования дополнительный фильт. Таки образом, никто, кроме обычных пользователей сайта не сможет оставлять комментарии к скидкам.
Также тут был использован маршрут Route::when()
— это так называемый шаблонный фильтр (Pattern Filter [31]). Он позволяет первым параметром передать шаблон URL
, вторым — сам фильтр, который нужно применить, а третьим параметром он может принимать массив из HTTP запросов
, к которым нужно применить фильтр.
Изменим метод login()
контроллера LoginController
:
// app/controllers/LoginController.php
...
public function login()
{
if (Auth::attempt(array('email' => Input::get('email'), 'password' => Input::get('password')), true)
|| Auth::attempt(array('username' => Input::get('email'), 'password' => Input::get('password')), true)) {
if (!Auth::user()->isRegular()) {
return Redirect::to('dashboard');
}
return Redirect::intended('/');
}
return Redirect::back()->withInput(Input::except('password'))->with('message', 'Wrong creadentials!');
}
Теперь при входе на сайт обычные пользователи будут попадать на главную страницу, а администраторы, модераторы и менеджеры в админпанель.
Изменим немного навигационное меню для администрации:
// app/views/layouts/scaffold.blade.php
@if(!Auth::guest())
<ul class="nav nav-pills">
@if(Auth::user()->isManager())
<li>{{ link_to_route('offers.index', 'Offers') }}</li>
<li>{{ link_to_route('companies.index', 'Companies') }}</li>
<li>{{ link_to_route('tags.index', 'Tags') }}</li>
<li>{{ link_to_route('cities.index', 'Cities') }}</li>
@endif
@if(Auth::user()->isModerator())
<li>{{ link_to_route('comments.index', 'Comments') }}</li>
@endif
@if(Auth::user()->isAdmin())
<li>{{ link_to_route('roles.index', 'Roles') }}</li>
<li>{{ link_to_route('users.index', 'Users') }}</li>
@endif
<li class="pull-right">{{ link_to_route('login.logout', 'Logout') }}</li>
</ul>
@endif
Отлично — теперь каждой роли будут видны те ресурсы, к которым у них есть доступ.
Важным аспектом для web приложения является отправка почты.
Laravel
использует SwiftMailer
для создания писем (Laravel Mail [40]).
Для начала нужно сконфигурировать настройки отправки почты. В качестве демонстрации для отправки писем я буду использовать свой аккаунт на gmail
, но вы можете пользоваться по сути любым сервисом, который предоставляет возможность отправки почты с его серверов (к примеру Postmarkapp [41]).
Настройка почты:
// app/config/mail.php
...
return array(
...
'driver' => 'smtp',
...
'host' => 'smtp.gmail.com',
...
'port' => 587,
...
'from' => array('address' => 'habrahabr@habr.com', 'name' => 'Habra Offers'),
...
'encryption' => 'tls',
...
'username' => 'mygmailaccount@gmail.com',
...
'password' => 'mypassword',
...
'pretend' => false
);
Параметр pretend
отвечает за то, нужно ли отправлять письма. Если его выставить в true, то оправка писем происходить не будет, но в логах сайта (app/storage/logs) будут сохраняться отчеты об отправке.
Первым делом я хочу, чтобы при регистрации пользователю отправлялось письмо с приветствием, для этого создам шаблон в папке app/views/emails:
// app/views/emails/welcome.blade.php
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8">
</head>
<body>
<h1>Welcome to Habra Offers!</h1>
<div>
We are glad that you are interested in us, {{{ $username }}}!
</div>
</body>
</html>
Далее изменим метод store()
нашего LoginController
:
// app/controllers/LoginController.php
...
$user->save();
Mail::send('emails.welcome', array('username' => $user->username), function($message) use ($user)
{
$message->to($user->email, $user->username)->subject('Welcome to Habra Offers!');
});
Auth::loginUsingId($user->id);
...
Класс Mail [42] для отправки почты использует метод send()
, который принимает три аргумента:
Но приветственное письмо — это не единственный тип писем, который нам нужен. Что если пользователь забыл свой пароль и хочет его восстановить? Для этого Laravel
предоставляет Password Reminders & Reset [43].
Что нам нужно сделать:
cd /workspace/php/habr
php artisan auth:reminders
php artisan migrate
Для восстановления пароля достаточно вызова Password::remind(array('email' => $email))
и письмо с ссылкой на восстановление пароля будет отправлено.
Нам потребуется создать 2 шаблона:
// app/views/auth/remind.blade.php
@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
@extends('layouts.scaffold')
@section('main')
@if (Session::has('error'))
<div class="alert alert-error">
{{ trans(Session::get('reason')) }}
</div>
@endif
<h1>Reset your password</h1>
{{ Form::open() }}
{{ Form::hidden('token', $token) }}
<ul>
<li>
{{ Form::label('email', 'Email')}}
{{ Form::email('email', Input::old('email')) }}
</li>
<li>
{{Form::label('password', 'New password')}}
{{ Form::password('password')}}
</li>
<li>
{{Form::label('password', 'New password confirmation')}}
{{ Form::password('password_confirmation')}}
</li>
</ul>
{{ Form::submit('Reset', array('class' => 'btn'))}}
{{ Form::close() }}
@stop
Функция
trans()
— вспомогательная функция, которая выводит локализированную строку из конфигурации. Можете заглянуть в папку app/lang/en/reminders.php и увидить какие ошибки могут выводиться. Для смены локализации на, допустим, русский язык вам понадобится изменить в файле app/config/app.php значение locale с en на ru и добавить папку app/lang/ru, в которой воссоздать файлы как в папке app/lang/en.
Далее добавим 4 маршрута:
// app/routes.php
...
Route::group(array('before' => 'un_auth'), function()
{
...
Route::get('password/remind', array('as' => 'password.remind', 'uses' => 'LoginController@showReminderForm'));
Route::post('password/remind', array('uses' => 'LoginController@sendReminder'));
Route::get('password/reset/{token}', array('as' => 'password.reset', 'uses' => 'LoginController@showResetForm'));
Route::post('password/reset/{token}', array('uses' => 'LoginController@resetPassword'));
});
...
Для перехода на восстановление так же добавим ссылку на странице логина:
// app/views/login/index.blade.php
...
{{ Form::close() }}
<p>{{ link_to_route('password.remind', 'Forgot password?') }}</p>
...
А так же недостающие методы в LoginController
:
// app/controllers/LoginController.php
...
/**
* Show reminder form.
*
* @return Response
*/
public function showReminderForm()
{
return View::make('auth.remind');
}
/**
* Send reminder email.
*
* @return Response
*/
public function sendReminder()
{
$credentials = array('email' => Input::get('email'));
return Password::remind($credentials, function($message, $user)
{
$message->subject('Password Reminder on Habra Offers');
});
}
/**
* Show reset password form.
*
* @return Response
*/
public function showResetForm($token)
{
return View::make('auth.reset')->with('token', $token);
}
/**
* Reset password.
*
* @return Response
*/
public function resetPassword($token)
{
$credentials = array('email' => Input::get('email'));
return Password::reset($credentials, function($user, $password)
{
$user->password = Hash::make($password);
$user->save();
Auth::loginUsingId($user->id);
return Redirect::home()->with('message', 'Your password has been successfully reseted.');
});
}
Теперь любой пользователь может восстановить свой пароль.
Добавим еще ссылку для входа и регистрации на сайт на главной странице:
// app/views/layouts/main.blade.php
...
<a class="brand" href="{{ route('home') }}">Habr Offers</a>
<ul class="nav">
<li><a href="{{ route('home') }}">Home</a></li>
</ul>
<div class="btn-group pull-right">
@if(Auth::guest())
<a href="{{ route('login.index') }}" class="btn">Login</a>
<a href="{{ route('login.register') }}" class="btn">Register</a>
@else
<a href="{{ route('login.logout') }}" class="btn">Logout</a>
@endif
</div>
...
Для того, что бы ограничить вывод на страницах только тех скидок, которые еще не закончились нам понадобится добавить еще один метод в Модель Offer
:
// app/controllers/Offer.php
...
public function scopeActive($query)
{
return $query->where('expires', '>', DB::raw('NOW()'));
}
public function scopeSortLatest($query, $desc = true)
{
$order = $desc ? 'desc' : 'asc';
return $query->orderBy('created_at', $order);
}
...
Таким образом, мы можем в методе HomeController@index
всего лишь изменить Offer::orderBy('created_at', 'desc')->get()
на Offer::active()->sortLatest()->get()
. Наш новосозданный метод будет добавлять в цепочку условий нужные нам условия. Сделаем так же для методов сортировки по тегам, городам и компаниям.
// app/controllers/HomeController.php
...
public function byTag($name)
{
...
$offers = $tag->offers()->active()->sortLatest()->get();
...
}
Немаловажным аспектом является пагинация. Да, конечно можно слать запросы в БД, получать тысячи строк ответов, и потом их все пихать на страницу. Но это вряд ли чей либо подход. Ограничить количество возвращаемых результатов из БД достаточно просто — в конце запроса нужно использовать метод paginate()
вместо get()
, или all()
. Простой пример:
// app/controllers/HomeController.php
...
public function index()
{
$offers = Offer::active()->sortLatest()->paginate();
...
}
...
// app/views/home/index.blade.php
...
@if ($offers->count())
{{ $offers->links() }}
...
{{ $offers->links() }}
@else
There are no offers
@endif
...
Таким образом на одной странице будут выводиться только 15 результатов, и внизу будут переходы по страницам. Количество результатов легко изменяемо — достаточно передать нужное число в метод, например paginate(1)
даст 1 результат на страницу.
// app/controllers/HomeController.php
...
public function byTag($name)
{
$tag = Tag::whereName($name)->firstOrFail();
$offers = $tag->offers()->active()->sortLatest()->paginate();
$title = "Offers tagged as: " . $tag->name;
return View::make('home.index', compact('offers', 'title'));
}
...
public function byCity($name)
{
$city = City::whereName($name)->firstOrFail();
$offers = $city->offersr()->active()->sortLatest()->paginate();
$title = "Offers in: " . $city->name;
return View::make('home.index', compact('offers', 'title'));
}
...
public function byCompany($name)
{
$company = Company::whereName($name)->firstOrFail();
$offers = $company->offers()->active()->sortLatest()->paginate();
$title = "Offers by: " . $company->name;
return View::make('home.index', compact('offers', 'title'));
}
...
Ничего вроде сложного в этом нет.
Для удобства так же сделаем и в админ панели.
// app/controllers/OffersController
...
/**
* Display a listing of the resource.
*
* @return Response
*/
public function index()
{
$offers = $this->offer->sortLatest()->paginate();
return View::make('offers.index', compact('offers'));
}
...
Последнее, что хочется добавить к сайту — так это вывод последних комментариев на страницах и закладки из скидок, к которым пользователь оставил комментарии.
Начнем с добавления комментариев в каркасе страницы:
// app/views/layouts/main.blade.php
<div class="container">
@if (Session::has('message'))
<div class="flash alert">
{{ Session::get('message') }}
</div>
@endif
<div class="row-fluid">
<div class="span3">
<h2>Last Comments</h2>
@if (count($comments = Comment::take(5)->get()) > 0)
@foreach ($comments as $comment)
@include('partials.comment', $comment)
@endforeach
@else
There are no comments yet
@endif
</div>
<div class="span9">
@yield('main')
</div>
</div>
</div>
А так же создадим сам шаблон comment
:
// app/views/partials/comment.blade.php
<div class="well">
<a href="{{ route('home.offer', $comment->offer_id) }}">
{{ $comment->user->username }}
<span class="label label-success pull-right">mark: {{ $comment->mark }}</span>
</a>
<div>{{ $comment->webBody() }}</div>
</div>
Не забываем добавлять связь между Моделью Comment
User
и Offer
:
// app/models/Comment.php
...
public function user()
{
return $this->belongsTo('User');
}
public function offer()
{
return $this->belongsTo('Offer');
}
public function webBody($options = array())
{
$str = $this->body;
if (isset($options['shorten'])) {
$length = isset($options['length']) ? (int) $options['length'] : 50;
$end = isset($options['end']) ? : '…';
if (mb_strlen($str) > $length) {
$str = mb_substr(trim($str), 0, $length);
$str = mb_substr($str, 0, mb_strlen($str) - mb_strpos(strrev($str), ' '));
$str = trim($str.$end);
}
}
$str = str_replace("rn", '<br>', e($str));
return $str;
}
...
А так же вспомогательная функция для сокращения и избавлением от html-тегов
комментария.
Осталось добавить закладки для пользователя:
// app/routes.php
Route::get('/', array('as' => 'home', 'uses' => 'HomeController@index'));
Route::get('bookmarks', array('before' => 'auth', 'as' => 'home.bookmarks', 'uses' => 'HomeController@bookmarks'));
...
// app/views/layouts/main.blade.php
...
@if(Auth::guest())
<a href="{{ route('login.index') }}" class="btn">Login</a>
<a href="{{ route('login.register') }}" class="btn">Register</a>
@else
<a href="{{ route('home.bookmarks') }}" class="btn">My Bookmarks</a>
<a href="{{ route('login.logout') }}" class="btn">Logout</a>
@endif
...
// app/models/User.php
...
public function usersOffers()
{
return $this->belongsToMany('Offer', 'comments')->withPivot('body', 'mark')->withTimestamps();
}
...
// app/controllers/HomeController.php
...
/**
* Display a listing of bookmarked offers.
*
* @return Response
*/
public function bookmarks()
{
$offers = Auth::user()->usersOffers()->paginate();
$title = "My Bookmarked Offers";
return View::make('home.index', compact('offers', 'title'));
}
...
Для начала мы добавили маршрут в app/route.php, потом добавили ссылку на него в app/views/layouts/main.blade.php, задали связь между Моделью User
и Offer
, а в конце реализовали метод bookmarks
в HomeController
.
Настал час деплоя! Для этого я выбрал fortrabbit.com [44] — PHP
. Он поддерживает Git
, SSH
, Memcached
, Composer
, MySQL
и другое.
Процес регистрации там довольно прост.
Далее создаем новое приложение.
Назовем его habr
. Именем проекта будет ссылка на него habr.eu1.frbit.net/ [3]. Добавим заметку (Habra Offers), и добавим ssh
ключ со своей машины. Чтобы посмотреть свой ssh
ключ введите в терминале:
cat ~/.ssh/id_rsa.pub
Последним этапом будет ожидание конфигурации окружения. Вам сформируются данные для доступа к репозиторию Git
, SSH
и SFTP
, MySQL
настройки и ReSync
доступ.
Окружение запущено и работает.
fortrabbit
замораживает не активные приложения. То, как разморозить приложение можно почитать тут [46].
Теперь для того, чтобы залить наше приложение на fortrabbit
идем в терминал:
cd && cd workspace/php/
git clone git@git1.eu1.frbit.com:habr.git fort_habr
Будет создан клон пустого репозитория с fortrabbit
'a. Далее просто перенесем весь проект с папки workspace/php/habr в папку workspace/php/fort_habr. Зайдем в файл конфигурации БД и исправим на новые данные MySQL
. Теперь мы готовы заливать наше приложение:
cd fort_habr
git add .
git commit -am "Initial Commit"
git push -u origin master
После всего, осталось зайти через ssh
и запустить миграции. Итак:
ssh u-habr@ssh1.eu1.frbit.com
Потом введите свой пароль и вы на сервере.
Перейдите в папку htdocs и выполните:
cd htdocs
php artisan migrate:install
php artisan migrate --seed
Если настройка БД была правильной — никаких проблем возникнуть не должно.
Для работы с Composer
на ssh
— достаточно в коммите добавить такой триггер:
git commit --allow-empty -am "Update dependencies [trigger:composer:update]"
git push -u origin master
Опция --allow-empty
здесь для того, чтобы мы могли запустить сделать коммит, не внося каких-либо изменений в файлах. Как бы пустой коммит. Но увидев в комментарии [trigger:composer:update]
, composer update
, и все зависимости проекта будут обновлены.
Кстати, в своем репозитории на GitHub [2] я добавил еще seeds
и картинки для скидок.
И последнее: прежде, чем переходить на свой сайт убедитесь, что в Domains
на сервере Root Path
соответсвует значению public
. Так как именно таким образом устроен Laravel
.
Поиграться можно тут: Habra Offers [3].
Надеюсь вам было интересно это читать, и полезно это делать. Laravel
— отличный фреймворк для разработки веб приложений разной сложности.
Основные, и даже больше, аспекты я постарался объяснить. И для интереса дам домашнее задание:
WYSIWYG
редактор в админке.Пожалуй неплохие таски, как считаете?
Laravel
с версии 3.Все грамматические ошибки пишите, пожалуйста в личку.
Haters gonna die (Поспорил, что напишу это).
Автор: adacenko
Источник [47]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/framework/45613
Ссылки в тексте:
[1] Laravel: http://laravel.com/
[2] GitHub: https://github.com/andrewdacenko/habrahabr
[3] Приложение: http://habr.eu1.frbit.net/
[4] Composer: http://getcomposer.org/
[5] Composer: http://getcomposer.org/doc/00-intro.md#introduction
[6] Windows: http://getcomposer.org/doc/00-intro.md#installation-windows
[7] WAMP: http://www.wampserver.com/ru/
[8] Git: http://windows.github.com/
[9] http://localhost:8000: http://localhost:8000
[10] Artisan CLI: http://laravel.com/docs/artisan
[11] Миграции: http://laravel.com/docs/migrations
[12] Построитель Схем: http://laravel.com/docs/schema
[13] GitHub: https://github.com/JeffreyWay/Laravel-4-GeneratotHub
[14] Создание миграции: https://github.com/JeffreyWay/Laravel-4-Generators#generating-schema
[15] Типы данных и прочее: http://laravel.com/docs/schema#adding-columns
[16] Eloquent ORM: http://laravel.com/docs/eloquent
[17] REST: http://laravel.com/docs/controllers#resource-controllers
[18] http://localhost:8000/roles: http://localhost:8000/roles
[19] Blade: http://laravel.com/docs/templates#blade-templating
[20] Session: http://laravel.com/docs/session
[21] _token: http://laravel.com/docs/html#csrf-protection
[22] _method: http://laravel.com/docs/html#opening-a-form
[23] Подробнее: http://laravel.com/docs/requests
[24] Basic Input: http://laravel.com/docs/requests#basic-input
[25] bcrypt: http://ru.wikipedia.org/wiki/Bcrypt
[26] Laravel Security: http://laravel.com/docs/security#storing-passwords
[27] Validator: http://laravel.com/docs/validation
[28] Available Validation Rules: http://laravel.com/docs/validation#available-validation-rules
[29] Redirect: http://laravel.com/docs/responses#redirects
[30] Authenticating Users: http://laravel.com/docs/security#authenticating-users
[31] тут: http://laravel.com/docs/routing#route-filters
[32] тут: http://laravel.com/docs/routing#route-groups
[33] Forms & Html (Dropdown Lists): http://laravel.com/docs/html#drop-down-lists
[34] jquery-ui с autocomplete: http://jqueryui.com/autocomplete/#multiple-remote
[35] jqueryui.com: http://jqueryui.com/autocomplete/#multiple
[36] Eloquent Relationships: http://laravel.com/docs/eloquent#relationships
[37] array_fetch: http://laravel.com/docs/helpers#arrays
[38] contains(): http://laravel.com/docs/eloquent#collections
[39] DatabaseSeeder: http://laravel.com/docs/migrations#database-seeding
[40] Laravel Mail: http://laravel.com/docs/mail
[41] Postmarkapp: https://postmarkapp.com/
[42] Mail: http://laravel.com/docs/mail#basic-usage
[43] Password Reminders & Reset: http://laravel.com/docs/security#password-reminders-and-reset
[44] fortrabbit.com: http://fortrabbit.com
[45] хостинг: https://www.reg.ru/?rlink=reflink-717
[46] тут: http://fortrabbit.com/docs/essentials/freeze
[47] Источник: http://habrahabr.ru/post/197454/
Нажмите здесь для печати.