One of the benchmarks of any framework is how well it fares in the creation of an e-commerce application. Laravel 4 is up to the challenge; and, in this chapter, we’re going to create an online shop.
There is no way that an e-commerce platform, built in 40 minutes, can be production-ready. Please do not attempt to conduct any real business on the basis of this chapter; without having taken the necessary precautions.
This chapter is a guide, an introduction, a learning tool. It is not meant to be the last word in building e-commerce platforms. I do not want to hear about how all your customers want to sue you because you slapped your name and logo on the GitHub source-code and left all reason behind.
In this chapter; we will create a number of database objects, which will later be made available through API endpoints. We’ll then use these, together with AngularJS, to create an online shop. We’ll finish things off with an overview of creating PDF documents dynamically.
Laravel 4 uses Composer to manage its dependencies. You can install Composer by following the instructions at http://getcomposer.org/doc/00-intro.md#installation-nix.
Once you have Composer working, make a new directory or navigation to an existing directory and install Laravel 4 with the following command:
1 composer create-project laravel/laravel ./ —prefer-dist If you chose not to install Composer globally (though you really should), then the command you use should resemble the following:
1 php composer.phar create-project laravel/laravel ./ —prefer-dist Both of these commands will start the process of installing Laravel 4. There are many dependencies to be sourced and downloaded; so this process may take some time to finish.
Our application will do loads of things, so we’ll need to install a few dependencies to lighten the workload.
AngularJS is an open-source JavaScript framework, maintained by Google, that assists with running single-page applications. Its goal is to augment browser-based applications with model–view–controller capability, in an effort to make both development and testing easier.
Angular allows us to create a set of interconnected components for things like product listings, shopping carts and payment pages. That’s not all it can do; but that’s all we’re going to do with it (for now).
To get started, all we need to know is how to link the AngularJS library to our document:
1 <script 2 type="text/javascript" 3 src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.0rc1/angular.js" 4 ></script> If this seems too easy to be enough, fret not. AngularJS includes no stylesheets or any other resources. It’s purely a JavaScript framework, so that script is all you need. If you prefer to keep scripts loading from your local machine, just download the contents of the file at the end of that src attribute.
Sleek, intuitive, and powerful mobile first front-end framework for faster and easier web development.
Bootstrap has become somewhat of a standard in modern applications. It’s often used as a CSS reset, a wire-framing tool and even as the baseline for all application CSS. We’re going to use it to neaten up our simple HTML.
It’s available for linking (as we did with AngularJS):
1 <link 2 type="text/css" 3 rel="stylesheet" 4 href="//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap.min.css" 5 /> 6 <link 7 type="text/css" 8 rel="stylesheet" 9 href="//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap-theme.min.css" 10 /> 11 <script 12 type="text/javascript" 13 src="//netdna.bootstrapcdn.com/bootstrap/3.0.3/js/bootstrap.min.js" 14 ></script> You can also download it, and serve it directly from the public folder. As it contains CSS and fonts; be sure to update all paths to the relevant images and/or fonts that come bundled with it.
At its heart, dompdf is (mostly) CSS 2.1 compliant HTML layout and rendering engine written in PHP. It is a style-driven renderer: it will download and read external stylesheets, inline style tags, and the style attributes of individual HTML elements. It also supports most presentational HTML attributes.
DOMPDF essentially takes HTML documents and converts them into PDF files. If you’ve ever had to produce PDF files programatically (at least in PHP) then this library should be like the singing of angels to your ears. It really is epic.
To install this guy; we need to add a composer dependency:
1 "require" : { 2 "dompdf/dompdf" : "dev-master" 3 } Stripe is a simple, developer-friendly way to accept payments online. We believe that enabling transactions on the web is a problem rooted in code, not finance, and we want to help put more websites in business.
We’re going to look at accepting payments with Stripe. It’s superior to the complicated payment processes of other services, like PayPal.
Installation is similar to DOMPDF:
1 "require" : { 2 "stripe/stripe-php" : "dev-master" 3 } Faker is a PHP library that generates fake data for you. Whether you need to bootstrap your database, create good-looking XML documents, fill-in your persistence to stress test it, or anonymize data taken from a production service, Faker is for you.
We’re going to use Faker for populating our database tables (through seeders) so we have a fresh set of data to use each time we migrate our database objects.
To install Faker; add another Composer dependency:
1 "require" : { 2 "fzaninotto/faker" : "dev-master" 3 } Remember to make this dependency behave nicely with the others in composer.json. A simple composer update should take us the rest of the way to being able to use DOMPDF, Stripe and Faker in our application.
For our online shop; we’re going to need categories for products to be sorted into, products and accounts. We’ll also need orders and order items, to track which items have been sold.
We listed five migrations, which we need to create. Fire up a terminal window and use the following command to create them:
1 php artisan migrate:make CreateCategoryTable After a bit of modification; I have the following migrations:
1 <?php 2 3 use Illuminate\Database\Migrations\Migration; 4 5 class CreateAccountTable 6 extends Migration 7 { 8 public function up() 9 { 10 Schema::create("account", function($table) 11 { 12 $table->engine = "InnoDB"; 13 14 $table->increments("id"); 15 $table->string("email"); 16 $table->string("password"); 17 $table->dateTime("created_at"); 18 $table->dateTime("updated_at"); 19 $table->dateTime("deleted_at"); 20 }); 21 } 22 23 public function down() 24 { 25 Schema::dropIfExists("account"); 26 } 27 } 1 <?php 2 3 use Illuminate\Database\Migrations\Migration; 4 5 class CreateCategoryTable 6 extends Migration 7 { 8 public function up() 9 { 10 Schema::create("category", function($table) 11 { 12 $table->engine = "InnoDB"; 13 14 $table->increments("id"); 15 $table->string("name"); 16 $table->dateTime("created_at"); 17 $table->dateTime("updated_at"); 18 $table->dateTime("deleted_at"); 19 }); 20 } 21 22 public function down() 23 { 24 Schema::dropIfExists("category"); 25 } 26 } 1 <?php 2 3 use Illuminate\Database\Migrations\Migration; 4 5 class CreateOrderItemTable 6 extends Migration 7 { 8 public function up() 9 { 10 Schema::create("order_item", function($table) 11 { 12 $table->engine = "InnoDB"; 13 14 $table->increments("id"); 15 $table->integer("order_id"); 16 $table->integer("product_id"); 17 $table->integer("quantity"); 18 $table->float("price"); 19 $table->dateTime("created_at"); 20 $table->dateTime("updated_at"); 21 $table->dateTime("deleted_at"); 22 }); 23 } 24 25 public function down() 26 { 27 Schema::dropIfExists("order_item"); 28 } 29 } 1 <?php 2 3 use Illuminate\Database\Migrations\Migration; 4 5 class CreateOrderTable 6 extends Migration 7 { 8 public function up() 9 { 10 Schema::create("order", function($table) 11 { 12 $table->engine = "InnoDB"; 13 14 $table->increments("id"); 15 $table->integer("account_id"); 16 $table->dateTime("created_at"); 17 $table->dateTime("updated_at"); 18 $table->dateTime("deleted_at"); 19 }); 20 } 21 22 public function down() 23 { 24 Schema::dropIfExists("order"); 25 } 26 } 1 <?php 2 3 use Illuminate\Database\Migrations\Migration; 4 5 class CreateProductTable 6 extends Migration 7 { 8 public function up() 9 { 10 Schema::create("product", function($table) 11 { 12 $table->engine = "InnoDB"; 13 14 $table->increments("id"); 15 $table->string("name"); 16 $table->integer("stock"); 17 $table->float("price"); 18 $table->dateTime("created_at"); 19 $table->dateTime("updated_at"); 20 $table->dateTime("deleted_at"); 21 }); 22 } 23 24 public function down() 25 { 26 Schema::dropIfExists("product"); 27 } 28 } There’s nothing particularly special about these - we’ve create many of them before. What is important to note is that we’re calling the traditional user table account.
The relationships might not yet be apparent, but we’ll see them more clearly in the models…
We need to create the same amount of models. I’ve gone ahead and created them with table names matching those defined int he migrations. I’ve also added the relationship methods:
1 <?php 2 3 use Illuminate\Auth\UserInterface; 4 use Illuminate\Auth\Reminders\RemindableInterface; 5 6 class Account 7 extends Eloquent 8 implements UserInterface, RemindableInterface 9 { 10 protected $table = "account"; 11 12 protected $hidden = ["password"]; 13 14 protected $guarded = ["id"]; 15 16 protected $softDelete = true; 17 18 public function getAuthIdentifier() 19 { 20 return $this->getKey(); 21 } 22 23 public function getAuthPassword() 24 { 25 return $this->password; 26 } 27 28 public function getReminderEmail() 29 { 30 return $this->email; 31 } 32 33 public function orders() 34 { 35 return $this->hasMany("Order"); 36 } 37 } 1 <?php 2 3 class Category 4 extends Eloquent 5 { 6 protected $table = "category"; 7 8 protected $guarded = ["id"]; 9 10 protected $softDelete = true; 11 12 public function products() 13 { 14 return $this->hasMany("Product"); 15 } 16 } 1 <?php 2 3 class Order 4 extends Eloquent 5 { 6 protected $table = "order"; 7 8 protected $guarded = ["id"]; 9 10 protected $softDelete = true; 11 12 public function account() 13 { 14 return $this->belongsTo("Account"); 15 } 16 17 public function orderItems() 18 { 19 return $this->hasMany("OrderItem"); 20 } 21 22 public function products() 23 { 24 return $this->belongsToMany("Product", "order_item"); 25 } 26 } 1 <?php 2 3 class OrderItem 4 extends Eloquent 5 { 6 protected $table = "order_item"; 7 8 protected $guarded = ["id"]; 9 10 protected $softDelete = true; 11 12 public function product() 13 { 14 return $this->belongsTo("Product"); 15 } 16 17 public function order() 18 { 19 return $this->belongsTo("Order"); 20 } 21 } 1 <?php 2 3 class Product 4 extends Eloquent 5 { 6 protected $table = "product"; 7 8 protected $guarded = ["id"]; 9 10 protected $softDelete = true; 11 12 public function orders() 13 { 14 return $this->belongsToMany("Order", "order_item"); 15 } 16 17 public function orderItems() 18 { 19 return $this->hasMany("OrderItem"); 20 } 21 22 public function category() 23 { 24 return $this->belongsTo("Category"); 25 } 26 } I’m using a combination of one-to-many relationships and many-to-many relationships (through the order_item) table. These relationships can be expressed as:
We can now being to populate these tables with fake data, and manipulate them with API endpoints.
Having installed Faker; we’re going to use it to populate the database tables with fake data. We do this for two reasons. Firstly, using fake data is safer than using production data.
Have you ever been writing a script that sends out emails and used some dummy copy while you’re building it? Ever used some cheeky words in that content? Ever accidentally sent that email out to 10,000 real customers email addresses? Ever been fired for losing a company north of £200,000?
I haven’t, but I know a guy that has. Don’t be that guy.
Secondly, Faker provides random fake data so we get to see what our models look like with random variable data. This will show us the oft-overlooked field limits and formatting errors that we tend to miss while using the same set of pre-defined seed data.
Using Faker is easy:
1 <?php 2 3 class DatabaseSeeder 4 extends Seeder 5 { 6 protected $faker; 7 8 public function getFaker() 9 { 10 if (empty($this->faker)) 11 { 12 $this->faker = Faker\Factory::create(); 13 } 14 15 return $this->faker; 16 } 17 18 public function run() 19 { 20 $this->call("AccountTableSeeder"); 21 } 22 } 1 <?php 2 3 class AccountTableSeeder 4 extends DatabaseSeeder 5 { 6 public function run() 7 { 8 $faker = $this->getFaker(); 9 10 for ($i = 0; $i < 10; $i++) 11 { 12 $email = $faker->email; 13 $password = Hash::make("password"); 14 15 Account::create([ 16 "email" => $email, 17 "password" => $password 18 ]); 19 } 20 } 21 } The first step is to create an instance of the FakerGenerator class. We do this by calling the FakerFactory::create() method and assigning it to a protected property.
Then, in AccountTableSeeder, we loop ten times; creating different accounts. Each account has a random email address, but all of them share the same hashed password. This is so that we will be able to log in with any of these accounts to interact with the rest of the application.
We can actually test this process, to see how the data is created, and how we can authenticate against it. Seed the database, using the following command:
1 php artisan migrate:refresh --seed Depending on whether you have already migrated the schema; this may fail. If this happens, you can try the following commands:
1 php artisan migrate 2 php artisan db:seed You should see ten account records, each with a different email address and password hash. We can attempt to authenticate with one of these. To do this; we need to adjust the auth settings:
1 <?php 2 3 return [ 4 "driver" => "eloquent", 5 "model" => "Account", 6 "table" => "account", 7 "reminder" => [ 8 "email" => "email/request", 9 "table" => "token", 10 "expire" => 60 11 ] 12 ]; Fire up a terminal window and try the following commands:
1 php artisan tinker 1 dd(Auth::attempt([ 2 "email" => [one of the email addresses], 3 "password" => "password" 4 ])); If you see bool(true) then the details you entered will allow a user to log in. Now, let’s repeat the process for the other models:
1 public function getFaker() 2 { 3 if (empty($this->faker)) 4 { 5 $faker = Faker\Factory::create(); 6 $faker->addProvider(new Faker\Provider\Base($faker)); 7 $faker->addProvider(new Faker\Provider\Lorem($faker)); 8 } 9 10 return $this->faker = $faker; 11 } We’ve modified the getFaker() method to add things called providers. Providers are like plugins for Faker; which extend the base array of properties/methods that you can query.
1 <?php 2 3 class CategoryTableSeeder 4 extends DatabaseSeeder 5 { 6 public function run() 7 { 8 $faker = $this->getFaker(); 9 10 for ($i = 0; $i < 10; $i++) 11 { 12 $name = ucwords($faker->word); 13 14 Category::create([ 15 "name" => $name 16 ]); 17 } 18 } 19 } 1 <?php 2 3 class ProductTableSeeder 4 extends DatabaseSeeder 5 { 6 public function run() 7 { 8 $faker = $this->getFaker(); 9 10 $categories = Category::all(); 11 12 foreach ($categories as $category) 13 { 14 for ($i = 0; $i < rand(-1, 10); $i++) 15 { 16 $name = ucwords($faker->word); 17 $stock = $faker->randomNumber(0, 100); 18 $price = $faker->randomFloat(2, 5, 100); 19 20 Product::create([ 21 "name" => $name, 22 "stock" => $stock, 23 "price" => $price, 24 "category_id" => $category->id 25 ]); 26 } 27 } 28 } 29 } Here, we use the randomNumber() and randomFloat() methods. What’s actually happening, when you request a property value, is that Faker invokes a method of the same name (on one of the providers). We can just as easily use the $faker->word() means the same as $faker->word. Some of the methods (such as the random*() methods we’ve used here) take arguments, so we provide them in the method form.
1 <?php 2 3 class OrderTableSeeder 4 extends DatabaseSeeder 5 { 6 public function run() 7 { 8 $faker = $this->getFaker(); 9 10 $accounts = Account::all(); 11 12 foreach ($accounts as $account) 13 { 14 for ($i = 0; $i < rand(-1, 5); $i++) 15 { 16 Order::create([ 17 "account_id" => $account->id 18 ]); 19 } 20 } 21 } 22 } 1 <?php 2 3 class OrderItemTableSeeder 4 extends DatabaseSeeder 5 { 6 public function run() 7 { 8 $faker = $this->getFaker(); 9 10 $orders = Order::all(); 11 $products = Product::all()->toArray(); 12 13 foreach ($orders as $order) 14 { 15 $used = []; 16 17 for ($i = 0; $i < rand(1, 5); $i++) 18 { 19 $product = $faker->randomElement($products); 20 21 if (!in_array($product["id"], $used)) 22 { 23 $id = $product["id"]; 24 $price = $product["price"]; 25 $quantity = $faker->randomNumber(1, 3); 26 27 OrderItem::create([ 28 "order_id" => $order->id, 29 "product_id" => $id, 30 "price" => $price, 31 "quantity" => $quantity 32 ]); 33 34 $used[] = $product["id"]; 35 } 36 } 37 } 38 } 39 } 1 public function run() 2 { 3 $this->call("AccountTableSeeder"); 4 $this->call("CategoryTableSeeder"); 5 $this->call("ProductTableSeeder"); 6 $this->call("OrderTableSeeder"); 7 $this->call("OrderItemTableSeeder"); 8 } The order in which we call the seeders is important. We can’t start populating orders and order items if we have no products or accounts in the database…
We don’t have time to cover all aspects of creating APIs with Laravel 4, so we’ll confine our efforts to creating endpoints for the basic interactions that need to happen for our interface to function.
The endpoints for categories and products are read-only in nature. We’re not concentrating on any sort of administration interface, so we don’t need to add or update them. We will need to adjust the quantity of available products, but we can do that from the OrderController, later on. For now, all we need is:
1 <?php 2 3 class CategoryController 4 extends BaseController 5 { 6 public function indexAction() 7 { 8 return Category::with(["products"])->get(); 9 } 10 } 1 <?php 2 3 class ProductController 4 extends BaseController 5 { 6 public function indexAction() 7 { 8 $query = Product::with(["category"]); 9 $category = Input::get("category"); 10 11 if ($category) 12 { 13 $query->where("category_id", $category); 14 } 15 16 return $query->get(); 17 } 18 } The CategoryController has a single index() method which returns all categories, and the ProductController has a single index() method which returns all the products. If ?category=n is provided to the product/index route, the products will be filtered by that category.
We do, of course, still need to add these routes:
1 Route::any("category/index", [ 2 "as" => "category/index", 3 "uses" => "CategoryController@indexAction" 4 ]); 5 6 Route::any("product/index", [ 7 "as" => "product/index", 8 "uses" => "ProductController@indexAction" 9 ]); For users to be able to buy products, they will need to log in. We’ve added some users to the database, via the UserTableSeeder class, but we should create an authentication endpoint:
1 <?php 2 3 class AccountController 4 extends BaseController 5 { 6 public function authenticateAction() 7 { 8 $credentials = [ 9 "email" => Input::get("email"), 10 "password" => Input::get("password") 11 ]; 12 13 if (Auth::attempt($credentials)) 14 { 15 return Response::json([ 16 "status" => "ok", 17 "account" => Auth::user()->toArray() 18 ]); 19 } 20 21 return Response::json([ 22 "status" => "error" 23 ]); 24 } 25 } We’ll also need to add a route for this:
1 Route::any("account/authenticate", [ 2 "as" => "account/authenticate", 3 "uses" => "AccountController@authenticateAction" 4 ]); It should now be possible to determine whether login credentials are legitimate; through the browser:
1 /account/authenticate?email=x&password=y This will return an object with a status value. If the details were valid then an account object will also be returned.
Orders are slightly more complicated. We will need to be able to get all orders as well as orders by account. We’ll also need to create new orders.
Let’s begin by getting the orders:
1 <?php 2 3 class OrderController 4 extends BaseController 5 { 6 public function indexAction() 7 { 8 $query = Order::with([ 9 "account", 10 "orderItems", 11 "orderItems.product", 12 "orderItems.product.category" 13 ]); 14 15 $account = Input::get("account"); 16 17 if ($account) 18 { 19 $query->where("account_id", $account); 20 } 21 22 return $query->get(); 23 } 24 } This looks similar to the indexAction() method, in ProductController. We’re also eager-loading the related “child” entities and querying by account (if that’s given).
For this; we will need to add a route:
1 Route::any("order/index", [ 2 "as" => "order/index", 3 "uses" => "OrderController@indexAction" 4 ]); We’ll deal with creating orders once we have the shopping and payment interfaces completed. Let’s not get ahead of ourselves…
Will the API in place; we can begin the interface work. We’re using AngularJS, which creates rich interfaces from ordinary HTML.
AngularJS allows much of the functionality, we would previous have split into separate pages, to be in the same single-page application interface. It’s not a unique feature of AngularJS, but rather the preferred approach to interface structure.
Because of this; we only need a single view:
1 <!doctype html> 2 <html lang="en"> 3 <head> 4 <meta charset="utf-8" /> 5 <title>Laravel 4 E-Commerce</title> 6 <link 7 type="text/css" 8 rel="stylesheet" 9 href="{{ asset("css/bootstrap.3.0.3.min.css") }}" 10 /> 11 <link 12 type="text/css" 13 rel="stylesheet" 14 href="{{ asset("css/bootstrap.theme.3.0.3.min.css") }}" 15 /> 16 <link 17 type="text/css" 18 rel="stylesheet" 19 href="{{ asset("css/shared.css") }}" 20 /> 21 <script 22 type="text/javascript" 23 src="{{ asset("js/angularjs.1.2.4.min.js") }}" 24 ></script> 25 <script 26 type="text/javascript" 27 src="{{ asset("js/angularjs.cookies.1.2.4.min.js") }}" 28 ></script> 29 </head> 30 <body> 31 <div class="container"> 32 <div class="row"> 33 <div class="col-md-12"> 34 <h1> 35 Laravel 4 E-Commerce 36 </h1> 37 </div> 38 </div> 39 <div class="row"> 40 <div class="col-md-8"> 41 <h2> 42 Products 43 </h2> 44 <div class="categories btn-group"> 45 <button 46 type="button" 47 class="category btn btn-default active" 48 > 49 All 50 </button> 51 <button 52 type="button" 53 class="category btn btn-default" 54 > 55 Category 1 56 </button> 57 <button 58 type="button" 59 class="category btn btn-default" 60 > 61 Category 2 62 </button> 63 <button 64 type="button" 65 class="category btn btn-default" 66 > 67 Category 3 68 </button> 69 </div> 70 <div class="products"> 71 <div class="product media"> 72 <button 73 type="button" 74 class="pull-left btn btn-default" 75 > 76 Add to basket 77 </button> 78 <div class="media-body"> 79 <h4 class="media-heading">Product 1</h4> 80 <p> 81 Price: 9.99, Stock: 10 82 </p> 83 </div> 84 </div> 85 <div class="product media"> 86 <button 87 type="button" 88 class="pull-left btn btn-default" 89 > 90 Add to basket 91 </button> 92 <div class="media-body"> 93 <h4 class="media-heading">Product 2</h4> 94 <p> 95 Price: 9.99, Stock: 10 96 </p> 97 </div> 98 </div> 99 <div class="product media"> 100 <button 101 type="button" 102 class="pull-left btn btn-default" 103 > 104 Add to basket 105 </button> 106 <div class="media-body"> 107 <h4 class="media-heading">Product 3</h4> 108 <p> 109 Price: 9.99, Stock: 10 110 </p> 111 </div> 112 </div> 113 </div> 114 </div> 115 <div class="col-md-4"> 116 <h2> 117 Basket 118 </h2> 119 <form class="basket"> 120 <table class="table"> 121 <tr class="product"> 122 <td class="name"> 123 Product 1 124 </td> 125 <td class="quantity"> 126 <input 127 class="quantity form-control col-md-2" 128 type="number" 129 value="1" 130 /> 131 </td> 132 <td class="product"> 133 9.99 134 </td> 135 <td class="product"> 136 <a 137 class="remove glyphicon glyphicon-remove" 138 href="#" 139 ></a> 140 </td> 141 </tr> 142 <tr class="product"> 143 <td class="name"> 144 Product 2 145 </td> 146 <td class="quantity"> 147 <input 148 class="quantity form-control col-md-2" 149 type="number" 150 value="1" 151 /> 152 </td> 153 <td class="product"> 154 9.99 155 </td> 156 <td class="product"> 157 <a 158 class="remove glyphicon glyphicon-remove" 159 href="#" 160 ></a> 161 </td> 162 </tr> 163 <tr class="product"> 164 <td class="name"> 165 Product 3 166 </td> 167 <td class="quantity"> 168 <input 169 class="quantity form-control col-md-2" 170 type="number" 171 value="1" 172 /> 173 </td> 174 <td class="product"> 175 9.99 176 </td> 177 <td class="product"> 178 <a 179 class="remove glyphicon glyphicon-remove" 180 href="#" 181 ></a> 182 </td> 183 </tr> 184 </table> 185 </form> 186 </div> 187 </div> 188 </div> 189 <script 190 type="text/javascript" 191 src="{{ asset("js/shared.js") }}" 192 ></script> 193 </body> 194 </html> You’ll notice that we also reference a shared.css file:
1 .products { 2 margin-top: 20px; 3 } 4 .basket td { 5 vertical-align: middle !important; 6 } 7 .basket .quantity input { 8 width: 50px; 9 } These changes to the view coincide with a modified IndexController:
1 <?php 2 3 class IndexController 4 extends BaseController 5 { 6 public function indexAction() 7 { 8 return View::make("index"); 9 } 10 } So far; we’ve set up the API and static interface, for our application. It’s not going to be much use without the JavaScript to drive purchase functionality, and to interact with the API. Let’s dive into AngularJS!
AngularJS interfaces are just regular HTML and JavaScript. To wire the interface into the beginnings of an AngularJS application architecture; we have to add a script, and a few directives:
1 <body ng-controller="main"> 1 <div class="col-md-8" ng-controller="products"> 1 <div class="col-md-4" ng-controller="basket"> 1 <script 2 type="text/javascript" 3 src="{{ asset("js/shared.js") }}" 4 ></script> In addition to these modifications, we should also create the shared.js file:
1 var app = angular.module("app", ["ngCookies"]); 2 3 app.controller("main", function($scope) { 4 console.log("main.init"); 5 6 this.shared = "hello world"; 7 8 $scope.main = this; 9 }); 10 11 app.controller("products", function($scope) { 12 console.log("products.init:", $scope.main.shared); 13 14 $scope.products = this; 15 }); 16 17 app.controller("basket", function($scope) { 18 console.log("basket.init:", $scope.main.shared); 19 20 $scope.basket = this; 21 }); AngularJS implements the concept of modules - contains for modularising business and interface logic. We begin by creating a module (called app). This correlates with the ng-app=”app” directive.
The remaining ng-controller directives define which controllers apply to which element. These match the names of the controllers which we have created. Controllers are nothing more than scoped functions. We assign the controller instances, and some shared data, to the $scope variable. This provides a consistent means of sharing data.
Let’s popular the interface with real products. To achieve this; we need to request the products from the API, and render them (in a loop).
1 app.factory("CategoryService", function($http) { 2 return { 3 "getCategories": function() { 4 return $http.get("/category/index"); 5 } 6 }; 7 }); 8 9 app.factory("ProductService", function($http) { 10 return { 11 "getProducts": function() { 12 return $http.get("/product/index"); 13 } 14 }; 15 }); 16 17 app.controller("products", function( 18 $scope, 19 CategoryService, 20 ProductService 21 ) { 22 23 var self = this; 24 var categories = CategoryService.getCategories(); 25 26 categories.success(function(data) { 27 self.categories = data; 28 }); 29 30 var products = ProductService.getProducts(); 31 32 products.success(function(data) { 33 self.products = data; 34 }); 35 36 $scope.products = this; 37 38 }); There are some awesome things happening in this code. Firstly, we abstract the logic by which we get categories and products (from the API) into AngularJS’s implementation of services. We also have access to the $http interface; which is a wrapper for XMLHTTPRequest, and acts as a replacement for other libraries (think jQuery) which we would have used before.
The two services each have a method for returning the API data, for categories and products. These methods return things, called promises, which are references to future-completed data. We attach callbacks to these, within the ProductController, which essentially update the controller data.
So we have the API data, but how do we render it in the interface? We do so with directives and data-binding:
1 <div class="col-md-8" ng-controller="products"> 2 <h2> 3 Products 4 </h2> 5 <div class="categories btn-group"> 6 <button 7 type="button" 8 class="category btn btn-default active" 9 > 10 All 11 </button> 12 <button 13 type="button" 14 class="category btn btn-default" 15 ng-repeat="category in products.categories" 16 > 17 @{{ category.name }} 18 </button> 19 </div> 20 <div class="products"> 21 <div 22 class="product media" 23 ng-repeat="product in products.products" 24 > 25 <button 26 type="button" 27 class="pull-left btn btn-default" 28 > 29 Add to basket 30 </button> 31 <div class="media-body"> 32 <h4 class="media-heading">@{{ product.name }}</h4> 33 <p> 34 Price: @{{ product.price }}, Stock: @{{ product.stock }} 35 </p> 36 </div> 37 </div> 38 </div> 39 </div> If you’re wondering how the interface is updated when the data is fetched asynchronously, but the good news is you don’t need to. AngularJS takes care of all interface updates; so you can focus on the actual application! Open up a browser and see it working…
Now that we have dynamic categories and products, we should implement a filter so that products are swapped out whenever a user selects a category of products.
1 <button 2 type="button" 3 class="category btn btn-default active" 4 ng-click="products.setCategory(null)" 5 ng-class="{ 'active' : products.category == null }" 6 > 7 All 8 </button> 9 <button 10 type="button" 11 class="category btn btn-default" 12 ng-repeat="category in products.categories" 13 ng-click="products.setCategory(category)" 14 ng-class="{ 'active' : products.category.id == category.id }" 15 > 16 @{{ category.name }} 17 </button> 18 </div> 19 <div class="products"> 20 <div 21 class="product media" 22 ng-repeat="product in products.products | filter : products.filterByCategory" 23 > We’ve added three new concepts here:
These directives, in isolation, will only cause errors. We need to add the JavaScript logic to back them up:
1 app.controller("products", function( 2 $scope, 3 CategoryService, 4 ProductService 5 ) { 6 7 var self = this; 8 9 // ... 10 11 this.category = null; 12 13 this.filterByCategory = function(product) { 14 15 if (self.category !== null) { 16 return product.category.id === self.category.id; 17 } 18 19 return true; 20 21 }; 22 23 this.setCategory = function(category) { 24 self.category = category; 25 }; 26 27 // ... 28 29 }); Let’s move on to the shopping basket. We need to be able to add items to it, remove items from it and change quantity values.
1 app.factory("BasketService", function($cookies) { 2 3 var products = JSON.parse($cookies.products || "[]"); 4 5 return { 6 7 "getProducts" : function() { 8 return products; 9 }, 10 11 "add" : function(product) { 12 13 products.push({ 14 "id" : product.id, 15 "name" : product.name, 16 "price" : product.price, 17 "total" : product.price * 1, 18 "quantity" : 1 19 }); 20 21 this.store(); 22 23 }, 24 25 "remove" : function(product) { 26 27 for (var i = 0; i < products.length; i++) { 28 29 var next = products[i]; 30 31 if (next.id == product.id) { 32 products.splice(i, 1); 33 } 34 35 } 36 37 this.store(); 38 39 }, 40 41 "update": function() { 42 43 for (var i = 0; i < products.length; i++) { 44 45 var product = products[i]; 46 var raw = product.quantity * product.price; 47 48 product.total = Math.round(raw * 100) / 100; 49 50 } 51 52 this.store(); 53 54 }, 55 56 "store" : function() { 57 $cookies.products = JSON.stringify(products); 58 }, 59 60 "clear" : function() { 61 products.length = 0; 62 this.store(); 63 } 64 65 }; 66 67 }); We’re grouping all the basket-related logic together in BasketService. You may have noticed the reference to ngCookies (when creating the app module) and the extra script file reference (in index.blade.php). These allow us to use AngularJS’s cookies module; for storing the basket items.
The getProducts() method returns the products. We need to store them as a serialised JSON array, so when we initially retrieve them; we parse them (with a default value of ”[]”). The add() and remove() methods create and destroy special item objects. After each basket item operation; we need to persist the products array back to $cookies.
The update() method works out the total cost of each item; by taking into account the original price and the updated quantity. It also rounds this value to avoid floating-point calculation irregularities.
There’s also a store() method which persists the products to $cookies, and a clear() method which removes all products.
The HTML, compatible with all this, is:
1 <div class="col-md-4" ng-controller="basket"> 2 <h2> 3 Basket 4 </h2> 5 <form class="basket"> 6 <table class="table"> 7 <tr 8 class="product" 9 ng-repeat="product in basket.products track by $index" 10 > 11 <td class="name"> 12 @{{ product.name }} 13 </td> 14 <td class="quantity"> 15 <input 16 class="quantity form-control col-md-2" 17 type="number" 18 ng-model="product.quantity" 19 ng-change="basket.update()" 20 min="1" 21 /> 22 </td> 23 <td class="product"> 24 @{{ product.total }} 25 </td> 26 <td class="product"> 27 <a 28 class="remove glyphicon glyphicon-remove" 29 ng-click="basket.remove(product)" 30 ></a> 31 </td> 32 </tr> 33 </table> 34 </form> 35 </div> We’ve change the numeric input element to use ng-model and ng-change directives. The first tells the input which dynamic (quantity) value to bind to, and the second tells the basket what to do if the input’s value has changed. We already know that this means re-calculating the total cost of that product, and storing the products back in $cookies.
We’ve also added an ng-click directive to the remove link; so that the product can be removed from the basket.
We need to be able to remove the basket items, also. Let’s modify the JavaScript/HTML to allow for this:
1 app.controller("products", function( 2 $scope, 3 CategoryService, 4 ProductService, 5 BasketService 6 ) { 7 8 // ... 9 10 this.addToBasket = function(product) { 11 BasketService.add(product); 12 }; 13 14 // ... 15 16 }); 1 <button 2 type="button" 3 class="pull-left btn btn-default" 4 ng-click="products.addToBasket(product)" 5 > 6 Add to basket 7 </button> Try it out in your browser. You should be able to add items into the basket, change their quantities and remove them. When you refresh, all should display correctly.
To complete orders; we need to send the order item data to the server, and process a payment. We need to pass this endpoint an account ID (to link the orders to an account) which means we also need to add authentication…
1 app.factory("AccountService", function($http) { 2 3 var account = null; 4 5 return { 6 "authenticate": function(email, password) { 7 8 var request = $http.post("/account/authenticate", { 9 "email" : email, 10 "password" : password 11 }); 12 13 request.success(function(data) { 14 if (data.status !== "error") { 15 account = data.account; 16 } 17 }); 18 19 return request; 20 21 }, 22 "getAccount": function() { 23 return account; 24 } 25 }; 26 }); 27 28 app.factory("OrderService", function( 29 $http, 30 AccountService, 31 BasketService 32 ) { 33 return { 34 "pay": function(number, expiry, security) { 35 36 var account = AccountService.getAccount(); 37 var products = BasketService.getProducts(); 38 var items = []; 39 40 for (var i = 0; i < products.length; i++) { 41 42 var product = products[i]; 43 44 items.push({ 45 "product_id" : product.id, 46 "quantity" : product.quantity 47 }); 48 49 } 50 51 return $http.post("/order/add", { 52 "account" : account.id, 53 "items" : JSON.stringify(items), 54 "number" : number, 55 "expiry" : expiry, 56 "security" : security 57 }); 58 } 59 }; 60 }); The AccountService object has a method for authenticating (with a provided email and password) and it returns the result of a POST request to /account/authenticate. It also has a getAccount() method which just returns whatever’s in the account variable.
The OrderService object as a single method for sending order details to the server. I’ve bundled the payment particulars in with this method to save some time. The idea is that the order is created and paid for in a single process.
We need to amend the basket controller:
1 app.controller("basket", function( 2 $scope, 3 AccountService, 4 BasketService, 5 OrderService 6 ) { 7 8 // ... 9 10 this.state = "shopping"; 11 this.email = ""; 12 this.password = ""; 13 this.number = ""; 14 this.expiry = ""; 15 this.secutiry = ""; 16 17 this.authenticate = function() { 18 19 var details = AccountService.authenticate(self.email, self.password); 20 21 details.success(function(data) { 22 if (data.status == "ok") { 23 self.state = "paying"; 24 } 25 }); 26 27 } 28 29 this.pay = function() { 30 31 var details = OrderService.pay( 32 self.number, 33 self.expiry, 34 self.security 35 ); 36 37 details.success(function(data) { 38 BasketService.clear(); 39 self.state = "shopping"; 40 }); 41 42 } 43 44 // ... 45 46 }); We’ve added a state variable which tracks progress through checkout. We’re also keeping track of the account email address and password as well as the credit card details. In addition; there are two new methods which will be triggered by the interface:
1 <div class="col-md-4" ng-controller="basket"> 2 <h2> 3 Basket 4 </h2> 5 <form class="basket"> 6 <table class="table"> 7 <tr 8 class="product" 9 ng-repeat="product in basket.products track by $index" 10 ng-class="{ 'hide' : basket.state != 'shopping' }" 11 > 12 <td class="name"> 13 @{{ product.name }} 14 </td> 15 <td class="quantity"> 16 <input 17 class="form-control" 18 type="number" 19 ng-model="product.quantity" 20 ng-change="basket.update()" 21 min="1" 22 /> 23 </td> 24 <td class="product"> 25 @{{ product.total }} 26 </td> 27 <td class="product"> 28 <a 29 class="remove glyphicon glyphicon-remove" 30 ng-click="basket.remove(product)" 31 ></a> 32 </td> 33 </tr> 34 <tr> 35 <td 36 colspan="4" 37 ng-class="{ 'hide' : basket.state != 'shopping' }" 38 > 39 <input 40 type="text" 41 class="form-control" 42 placeholder="email" 43 ng-model="basket.email" 44 /> 45 </td> 46 </tr> 47 <tr> 48 <td 49 colspan="4" 50 ng-class="{ 'hide' : basket.state != 'shopping' }" 51 > 52 <input 53 type="password" 54 class="form-control" 55 placeholder="password" 56 ng-model="basket.password" 57 /> 58 </td> 59 </tr> 60 <tr> 61 <td 62 colspan="4" 63 ng-class="{ 'hide' : basket.state != 'shopping' }" 64 > 65 <button 66 type="button" 67 class="pull-left btn btn-default" 68 ng-click="basket.authenticate()" 69 > 70 Authenticate 71 </button> 72 </td> 73 </tr> 74 <tr> 75 <td 76 colspan="4" 77 ng-class="{ 'hide' : basket.state != 'paying' }" 78 > 79 <input 80 type="text" 81 class="form-control" 82 placeholder="card number" 83 ng-model="basket.number" 84 /> 85 </td> 86 </tr> 87 <tr> 88 <td 89 colspan="4" 90 ng-class="{ 'hide' : basket.state != 'paying' }" 91 > 92 <input 93 type="text" 94 class="form-control" 95 placeholder="expiry" 96 ng-model="basket.expiry" 97 /> 98 </td> 99 </tr> 100 <tr> 101 <td 102 colspan="4" 103 ng-class="{ 'hide' : basket.state != 'paying' }" 104 > 105 <input 106 type="text" 107 class="form-control" 108 placeholder="security number" 109 ng-model="basket.security" 110 /> 111 </td> 112 </tr> 113 <tr> 114 <td 115 colspan="4" 116 ng-class="{ 'hide' : basket.state != 'paying' }" 117 > 118 <button 119 type="button" 120 class="pull-left btn btn-default" 121 ng-click="basket.pay()" 122 > 123 Pay 124 </button> 125 </td> 126 </tr> 127 </table> 128 </form> 129 </div> We’re using those ng-class directives to hide/show various table rows (in our basket). This lets us toggle the fields that users need to complete; and provides us with different buttons to dispatch the different methods in our basket controller.
Finally; we need to tie this into our OrderController, where the orders are completed and the payments are processed…
We’re going to create a service provider to handle the payment side of things, and while we could go into great detail about how to do this; we don’t have the time. Read Taylor’s book, or mime, or the docs.
Before we start hitting Stripe up; we should create the endpoint for creating orders:
1 public function addAction() 2 { 3 $validator = Validator::make(Input::all(), [ 4 "account" => "required|exists:account,id", 5 "items" => "required" 6 ]); 7 8 if ($validator->passes()) 9 { 10 $order = Order::create([ 11 "account_id" => Input::get("account") 12 ]); 13 14 try 15 { 16 $items = json_decode(Input::get("items")); 17 } 18 catch (Exception $e) 19 { 20 return Response::json([ 21 "status" => "error", 22 "errors" => [ 23 "items" => [ 24 "Invalid items format." 25 ] 26 ] 27 ]); 28 } 29 30 $total = 0; 31 32 foreach ($items as $item) 33 { 34 $orderItem = OrderItem::create([ 35 "order_id" => $order->id, 36 "product_id" => $item->product_id, 37 "quantity" => $item->quantity 38 ]); 39 40 $product = $orderItem->product; 41 42 $orderItem->price = $product->price; 43 $orderItem->save(); 44 45 $product->stock -= $item->quantity; 46 $product->save(); 47 48 $total += $orderItem->quantity * $orderItem->price; 49 } 50 51 $result = $this->gateway->pay( 52 Input::get("number"), 53 Input::get("expiry"), 54 $total, 55 "usd" 56 ); 57 58 if (!$result) 59 { 60 return Response::json([ 61 "status" => "error", 62 "errors" => [ 63 "gateway" => [ 64 "Payment error" 65 ] 66 ] 67 ]); 68 } 69 70 $account = $order->account; 71 72 $document = $this->document->create($order); 73 $this->messenger->send($order, $document); 74 75 return Response::json([ 76 "status" => "ok", 77 "order" => $order->toArray() 78 ]); 79 } 80 81 return Response::json([ 82 "status" => "error", 83 "errors" => $validator->errors()->toArray() 84 ]); 85 } There are a few steps taking place here:
The main purpose of this endpoint is to create the order (and order items) while passing the payment off to the service provider classes.
This leaves us with the service-provider part of things. I’ve gone through the motions to hook everything up (as you might have done following on from the chapter which covered this); and here is a list of the changes:
1 "providers" => array( 2 3 // ... 4 5 "Formativ\Billing\BillingServiceProvider" 6 7 ), 1 "autoload" : { 2 3 // ... 4 5 "psr-0": { 6 "Formativ\\Billing": "workbench/formativ/billing/src/" 7 } 8 } 1 <?php 2 3 namespace Formativ\Billing; 4 5 use App; 6 use Illuminate\Support\ServiceProvider; 7 8 class BillingServiceProvider 9 extends ServiceProvider 10 { 11 protected $defer = true; 12 13 public function register() 14 { 15 App::bind("billing.stripeGateway", function() { 16 return new StripeGateway(); 17 }); 18 19 App::bind("billing.pdfDocument", function() { 20 return new PDFDocument(); 21 }); 22 23 App::bind("billing.emailMessenger", function() { 24 return new EmailMessenger(); 25 }); 26 } 27 28 public function provides() 29 { 30 return [ 31 "billing.stripeGateway", 32 "billing.pdfDocument", 33 "billing.emailMessenger" 34 ]; 35 } 36 } 1 <?php 2 3 namespace Formativ\Billing; 4 5 interface GatewayInterface 6 { 7 public function pay( 8 $number, 9 $expiry, 10 $amount, 11 $currency 12 ); 13 } 1 <?php 2 3 namespace Formativ\Billing; 4 5 interface DocumentInterface 6 { 7 public function create($order); 8 } 1 <?php 2 3 namespace Formativ\Billing; 4 5 interface MessengerInterface 6 { 7 public function send( 8 $order, 9 $document 10 ); 11 } These are all the interfaces (what some might consider the scaffolding logic) that we need. Let’s make some payments!
As I’ve mentioned; we’re going to receive payments through Stripe. You can create a new Stripe account at: https://manage.stripe.com/register.
You should already have the Stripe libraries installed, so let’s make a GatewayInterface implementation:
1 <?php 2 3 namespace Formativ\Billing; 4 5 use Stripe; 6 use Stripe_Charge; 7 8 class StripeGateway 9 implements GatewayInterface 10 { 11 public function pay( 12 $number, 13 $expiry, 14 $amount, 15 $currency 16 ) 17 { 18 Stripe::setApiKey("..."); 19 20 $expiry = explode("/", $expiry); 21 22 try 23 { 24 $charge = Stripe_Charge::create([ 25 "card" => [ 26 "number" => $number, 27 "exp_month" => $expiry[0], 28 "exp_year" => $expiry[1] 29 ], 30 "amount" => round($amount * 100), 31 "currency" => $currency 32 ]); 33 34 return true; 35 } 36 catch (Exception $e) 37 { 38 return false; 39 } 40 } 41 } Using the document (found at: https://github.com/stripe/stripe-php); we’re able to create a test charge which goes through the Stripe payment gateway. You should be able to submit orders through the the interface we’ve created and actually see them on your Stripe dashboard.
The last thing left to do is generate and email the invoice. We’ll begin with the PDF generation, using DOMPDF and ordinary views:
1 public function getTotalAttribute() 2 { 3 return $this->quantity * $this->price; 4 } 1 public function getTotalAttribute() 2 { 3 $total = 0; 4 5 foreach ($this->orderItems as $orderItem) 6 { 7 $total += $orderItem->price * $orderItem->quantity; 8 } 9 10 return $total; 11 } 1 <!doctype html> 2 <html lang="en"> 3 <head> 4 <meta charset="utf-8" /> 5 <title>Laravel 4 E-Commerce</title> 6 <style type="text/css"> 7 8 body { 9 padding : 25px 0; 10 font-family : Helvetica; 11 } 12 13 td { 14 padding : 0 10px 0 0; 15 } 16 17 * { 18 float : none; 19 } 20 21 </style> 22 </head> 23 <body> 24 <div class="container"> 25 <div class="row"> 26 <div class="col-md-8"> 27 28 </div> 29 <div class="col-md-4 well"> 30 <table> 31 <tr> 32 <td class="pull-right"> 33 <strong>Account</strong> 34 </td> 35 <td> 36 {{ $order->account->email }} 37 </td> 38 </tr> 39 <tr> 40 <td class="pull-right"> 41 <strong>Date</strong> 42 </td> 43 <td> 44 {{ $order->created_at->format("F jS, Y"); }} 45 </td> 46 </tr> 47 </table> 48 </div> 49 </div> 50 <div class="row"> 51 <div class="col-md-12"> 52 <h2>Invoice {{ $order->id }}</h2> 53 </div> 54 </div> 55 <div class="row"> 56 <div class="col-md-12"> 57 <table class="table table-striped"> 58 <thead> 59 <tr> 60 <th>Product</th> 61 <th>Quantity</th> 62 <th>Amount</th> 63 </tr> 64 </thead> 65 <tbody> 66 @foreach ($order->orderItems as $orderItem) 67 <tr> 68 <td> 69 {{ $orderItem->product->name }} 70 </td> 71 <td> 72 {{ $orderItem->quantity }} 73 </td> 74 <td> 75 $ {{ number_format($orderItem->total, 2) }} 76 </td> 77 </tr> 78 @endforeach 79 <tr> 80 <td> </td> 81 <td> 82 <strong>Total</strong> 83 </td> 84 <td> 85 <strong>$ {{ number_format($order->total, 2) }}</strong> 86 </td> 87 </tr> 88 </tbody> 89 </table> 90 </div> 91 </div> 92 </div> 93 </body> 94 </html> This view just displays information about the order, including items, totals and a grand total. I’ve avoided using Bootstrap since it seems to kill DOMDPF outright. The magic, however, is in how the PDF document is generated:
1 <?php 2 3 namespace Formativ\Billing; 4 5 class PDFDocument 6 implements DocumentInterface 7 { 8 public function create($order) 9 { 10 $view = View::make("email/invoice", [ 11 "order" => $order 12 ]); 13 14 define("DOMPDF_ENABLE_AUTOLOAD", false); 15 16 require_once base_path() . "/vendor/dompdf/dompdf/dompdf_config.inc.php"; 17 18 $dompdf = new DOMPDF(); 19 $dompdf->load_html($view); 20 $dompdf->set_paper("a4", "portrait"); 21 22 $dompdf->render(); 23 $results = $dompdf->output(); 24 25 $temp = storage_path() . "/order-" . $order->id . ".pdf"; 26 file_put_contents($temp, $results); 27 28 return $temp; 29 } 30 } We generate a PDF invoice by rendering the invoice view; setting the page size (and orientation) and rendering the document. We’re also saving the PDF document to the app/storage/cache directory.
Last thing to hook up is the MessengerInterface implementation:
1 Here's your invoice! 1 <?php 2 3 namespace Formativ\Billing; 4 5 use Mail; 6 7 class EmailMessenger 8 implements MessengerInterface 9 { 10 public function send( 11 $order, 12 $document 13 ) 14 { 15 Mail::send("email/wrapper", [], function($message) use ($order, $document) 16 { 17 $message->subject("Your invoice!"); 18 $message->from("[email protected]", "Laravel 4 E-Commerce"); 19 $message->to($order->account->email); 20 21 $message->attach($document, [ 22 "as" => "Invoice " . $order->id, 23 "mime" => "application/pdf" 24 ]); 25 }); 26 } 27 } The EmailMessenger class sends a simple email to the account, attaching the PDF invoice along the way.