This is part five of the Building Your Startup With PHP series on Tuts+. In this series, I'm guiding you through launching a startup from concept to reality using my Meeting Planner app as a real life example. Every step along the way, we'll release the Meeting Planner code as open source examples you can learn from. We'll also address startup-related business issues as they arise.
All of the code for Meeting Planner is written in the Yii2 Framework for PHP. If you'd like to learn more about Yii2, check out our parallel series Programming With Yii2 at Tuts+.
In this tutorial, I'm going to walk you through more features of the Yii Framework that will make our existing place management code more robust. We're going to implement Yii's simple access controls for ensuring that only logged in users add Places. We're going to use Active Record relations so that only places the user has added appear on their view of the place index page. We're also going to use Yii's sluggable behaviors to implement URL-friendly slugs for displaying these places. And we're going to do a bit more cleanup and polish around the Place features.
Just a reminder, I do participate in the comment threads below. I'm especially interested if you have different approaches or additional ideas, or if you want to suggest topics for future tutorials. I also welcome feature requests for Meeting Planner.
Access Control
The code we've written thus far allows anyone to create places even if they haven't signed in. We can use Yii2's simple access control features to ensure that users register and sign in before adding and viewing places.
Yii2 also offers more advanced (and complex) Role Based Access Control (RBAC) which we will not be implementing at this time.
If users are not logged in when they visit the place pages, Yii will redirect them to the login page.
Yii2's built-in Access Control supports only two roles by default: guest (not logged in), represented by '?', and authenticated, represented by '@'. If you're interested, The Code Ninja wrote up a nice example of extending this to support moderators and administrators (Simpler Role Based Authorization in Yii 2.0) without having to resort to using RBAC.
The framework makes it quite simple to implement these controls. We just add behaviors to PlaceController.php which define the access rules for each action, e.g. index, create, view, etc. Here we'll review the access behavior but if you're interested, Yii's verb filters allow you to restrict http operations based on your controller action.
public function behaviors() { return [ 'verbs' => [ 'class' => VerbFilter::className(), 'actions' => [ 'delete' => ['post'], ], ], 'access' => [ 'class' => \yii\filters\AccessControl::className(), 'only' => ['index','create', 'create_geo','create_place_google','update','view','slug'], 'rules' => [ // allow authenticated users [ 'allow' => true, 'roles' => ['@'], ], // everything else is denied ], ], ]; }
Once added, if you click the Places menu, you'll be redirected to the login page:
Yii also handles the redirect back to the index page once login is complete.
When users access the place pages, we can find the current user with this code:
Yii::$app->user->getId();
So, just before new places are saved, we can update the created_by
field to the currently logged in user. And we can trust access controls to ensure the create method is accessed only by authenticated users:
public function actionCreate() { $model = new Place(); if ($model->load(Yii::$app->request->post())) { $form = Yii::$app->request->post(); $model->created_by= Yii::$app->user->getId(); $model->save();
Active Record Relations
In Meeting Planner, I want to track which user first creates a place. This is stored in the created_by
field. We also want to track places that users suggest and use for their meetings, the frequency with which they use places, and their favorites. We use the UserPlace table for this.
Now that we know which user is signed in when we create a place, we want to populate a relational row in the UserPlace table as well.
First, we need to use Yii's code generator, Gii (http://localhost:8888/mp/index.php/gii/model), to create a model for UserPlace:
Then, when a place is successfully added, we want to create a record in the UserPlace table for the active user. We can extend Yii ActiveRecord with the afterSave
method. In the Place model, we add:
public function afterSave($insert,$changedAttributes) { parent::afterSave($insert,$changedAttributes); if ($insert) { $up = new UserPlace; $up->add($this->created_by,$this->id); } }
In the UserPlace model, we add the function:
// add place to user place list public function add($user_id,$place_id) { // check if it exists if (!UserPlace::find() ->where( [ 'user_id' => $user_id, 'place_id' => $place_id ] ) ->exists()) { // if not, add it $up = new UserPlace; $up->user_id =$user_id; $up->place_id=$place_id; $up->save(); } }
When the user visits the place index page, we want to show just the places that they've used, just those in the UserPlace table for that user.
I'm going to walk you through two different ways of accomplishing this. As I'm still gaining experience with Yii2.x as I go along, the specifics of the best approach here were new to me. I want to thank Alex Makarov, a Yii developer who also manages YiiFeed.com, for his assistance. Both he and Yii founder Qiang Xue have been very helpful in answering questions and supporting my efforts with these Yii-related tutorials.
The Simplest Approach
The easiest way is to join the Place table with the UserPlace table on the UserPlace.place_id
property filtering on UserPlace.user_id
with the currently authenticated user.
Here's the default index controller method:
public function actionIndex() { $searchModel = new PlaceSearch(); $dataProvider = $searchModel->search(Yii::$app->request->queryParams); return $this->render('index', [ 'searchModel' => $searchModel, 'dataProvider' => $dataProvider, ]); }
We'll create a new controller method called Yours:
public function actionYours() { $query = Place::find()->joinWith('userPlaces')->where(['user_id' => Yii::$app->user->getId()]); $searchModel = new PlaceSearch(); $dataProvider = new ActiveDataProvider([ 'query' => $query, 'pagination' => ['pageSize' => 10], ]); return $this->render('yours',[ 'dataProvider' => $dataProvider, 'searchModel'=>$searchModel, ]); }
Notice that joinWith('userPlaces')
is using the Gii-generated relational query in Place.php—it can be a bit confusing as you omit the "get" prefix:
/** * @return \yii\db\ActiveQuery */ public function getUserPlaces() { return $this->hasMany(UserPlace::className(), ['place_id' => 'id']); }
We also need to modify the PlaceSearch
class:
// add a required rule for created_by public function rules() { return [ [['created_by'], 'required'], // add the join within the search query public function search($params) { $query = Place::find()->joinWith('user_place')->where(['user_id' => Yii::$app->user->getId()]); $dataProvider = new ActiveDataProvider([ 'query' => $query, ]);
An Alternative Approach
Another way to implement this is through a UserPlace controller. After all, we are viewing "the user's places". In this case we can make a slight modification to the Gii-generated controller index method:
public function actionIndex() { $searchModel = new UserPlaceSearch(); $searchModel->user_id = Yii::$app->user->getId(); $dataProvider = $searchModel->search(Yii::$app->request->queryParams); return $this->render('index', [ 'searchModel' => $searchModel, 'dataProvider' => $dataProvider, ]); }
Then in /views/user-place/index.php
, we need to modify the generated link paths and model data access, e.g. /place/create_place_google
:
<p> <?= Html::a(Yii::t('frontend', 'Create {modelClass}', [ 'modelClass' => 'Place', ]), ['/place/create'], ['class' => 'btn btn-success']) ?> <?= Html::a(Yii::t('frontend','Add Current Location'), ['/place/create_geo'], ['class' => 'btn btn-success']) ?> <?= Html::a(Yii::t('frontend','Add a Google {modelClass}',[ 'modelClass' => 'Place' ]), ['/place/create_place_google'], ['class' => 'btn btn-success']) ?> </p>
In the Grid View widget, we use the UserPlace relation to the Place table to reach properties from the latter model, e.g. $model->place->slug
:
<?= GridView::widget([ 'dataProvider' => $dataProvider, //'filterModel' => $searchModel, 'columns' => [ // ['class' => 'yii\grid\SerialColumn'], [ 'attribute' => 'place_name', 'format' => 'raw', 'value' => function ($model) { return '<div>'.$model->place->name.'</div>'; }, ], [ 'attribute' => 'place_type', 'format' => 'raw', 'value' => function ($model) { return '<div>'.$model->place->getPlaceType($model->place->place_type).'</div>'; }, ], ['class' => 'yii\grid\ActionColumn', 'template'=>'{view} {update} ', 'buttons'=>[ 'view' => function ($url, $model) { return Html::a('<span class="glyphicon glyphicon-eye-open"></span>', Yii::getAlias('@web').'/place/'.$model->place->slug, ['title' => Yii::t('yii', 'View'),]); }, 'update' => function ($url, $model) { return Html::a('<span class="glyphicon glyphicon-pencil"></span>', Yii::getAlias('@web').'/place/update/'.$model->place_id, ['title' => Yii::t('yii', 'Update'),]); } ], ], ], ]); ?>
The UserPlace approach requires some changes to links around breadcrumbs, widget action links and command buttons, but is still fairly simple.
If you review the code in this release, you can see the user's places from both: http://localhost:8888/mp/place/yours and http://localhost:8888/mp/user-place. It's interesting to see how you can accomplish this functionality with two different approaches within Yii.
Slugs
Once you activate pretty URLs in the Yii Framework, the view page for a model object typically is something like http://meetingplanner.com/place/692, where 692 represents the ID of the object to display. Besides being nondescript to the user, it's also less effective with search engines. It's better practice to use URL-friendly strings such as http://meetingplanner.com/place/caffe-seattle. The string is sometimes referred to as a slug. Yii2 provides built-in support for slugs, in the form of Sluggable Behaviors. Behaviors are part of Yii's Active Record support, and they can be applied automatically to data object models.
In our place model, we added a slug property. Here's how we implement Sluggable Behavior in the place model:
public function behaviors() { return [ [ 'class' => SluggableBehavior::className(), 'attribute' => 'name', 'immutable' => true, 'ensureUnique'=>true, ],
Yii will ensure during the save()
operation that the slug field is populated with a URL-friendly version of the name field. In other words, if the place name is Oddfellows Cafe, the slug will be oddfellows-cafe.
The immutable property ensures that the slug never changes even if the friendly name is edited. This is useful for preserving links to places as well as search engine references.
The ensureUnique property generates a unique slug by appending a suffix index automatically.
Yii's automated code generator Gii typically links to objects by numerical IDs. We want to change these links to use the slug. There are two places where this code exists.
The first is on the place index page in the grid action columns. You can customize these links like this in /frontend/views/places/index.php
:
<?= GridView::widget([ 'dataProvider' => $dataProvider, 'filterModel' => $searchModel, 'columns' => [ ['class' => 'yii\grid\SerialColumn'], 'name', [ 'attribute' => 'place_type', 'format' => 'raw', 'value' => function ($model) { return '<div>'.$model->getPlaceType($model->place_type).'</div>'; }, ], ['class' => 'yii\grid\ActionColumn', 'template'=>'{view} {update} ', 'buttons'=>[ 'view' => function ($url, $model) { return Html::a('<span class="glyphicon glyphicon-eye-open"></span>', 'place/'.$model->slug, ['title' => Yii::t('yii', 'View'),]); } ], ], ], ]); ?>
The other place is in breadcrumbs:
For example, in /frontend/views/place/update.php
, we need to change this:
<?php use yii\helpers\Html; /* @var $this yii\web\View */ /* @var $model frontend\models\Place */ $this->title = Yii::t('frontend', 'Update {modelClass}: ', [ 'modelClass' => 'Place', ]) . ' ' . $model->name; $this->params['breadcrumbs'][] = ['label' => Yii::t('frontend', 'Places'), 'url' => ['index']]; $this->params['breadcrumbs'][] = ['label' => $model->name, 'url' => ['view', 'id' => $model->id]]; $this->params['breadcrumbs'][] = Yii::t('frontend', 'Update'); ?>
Replacing the view ID code to use the slug:
... $this->params['breadcrumbs'][] = ['label' => Yii::t('frontend', 'Places'), 'url' => ['index']]; $this->params['breadcrumbs'][] = ['label' => $model->name, 'url' => ['slug', 'slug' => $model->slug]]; $this->params['breadcrumbs'][] = Yii::t('frontend', 'Update'); ...
Cleanup and Polish
As we build Meeting Planner, there will be code sprints to create new features and periods needed for cleanup and polish. This is likely to be a repetitive cycle.
I'm going to walk through a few scenarios I want to address at this point in time. Of course, there will continue to be cleanup areas needed as long as we're building out code, taking in user feedback and improving the product.
Extending the Navigation Bar
Yii2 is well integrated with Bootstrap, so your applications look great and run responsively right out of the box. If you want to build a navigation bar with dropdown menus, it's helpful if you review Bootstrap's documentation and understand Yii2's use of short array notation in PHP. The Yii2 Navbar widget documentation doesn't provide a lot of examples at this time.
I decided to begin to update the Meeting Planner navigation with some dropdown menus based on user state, e.g. guest or authenticated.
Here's the code that implements the above—I'm sure I'll keep modifying it as we go along:
NavBar::begin([ 'brandLabel' => Yii::t('frontend','MeetingPlanner.io'), // 'brandUrl' => Yii::$app->homeUrl, 'options' => [ 'class' => 'navbar-inverse navbar-fixed-top', ], ]); if (Yii::$app->user->isGuest) { $menuItems[] = ['label' => Yii::t('frontend','Signup'), 'url' => ['/site/signup']]; $menuItems[] = ['label' => Yii::t('frontend','Login'), 'url' => ['/site/login']]; } else { $menuItems = [ ['label' => Yii::t('frontend','Places'), 'url' => ['/place/yours']], ]; } $menuItems[]=['label' => Yii::t('frontend','About'), 'items' => [ ['label' => Yii::t('frontend','Learn more'), 'url' => ['/site/about']], ['label' => Yii::t('frontend','Contact'), 'url' => ['/site/contact']], ], ]; if (!Yii::$app->user->isGuest) { $menuItems[] = [ 'label' => 'Account', 'items' => [ [ 'label' => Yii::t('frontend','Contact information'), 'url' => ['/user-contact'], ], [ 'label' => Yii::t('frontend','Logout').' (' . Yii::$app->user->identity->username . ')', 'url' => ['/site/logout'], 'linkOptions' => ['data-method' => 'post'] ], ], ]; } echo Nav::widget([ 'options' => ['class' => 'navbar-nav navbar-right'], 'items' => $menuItems, ]); NavBar::end();
Validating Models Properly Before Saving
I rewrote the create actions in PlaceController to validate the models before we try to add UserPlace relations. There were some cases where invalid data could be submitted and this would break the attempts to add relations. This also ensures that users will be returned to the form with friendly error messages when validations fail.
public function actionCreate() { $model = new Place(); if ($model->load(Yii::$app->request->post())) { $form = Yii::$app->request->post(); if (!is_numeric($model->place_type)) { $model->place_type=Place::TYPE_OTHER; } $model->created_by= Yii::$app->user->getId(); // validate the form against model rules if ($model->validate()) { // all inputs are valid $model->save(); // lookup gps location from address $model->addLocationFromAddress($model,$form['Place']['full_address']); return $this->redirect(['view', 'id' => $model->id]); } else { // validation failed return $this->render('create', [ 'model' => $model, ]); } } else { return $this->render('create', [ 'model' => $model, ]); } }
Preventing Duplicates
In our initial place creation code, we want to protect against duplicates. Places can have identical names, e.g. there are many Starbucks Coffees (far too many), but we want to prevent the exact place being created twice, e.g. Starbucks Coffee with identical Google Place ID or identical name and street address.
Yii offers model validators which integrate with ActiveForms to do a lot of this work for us. Here are the rules that we'll define in the Place model:
/** * @inheritdoc */ public function rules() { return [ [['name','slug'], 'required'], [['place_type', 'status', 'created_by', 'created_at', 'updated_at'], 'integer'], [['name', 'google_place_id', 'slug', 'website', 'full_address', 'vicinity'], 'string', 'max' => 255], [['website'], 'url'], [['slug'], 'unique'], [['searchbox'], 'unique','targetAttribute' => 'google_place_id'], [['name', 'full_address'], 'unique', 'targetAttribute' => ['name', 'full_address']], ]; }
Validators can enforce required fields and field types and length. They can also validate URLs such as with the website field above, email addresses and more. You can also write custom validators.
There are several built-in validators specific to uniqueness. For example, they can validate single field uniqueness as we've done with the slug field. But there are also more complex uniqueness validators.
When the user is adding a place with Google Autocomplete, we want to enforce uniqueness of the resulting google_place_id
in the hidden field but we want the error message to appear on the searchbox
field. This definition accomplishes it:
[['searchbox'], 'unique','targetAttribute' => 'google_place_id'],
We also want to ensure that name and address are unique together. In other words, multiple places can have the same name or the same address but no place can be identical in both fields. This definition does that:
[['name', 'full_address'], 'unique', 'targetAttribute' => ['name', 'full_address']],
Of course, many users can add the same place to their list of UserPlaces.
Eliminating Deletion
I also want to protect against deletion of Places. Yii's automated code generator Gii typically links to delete operations from the index grid and update pages. We want to remove those. And we want to restrict access to the delete action from the controller.
Here's an example of the place index page with delete icons:
When we customized the slug links above, we eliminated the default use of the delete command.
By the way, I really appreciate Yii's use of Bootstrap and the Glyphicons. They work beautifully.
Here's the view page with its delete button:
For now, let's comment out the code for the Delete button in /frontend/views/place/view.php
. We might want to add it back for administrators at some point.
<?php /* Html::a(Yii::t('frontend', 'Delete'), ['delete', 'id' => $model->id], [ 'class' => 'btn btn-danger', 'data' => [ 'confirm' => Yii::t('frontend', 'Are you sure you want to delete this item?'), 'method' => 'post', ], ]) */ ?>
Preventing Autocomplete Enter Key Submission
As I was building some of the HTML5 Geolocation and Google Places autocomplete code, there were a few errors popping up, some related to JavaScript.
For example, if you click the Enter key after typing in the autocomplete field, Google submits the form. We need to override this.
In our create_place.js, we add a key handler to stop the form from submitting:
function setupListeners() { // ... var place_input = document.getElementById('place-searchbox'); google.maps.event.addDomListener(place_input, 'keydown', function(e) { if (e.keyCode == 13) { e.preventDefault(); } }); }
Now when you hit Enter, you'll see the map on the page and be able to edit the rest of the form before submitting.
Reviewing Benefits of Using a Framework
A lot of people think PHP is a less serious or capable platform to build on. For me, Facebook's success with PHP has forever proven them wrong.
A lot of people haven't heard of the Yii Framework or dismiss the value of frameworks.
Shortly into this Startup series, we have already benefited from a huge number of Yii's framework features—speeding development, delivering a clean architecture and quality code:
- MVC architecture
- database migrations
- Active Record model relations and validation
- automated code generation
- Bootstrap integration and Glyphicons
- access control filters
- slug behaviors
- internationalization
This is why I'm a huge advocate for the Yii Framework. It's made me a much more efficient developer, capable of delivering solutions much more quickly than I would with vanilla PHP.
What's Next?
In the next Building Your Startup With PHP tutorial, we're going to build out support for user settings, contacts, and profile images.
Please feel free to add your questions and comments below; I generally participate in the discussions. You can also reach me on Twitter @reifman or email me directly.
Comments