Last weekend I chose to develop an SPA (single-page application) written in Laravel and Vue.js in order to promote the launch of my band Stalko’s second album.
You can check out the final product, and follow along with the code.
The brief for this project is the following:
- Allow users to listen to a new Stalko song
- Share the song with friends in a fun way by typing in their name and a personalised message
- Create a custom URL that users can send to their friends for them to read their message
- Let the user choose what language to create their ‘share’ experience in (English or Maltese)
I also set myself some personal goals:
- Test drive the application – a skill that I’ve been working on in PHP for the last year or so.
- Use Vue’s router library to build my first SPA
- Become more familiar with Vue in general
Planning It
It’s important to analyse the brief and translate it to technical terms that you can work with.
I want my users to be able to make a personalised experience for their friends, so that when visiting the website it would greet them with a message, and a Stalko song. Users would then be encouraged to create and share a personalised experience for one of their friends, and to buy tickets to our album launch. Hopefully this will create a viral loop where invited users will share to their friends.
Also – I decided to spice things up by allowing users to choose the language of their share.
I need to model this behaviour. To do so, I’ll store the locale, name and message of each Share. To allow my SPA to preview and store each Share I’ll set up a couple of Api endpoints.
Setting up
Setting up is quick and easy when using Laravel’s installer with Homestead:
laravel new stalkoshare
I set up my hosts file:
sudo nano /etc/hosts
by adding one line to it:
192.168.10.10 stalkoshare.app
Getting to work
I need to develop a simple API that my client side javascript can consume in order to create Shares.
The Laravel make:test command generates a boilerplate test class:
php artisan make:test ShareControllerTest
I can now put tests in my new ShareControllerTest file, and run them from terminal with a single line:
vendor/bin/phpunit
I want to be able to post to the /shares endpoint in order to create a new Share resource. I want to make sure that only valid requests will filter down to my ShareController. It’s often tempting to jump ahead when writing tests, because through experience we can sometimes predict how the eventual test should look:
/**
* @test
*/
public function it_validates_share()
{
$this->json('POST', '/shares', [
])->seeJsonStructure(['name', 'message', 'locale']);
$this->assertResponseStatus(422);
}
But by doing that, you aren’t “driving” the development through the tests, since the code isn’t evolving naturally due to the test. A better approach is to remember Uncle Bob’s rule:
You are not allowed to write any more of a unit test than is sufficient to fail.
So an appropriate first test would be more like:
/**
* @test
*/
public function it_responds_to_shares_store_endpoint()
{
$this->json('POST', '/shares', [
]);
}
This fails.
PHPUnit 4.8.23 by Sebastian Bergmann and contributors.
F
Time: 1.21 seconds, Memory: 12.00Mb
There was 1 failure:
1) ShareControllerTest::it_responds_to_shares_store_endpoint
Expected status code 422, got 404.
Failed asserting that 404 matches expected 422.
The failing test drives my next piece of code, which is to fix the 404 error that I’m receiving. I don’t have any routes for my application to respond to – hence the 404:
$router->post('shares', [
'as' => 'shares.store',
'uses' => 'ShareController@store',
]);
Running the test again now will result in a 500 error – something to be expected since I haven’t created the ShareController yet. I can do that easily with Laravel:
php artisan make:controller ShareController
For now I’ll create an empty store method in my new controller, and run the test again:
PHPUnit 4.8.23 by Sebastian Bergmann and contributors.
F
Time: 1.55 seconds, Memory: 12.00Mb
There was 1 failure:
1) ShareControllerTest::it_responds_to_shares_store_endpoint
Expected status code 422, got 200.
Failed asserting that 200 matches expected 422.
We’re not doing anything inside the method, and as a result Laravel automatically is returning a 200 response for us. Let’s change that by leveraging Laravel’s Form Request Validation:
php artisan make:request StoreShareRequest
We can add validation rules inside this request object:
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'name' => 'required',
];
}
To instruct Laravel to validate using this object when trying to store a Share, we type hint the store method on our ShareController:
public function store(StoreShareRequest $request)
{
//
}
Our test passes now!
PHPUnit 4.8.23 by Sebastian Bergmann and contributors.
.
Time: 1.32 seconds, Memory: 12.00Mb
OK (1 test, 1 assertion)
We can make it fail again by testing for the appropriate Json response:
/**
* @test
*/
public function it_validates_share()
{
$this->json('POST', '/shares', [
])->seeJsonStructure(['name', 'message', 'locale']);
$this->assertResponseStatus(422);
}
We’ve returned to our initial prediction of how the first test might look, but we’ve test driven our production code to this point – great! We can fix our now failing test by simply adding the appropriate validation rules in our StoreShareRequest.
public function rules()
{
return [
'name' => 'required',
'message' => 'required',
'locale' => 'required|in:en,mt',
];
}
Great. It’s time to actually save the Share now.
/**
* @test
*/
public function it_saves_share()
{
$this->json('POST', '/shares', [
'name' => 'Paul',
'message' => 'I heard you were stuck at home studying :(',
'locale' => 'en',
]);
$this->assertResponseStatus(200);
$this->seeInDatabase('shares', [
'name' => 'Paul',
'message' => 'I heard you were stuck at home studying :(',
'locale' => 'en',
]);
}
By passing a a request that will pass validation, the control of the application now resides inside the ShareController::store method. This test will fail until we add the code to persist the Share.
Let’s start by creating the appropriate migration and model:
php artisan make:migration create_shares_table --create=shares
php artisan make:model Share
We’ll populate the migration file as follows:
public function up()
{
Schema::create('shares', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->text('message');
$table->string('locale');
$table->timestamps();
});
}
And we can pop in the line that will save our Share to the database.
public function store(StoreShareRequest $request)
{
Share::create($request->all());
}
Don’t forget to enable database migrations when testing by adding the following use statement:
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\DatabaseTransactions;
class ShareControllerTest extends TestCase
{
use DatabaseMigrations;
// ..
We’ll run the tests again.
PHPUnit 4.8.23 by Sebastian Bergmann and contributors.
F
Time: 1.41 seconds, Memory: 12.00Mb
There was 1 failure:
1) ShareControllerTest::it_saves_share
Unable to find row in database table [shares] that matched attributes [{"name":"Paul","message":"I heard you were stuck at home studying :(","locale":"en"}].
Failed asserting that 0 is greater than 0.
A common mistake – we’ve forgotten to set up our fillables property on the Share model:
protected $fillable = [
'name',
'message',
'locale',
];
Now that we’re passing again, we can continue fleshing out our test.
It’s good to have a deep understanding of what we want to happen first: when a user creates a new Share we want to present them with a Url that they can then share with their friends.
Let’s translate that intent and add it to our test.
/**
* @test
**/
public function it_saves_share()
{
$this->json('POST', '/shares', [
'name' => 'Paul',
'message' => 'I heard you were stuck at home studying :(',
'locale' => 'en',
])->seeJson([
'url' => route('shares.show', 1)
]);
$this->assertResponseStatus(200);
$this->seeInDatabase('shares', [
'name' => 'Paul',
'message' => 'I heard you were stuck at home studying :(',
'locale' => 'en',
]);
}
Running this test the first time will result in an error – the route shares.show isn’t defined, and so the test throws an error. This is a common scenario where you first need to work on getting your tests to fail instead of throwing an error before you can make them pass.
Let’s do just that.
$router->get('/{id}', [
'as' => 'shares.show',
'uses' => 'ShareController@show',
]);
Now the test fails. Let’s make it pass by adjusting the response:
public function store(StoreShareRequest $request)
{
$share = Share::create($request->all());
return response()->json([
'url' => route('shares.show', $share->id)
]);
}
Fast Forwarding
There’s some other functionality that needs to be added before we can move on to the front end. To avoid this post from getting too long, I’m going to highlight what was done and leave the source code up for your perusal:
- Make the application respond to the shares.show route to display the customized share message
- Make the application respond to a share-previews.store endpoint to allow my javascript Api consumer to generate a preview of what their share message will look like to their friend.
- Make the homepage respond to a default Share
The Front End
I was really excited to jump into creating my first SPA with Vue. Even though I’ve used Vue for a number of projects – I’ve never invested the time to become really comfortable with it. My goal is to develop something that I can come back to a months from now and be able to quickly understand what’s going on.
I’ll start by pulling in Vue, Vue Resource, and Vue Router via npm.
npm install vue vue-resource vue-router --save
I decided to use browserify for two reasons. First is the ability to use the latest features of Javascipt. Second, browserify allows you to require javascript files into others allowing me to organise my Javascript in a modular fashion.
I’d like to go over some of the concepts that I used, and the tricks that helped me level up my Vue game.
First, let’s set up the boilerplate to at least get a screen going where we can see the homepage!
I’ve setup my app.js to allow Vue’s router to take control of the homepage, and to pass that control to a Vue component.
var Vue = require('Vue');
var VueRouter = require('vue-router');
Vue.use(VueRouter);
Vue.use(require('vue-resource'));
var router = new VueRouter();
router.map({
'/': {
component: Vue.extend(require('./components/submission.js')())
}
})
router.start(Vue.extend({}), '#app');
The component is extremely basic, and simply renders the template defined:
module.exports = function () {
return {
template: "#submission"
}
}
We need to make a few adjustments to the Html too:
<body id="app">
<router-view></router-view>
<template id="submission">
<h1>Hey {{ $share->name }}</h1>
<p>{{ $share->message }}</p>
<p>Why don't you listen to Stalko's new song <a href="#">A Long Wave Goodbye</a>?</p>
</template>
</body>
This in itself is already very satisfying to me. On page load, Vue’s router kicks in and takes control!
Allowing users to create a message to share with friends
The application needs to collect the following data from the user:
- The language of the message that they’re writing
- The name of the person this message is for
- The actual message.
For aesthetic purposes I decided to split up these actions into three screens. This decision led me to discover a great pattern to keep a growing Javascript app from getting out of control.
The store pattern is explained on this excellent page on Vue’s documentation and recommends to create an object that acts as a single source of truth, and pass that object around to the different modules that you might have, allowing them to interact with it as you see fit. Without this pattern I might have been tempted to use events to communicate between different modules – from experience this can easily become messy and difficult to deal with.
I created my store object to allow me to handle the user’s Share:
module.exports = {
share: {
locale: null,
name: null,
message: null
},
setLocale: function(locale) {
this.share.locale = locale;
},
setName: function(name) {
this.share.name = name;
},
setMessage: function(message) {
this.share.message = message;
},
getShare: function () {
return this.share;
}
}
I subsequently injected the store and router objects into my new components that needed to interact with them:
var store = require('./store.js');
router.map({
'/': {
component: Vue.extend(require('./components/submission.js')())
},
'/locale': {
component: Vue.extend(require('./components/locale.js')(store, router))
},
'/name': {
component: Vue.extend(require('./components/name.js')(store, router))
},
'/message': {
component: Vue.extend(require('./components/message.js')(store, router))
}
})
The locale, name and message components are very similar, so let’s use the locale component as a case study.
module.exports = function (store, router) {
return {
template: "#locale",
data: function () {
return {
store: store,
router: router
}
},
methods: {
setLocale(locale) {
this.store.setLocale(locale);
router.go('/name');
}
}
}
}
I’ve set up a simple Vue component that depends on the store and router objects, and exposes one method: setLocale. The setLocale method sets the locale that it is passed on the store object, and then instructs the app via the router to continue on to the name route.
The template that will be rendered is defined as follows:
<template id="locale">
<h2>What language will your message be in?</h2>
<p>
<button class="btn btn-primary" v-on:click="setLocale('en')">English</button>
<button class="btn btn-primary" v-on:click="setLocale('mt')">Maltese</button>
</p>
</template>
Fast Forwarding
I’d like to cover interacting with the server before wrapping up, so I’ll skip setting up the name and message screens and jump straight to submitting the Share in the review screen.
The Vue component looks like this:
module.exports = function (store, router) {
return {
template: "#review",
data: function () {
return {
store: store,
preview: ''
}
},
ready: function () {
this.preview = "Generating preview...";
this.fetchPreview();
},
methods: {
submit() {
this.$http.post('/shares', this.store.getShare())
.then(function (response) {
// success callback
this.store.setUrl(response.data.url);
router.go('/share');
}, function (response) {
// do something with errors
});
},
fetchPreview() {
this.$http.post('/share-previews', this.store.getShare())
.then(function (response) {
this.preview = response.data.preview;
}, function (response) {
this.handleErrors(response.data);
});
},
}
}
}
Once ready, the preview is created via the fetchPreview method. I’ve utilized the $http object made available to me by including the Vue Resource plugin.
The review template interacts with the component with a couple of simple bindings:
<template id="review">
<h2>How does it look?</h2>
<p>@{{{ preview }}}<p>
<button class="btn btn-primary" v-on:click="submit()">Great - give me my link!</button>
<button class="btn btn-primary" v-link="{path: '/locale'}">Start Over</button>
</template>
Wrapping Up
I skimmed over some of the work done setting up this mini website, but I hope that it conveys the thought process behind a test driven application.
Vue and Laravel make for a beautiful combination for web development, this was a fun project.
Check out the source on github, check out the final website and feel free to leave a comment below!