Pull to refresh

Удобная генерация URL (ЧПУ). Laravel 4 + сторонние пакеты

Reading time 10 min
Views 22K
Хотелось бы поделиться удобными инструментами для генерации URL и примерами их использования.

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

Ссылки на инструменты




Этого нам вполне хватит.

Постановка задачи


Автоматическая генерация уникальных URL для записей в таблицу БД для доступа к ним по /resource/unique-resource-url вместо /resource/1.


Приступаем


Допустим, нам нужно разбить поиск на сайте по Странам и Городам, но так, чтобы пользователь легко ориентировался, какая область/город выбран при просмотре списка Продуктов сайта.

Начнем с того, что создадим новый проект:

composer create-project laravel/laravel habr_url --prefer-dist

Далее откываем composer.json в корне habr_url и вносим пакеты в require:

{
    "name": "laravel/laravel",
    "description": "The Laravel Framework.",
    "keywords": ["framework", "laravel"],
    "license": "MIT",
    "require": {
        "laravel/framework": "4.1.*",
        "ivanlemeshev/laravel4-cyrillic-slug": "dev-master",
        "cviebrock/eloquent-sluggable": "1.0.*",
        "way/generators": "dev-master"
    },
    "autoload": {
        "classmap": [
            "app/commands",
            "app/controllers",
            "app/models",
            "app/database/migrations",
            "app/database/seeds",
            "app/tests/TestCase.php"
        ]
    },
    "scripts": {
        "post-install-cmd": [
            "php artisan optimize"
        ],
        "post-update-cmd": [
            "php artisan clear-compiled",
            "php artisan optimize"
        ],
        "post-create-project-cmd": [
            "php artisan key:generate"
        ]
    },
    "config": {
        "preferred-install": "dist"
    },
    "minimum-stability": "dev"
}

"way/generators": "dev-master" добавим для быстрого прототипирования.

После выполняем комманду composer update в консоли, а после успешно установленных пакетов вносим изменения в app/config/app.php:

<?php

return array(
    // ...
    'providers' => array(
        // ...
        'Ivanlemeshev\Laravel4CyrillicSlug\SlugServiceProvider',
        'Cviebrock\EloquentSluggable\SluggableServiceProvider',
        'Way\Generators\GeneratorsServiceProvider',
    ),
    // ...
    'aliases' => array(
        // ...
        'Slug' => 'Ivanlemeshev\Laravel4CyrillicSlug\Facades\Slug',
        'Sluggable' => 'Cviebrock\EloquentSluggable\Facades\Sluggable',
    ),
);
?>

Класс Slug даст нам возможность генерировать URL из киррилицы, так как стандартный класс Str умеет работать только с латиницей. О Sluggable я расскажу чуть позже.

Генерируем код

php artisan generate:scaffold create_countries_table --fields="name:string:unique, code:string[2]:unique"
php artisan generate:scaffold create_cities_table --fields="name:string, slug:string:unique, country_id:integer:unsigned"
php artisan generate:scaffold create_products_table --fields="name:string, slug:string:unique, price:integer, city_id:integer:unsigned"

Изменяем новые файлы, добавляя внешних ключей:

// файл app/database/migrations/ХХХХ_ХХ_ХХ_ХХХХХХ_create_cities_table.php
class CreateCitiesTable extends Migration {
    // ...
    public function up()
    {
        Schema::create('cities', function(Blueprint $table) {
            $table->increments('id');
            $table->string('name');
            $table->string('slug')->unique();
            $table->integer('country_id')->unsigned()->index();
            $table->foreign('country_id')->references('id')->on('countries')->onDelete('cascade');
            $table->timestamps();
        });
    }
    // ...
}

// файл app/database/migrations/ХХХХ_ХХ_ХХ_ХХХХХХ_create_products_table.php
class CreateProductsTable extends Migration {
    // ...
    public function up()
    {
        Schema::create('products', function(Blueprint $table) {
            $table->increments('id');
            $table->string('name');
            $table->string('slug')->unique();
            $table->integer('price');
            $table->integer('city_id')->unsigned()->index();
            $table->foreign('city_id')->references('id')->on('cities')->onDelete('cascade');
            $table->timestamps();
        });
    }
    // ...
}

А так же добавим несколько Стран и Городов в БД через seeds. Открываем папку app/database/seeds и изменяем два файла:

// файл app/database/seeds/CountriesTableSeeder.php
class CountriesTableSeeder extends Seeder {

    public function run()
    {
        $countries = array(
            array('name' => 'Россия',  'code' => 'ru'),
            array('name' => 'Украина', 'code' => 'ua')
        );

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

}

// файл app/database/seeds/CitiesTableSeeder.php
class CitiesTableSeeder extends Seeder {

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

        $cities = array(
            array('name' => 'Москва', 'slug' => Slug::make('Москва'), 'country_id' => 1),
            array('name' => 'Санкт-Петербург', 'slug' => Slug::make('Санкт-Петербург'), 'country_id' => 1),
            array('name' => 'Киев', 'slug' => Slug::make('Киев'), 'country_id' => 2),
        );

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

}

Тут используется Slug::make($input), который принимает $input как строку и генерирует из нее что-то на подобии moskva или sankt-peterburg.

Теперь изменяем настройки БД:

// файл app/config/database.php
return array(
    // ...
    'connections' => array(
        // ...
        'mysql' => array(
            'driver'    => 'mysql',
            'host'      => 'localhost',
            'database'  => 'habr_url',
            'username'  => 'habr_url',
            'password'  => 'habr_url',
            'charset'   => 'utf8',
            'collation' => 'utf8_unicode_ci',
            'prefix'    => '',
        ),
    ),
    // ...
);

И вносим схему и данные в БД.

php artisan migrate --seed

И вот что мы получили:




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

// файл app/models/Product.php
class Product extends Eloquent {
    protected $guarded = array();

    public static $rules = array(
        'name' => 'required|alpha_num|between:2,255',
        'slug' => 'required|alpha_num|between:2,255|unique:products,slug',
        'price' => 'required|numeric|between:2,255',
        'city_id' => 'required|exists:cities,id'
    );

    public function city()
    {
        return $this->belongsTo('City');
    }
}

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

    public static $rules = array(
        'name' => 'required|alpha_num|between:2,255',
        'slug' => 'required|alpha_num|between:2,255|unique:cities,slug',
        'country_id' => 'required|exists:countries,id'
    );

    public function country()
    {
        return $this->belongsTo('Country');
    }

    public function products()
    {
        return $this->hasMany('Product');
    }
}


// файл app/models/Country.php
class Country extends Eloquent {
    protected $guarded = array();

    public static $rules = array(
        'name' => 'required|alpha_num|between:2,255|unique:countries,name',
        'code' => 'required|alpha|size:2|unique:countries,code'
    );

    public function cities()
    {
        return $this->hasMany('City');
    }

    public function products()
    {
        return $this->hasManyThrough('Product', 'City');
    }
}

Перепишем методы store в CitiesController и ProductsController.

// файл app/models/CitiesController.php
class CitiesController extends BaseController {
    // ...
    public function store()
    {
        $input = Input::all();
        $input['slug'] = Slug::make(Input::get('name', '')); // !добавлено
        $validation = Validator::make($input, City::$rules);

        if ($validation->passes())
        {
            $this->product->create($input);

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

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

// файл app/models/ProductsController.php
class ProductsController extends BaseController {
    // ...
    public function store()
    {
        $input = Input::all();
        $input['slug'] = Slug::make(Input::get('name', '')); // !добавлено
        $validation = Validator::make($input, Product::$rules);

        if ($validation->passes())
        {
            $this->product->create($input);

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

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

И уберем из app/views/cities/create.blade.php, app/views/cities/edit.blade.php, app/views/products/create.blade.php, app/views/products/edit.blade.php соответствующие елементы формы.

Отлично, URL генерируются, но что будет в случает с их дублированием? Возникнет ошибка. А чтобы этого избежать — при совпадении slug нам прийдется добавить префикс, а если префикс ужде есть — то инкрементировать его. Работы много, а элегантности нет. Чтобы избежать этих телодвижений воспользуемся пакетом Eloquent Sluggable.

Первым делом скинем себе в проект конфигурацию для Eloquent Sluggable:

php artisan config:publish cviebrock/eloquent-sluggable

В конфигурационном файле, который находится тут app/config/cviebrock/eloquent-sluggable/config.php изменим опцию 'method' => null на 'method' => array('Slug', 'make'). Таким образом, задача перевода из киррилических символов в транслит и создания URL возложится на класс Slug (вместо стандартного Str, который не умеет работать с киррилицей) и его метод make.

Чем хорош этот пакет? Он работает по такому принцыпу: ожидает, события eloquent.saving*, который отвечает за сохранение записи в БД, и записывает в поле, которое указано в настройках Модели сгенерированный slug. Пример конфигурации:

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

    public static $rules = array(
        'name' => 'required|alpha_num|between:2,255',
        'country_id' => 'required|exists:countries,id'
    );

    // Настройка генерации
    public static $sluggable = array(
        'build_from' => 'name',
        'save_to'    => 'slug',
    );

    public function country()
    {
        return $this->belongsTo('Country');
    }

    public function products()
    {
        return $this->hasMany('Product');
    }
}

При совпадении с уже существующим slug, в новый будет добавлен префикс -1, -2, и так далее. К тому же, мы можем избавиться от не нужного правила для slug и в методе CitiesController@store убрать строчку $input['slug'] = Slug::make(Input::get('name', ''));.

То же сделаем и для Product:

// файл app/models/Product.php
class Product extends Eloquent {
    protected $guarded = array();

    public static $rules = array(
        'name' => 'required|alpha_num|between:2,255',
        'price' => 'required|numeric|between:2,255',
        'city_id' => 'required|exists:cities,id'
    );

    public static $sluggable = array(
        'build_from' => 'name',
        'save_to'    => 'slug',
    );

    public function city()
    {
        return $this->belongsTo('City');
    }
}

Еще более интересную вещь мы можем сделать с этим slug, если перепишем $sluggable в Модели City таким образом:

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

    public static $rules = array(
        'name' => 'required|alpha_num|between:2,255',
        'slug' => 'required|alpha_num|between:2,255|unique:cities,slug',
        'country_id' => 'required|exists:countries,id'
    );

    public static $sluggable = array(
        'build_from' => 'name_with_country_code',
        'save_to'    => 'slug',
    );

    public function country()
    {
        return $this->belongsTo('Country');
    }

    public function products()
    {
        return $this->hasMany('Product');
    }

    public function getNameWithCountryCodeAttribute() {
        return $this->country->code . ' ' . $this->name;
    }
}

Да, мы можем выбрать не существующее поле из Объекта, и добавить его как хелпер.

Немного изменив CitiesTableSeeder добъемся желаемого результата:

// файл app/database/seeds/CitiesTableSeeder.php
class CitiesTableSeeder extends Seeder {

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

        $cities = array(
            array('name' => 'Москва', 'country_id' => 1),
            array('name' => 'Санкт-Петербург', 'country_id' => 1),
            array('name' => 'Киев', 'country_id' => 2),
        );

        // Uncomment the below to run the seeder
        foreach ($cities as $city) {
            City::create($city);
        }
    }

}

Теперь откатим миграции и зальем их по новой вместе с данными:

php artisan migrate:refresh --seed




Добавим немного маршрутов:

// файл app/routes.php
// ...
Route::get('country/{code}', array('as' => 'country', function($code)
{
    $country = Country::where('code', '=', $code)->firstOrFail();

    return View::make('products', array('products' => $country->products));
}));

Route::get('city/{slug}', array('as' => 'city', function($slug)
{
    $city = City::where('slug', '=', $slug)->firstOrFail();

    return View::make('products', array('products' => $city->products));
}));

Route::get('product/{slug}', array('as' => 'product', function($slug)
{
    $product = Product::where('slug', '=', $slug)->firstOrFail();

    return View::make('product', compact('product'));
}));

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

<!-- файл app/views/nav.blade.php -->
<ul class="nav nav-pills">
  @foreach(Country::all() as $country)
    <li><a href="{{{ route('country', $country->code) }}}">{{{ $country->name }}}</a>
  @endforeach
</ul>

<!-- файл app/views/products.blade.php -->
@extends('layouts.scaffold')

@section('main')

@include('nav')

<h1>Products</h1>

@if ($products->count())
  <table class="table table-striped table-bordered">
    <thead>
      <tr>
        <th>Name</th>
        <th>Price</th>
        <th>City</th>
      </tr>
    </thead>

    <tbody>
      @foreach ($products as $product)
        <tr>
          <td><a href="{{{ route('product', $product->slug)}}}">{{{ $product->name }}}</a></td>
          <td>{{{ $product->price }}}</td>
          <td><a href="{{{ route('city', $product->city->slug) }}}">{{{ $product->city->name }}}</a></td>
        </tr>
      @endforeach
    </tbody>
  </table>
@else
  There are no products
@endif

@stop

<!-- файл app/views/product.blade.php -->
@extends('layouts.scaffold')

@section('main')

@include('nav')

<h1>Product</h1>

<table class="table table-striped table-bordered">
    <thead>
        <tr>
            <th>Name</th>
            <th>Price</th>
            <th>City</th>
        </tr>
    </thead>

    <tbody>
        <tr>
            <td>{{{ $product->name }}}</td>
            <td>{{{ $product->price }}}</td>
            <td><a href="{{{ route('city', $product->city->slug) }}}">{{{ $product->city->name }}}</a></td>
        </tr>
    </tbody>
</table>

@stop

На этом все.

Демо и Git




Ошибки, как обычно в личку. Предложения и критику — в комментарии. Спасибо за внимание.
Tags:
Hubs:
+7
Comments 10
Comments Comments 10

Articles