Introduction
This article describes how to create an online shop system with the Scavix Web Development Framework in PHP. All codes are hosted in the GitHub repository, so you can easily browse them there. You should read this basics article first to get the basics of the Scavix Web Development Framework.
Audience
Experienced software developers with solid PHP, JavaScript and SQL knowledge.
The Task
"We need a shop, nothing complicated"
We often hear that from our customers and we're pretty sure you know that too. Fortunately we can show you how to set up a basic shop system using the Scavix WebFramework in no time. This sample shows you the basics so that you can then add all extra stuff for the shop you need. This gives you the freedom to start with a very basic slim shop code and then to extend it with all the addional logic for your purpose.
Requirements to the online shop are nearly the same most of the time:
- Products listing
- Product details page
- Each product has a title, tagline, description, price and an image.
- Shopping basket
- Simple administration
- Different payment providers
All of this can be done really quick and easy. Of course there's much more, but this is the heart of nearly every shop system. If these basics are implemented, all adaptations are kind of easy.
Great shop systems (and there are loads of very good shop systems out there) can do all that but need to be adjusted for every customer. These adjustments can eat up many days while implementing a small lightweight specialized shop would have been implemented in the same or less time. Additionally, the full-fledged-online shop systems out there have loads of functions that are often not needed, especially when you have a shop with only a few products.
Well, we won't discuss the pros and cons of other shop systems here, but just start with the...
Database scheme
It's quite common so we'll let an ERM speak for us:
As you can see, we will start without user registration but only let customers enter their address details. We'll also skip the administrative tables (for admin users and stuff) and just rely on hard-coded credentials for the admin part for now.
Of course that is a no-go for live shops, but as mentioned earlier we want to focus on the shop basics.
The sample code over at GitHub contains a function that will ensure the database structure is present and filled with some sample data in a SQLite database. Note that the above image just shows the basic idea behind the DB, not the real structure, so please do not blame us for fields you'll find in the code but not in the image :).
Basic setup
As always when working with the Scavix Web Development Framework, you will need three files: index.php, config.php, and a (in fact optional) .htaccess file.
- index.php contains the database setup code mentioned above but nothing else you don't already know.
- config.php sets up the Scavix Web Development Framework and
- .htaccess is needed for nice URLs.
All of this is described in detail over here: Ultra-Rapid PHP Application Development.
Structure
We will need three controllers: Products, Basket and Admin.
Of course if you would split up into other logical units you may do that too!
Anyway: there will be a product listing, product details pages, a shopping basket page and an administration. From the basket page customers may start the payment process that will require some more pages to collect customers data.
The layout
We wont spend much time to create a nice layout for our sample shop. Just a base class for the controllers and a few lines of CSS code will be enough for now:
<?php
use ScavixWDF\Base\HtmlPage;
class ShopBase extends HtmlPage { }
?>
<div id="page">
<div id="navigation">
<a href="<?=buildQuery('Products')?>">Products</a>
<a href="<?=buildQuery('Basket')?>">Basket</a>
<a href="<?=buildQuery('Admin')?>">Administration (normally hidden)</a>
</div>
<div id="content">
<? foreach( $content as $c ) echo $c; ?>
</div>
</div>
#page { font: 14px normal Verdana,Arial,serif; }
#page > div { width: 960px; margin: auto; }
#navigation { padding-bottom: 10px; border-bottom: 1px solid gray; }
#navigation a
{
font-size: 18px;
font-weight: bold;
margin-right: 15px;
}
.product_overview
{
clear: both;
margin-top: 25px;
border-bottom: 1px solid gray;
height: 75px;
}
.product_overview img
{
width: 50px;
float: left;
margin-right: 10px;
}
.product_overview div
{
white-space: nowrap;
overflow: hidden;
}
.product_overview .title { font-weight: bold; }
.product_overview a { float: right; }
.product_basket
{
clear: both;
}
.product_basket img
{
width: 30px;
float: left;
margin-right: 10px;
}
.basket_total
{
clear: both;
text-align: right;
font-size: 14px;
font-weight: bold;
}
So for now ShopBase
is just a central base class so that all derived classes will inherit the same layout. Of course it's always a good idea to share common CSS and program logic using inheritance, so that's no work for the bin.
See the dirty hard-coded navigation links? That will be another task for later.
There could be links only shown to verified users or perhaps the basket link could be hidden when empty. But as mentioned earlier: we don't want to loose the focus.
Products
We will implement two pages here: A product listing and a product details page.
<?php
use ScavixWDF\Base\Template;
use ScavixWDF\JQueryUI\uiMessage;
class Products extends ShopBase
{
function Index($error)
{
if( $error )
$this->content(uiMessage::Error($error));
$ds = model_datasource('system');
foreach( $ds->Query('products')->orderBy('title') as $prod )
{
$this->content( Template::Make('product_overview') )
->set('title',$prod->title)
->set('tagline',$prod->tagline)
->set('image',resFile($prod->image))
->set('link',buildQuery('Products','Details',array('id'=>$prod->id)))
;
}
}
function Details($id)
{
$ds = model_datasource('system');
$prod = $ds->Query('products')->eq('id',$id)->current();
if( !$prod )
redirect('Products','Index',array('error'=>'Product not found'));
$this->content( Template::Make('product_details') )
->set('title',$prod->title)
->set('description',$prod->body)
->set('image',resFile($prod->image))
->set('link',buildQuery('Basket','Add',array('id'=>$prod->id)))
;
}
}
No magic in there, pretty straight code: the 'Index' method loops through all the products in the database and displays each of them using the template 'product_overview'. If you are not familiar with this please see the WDF basics again.
<div class="product_overview">
<img src="<?=$image?>" alt=""/>
<div class="title"><?=$title?></div>
<div class="tagline"><?=$tagline?></div>
<a href="<?=$link?>">Details...</a>
</div>
The 'Details' method uses a similar approach but does not loop, but load a single product. It also uses another template file.
<div class="product_details">
<img src="<?=$image?>" alt=""/>
<div class="title"><?=$title?></div>
<div class="description"><?=$description?></div>
<a href="<?=$link?>">Add to basket</a>
<a href="javascript:history.back()">back to listing</a>
</div>
Well....that's it: we have implemented the complete products part.
The products listing page:
And the product's details page:
The Basket
Like almost any other shop out there we want a shopping basket. Simple principle: customers add products to it and can then change the amount of each product in the basket. Decreasing the amount to zero will remove the product from the basket. Of course that could be better but....you already know that 'keep the focus' sentence :).
So straight for the code again:
<?php
use ScavixWDF\Base\Template;
use ScavixWDF\JQueryUI\uiButton;
use ScavixWDF\JQueryUI\uiMessage;
class Basket extends ShopBase
{
function Index($error)
{
if( $error )
$this->content(uiMessage::Error($error));
if( !isset($_SESSION['basket']) )
$_SESSION['basket'] = array();
if( count($_SESSION['basket']) == 0 )
$this->content(uiMessage::Hint('Basket is empty'));
else
{
$ds = model_datasource('system');
$price_total = 0;
foreach( $_SESSION['basket'] as $id=>$amount )
{
$prod = $ds->Query('products')->eq('id',$id)->current();
$this->content( Template::Make('product_basket') )
->set('title',$prod->title)
->set('amount',$amount)
->set('price',$prod->price)
->set('image',resFile($prod->image))
->set('add',buildQuery('Basket','Add',array('id'=>$prod->id)))
->set('remove',buildQuery('Basket','Remove',array('id'=>$prod->id)))
;
$price_total += $amount * $prod->price;
}
$this->content("<div
class='basket_total'>Total price: $price_total</div>");
$this->content( uiButton::Make("Buy now") )->onclick =
"location.href = '".buildQuery('Basket','BuyNow')."'";
}
}
function Add($id)
{
$ds = model_datasource('system');
$prod = $ds->Query('products')->eq('id',$id)->current();
if( !$prod )
redirect('Basket','Index',array('error'=>'Product not found'));
if( !isset($_SESSION['basket'][$id]) )
$_SESSION['basket'][$id] = 0;
$_SESSION['basket'][$id]++;
redirect('Basket','Index');
}
function Remove($id)
{
$ds = model_datasource('system');
$prod = $ds->Query('products')->eq('id',$id)->current();
if( !$prod )
redirect('Basket','Index',array('error'=>'Product not found'));
if( isset($_SESSION['basket'][$id]) )
$_SESSION['basket'][$id]--;
if( $_SESSION['basket'][$id] == 0 )
unset($_SESSION['basket'][$id]);
redirect('Basket','Index');
}
}
Again the 'Index' method provides a listing, this time the source is a SESSION variable that contain key-value pairs of product IDs and their quantity.
The 'Add' and 'Remove' methods increase and decrease these quantities of the product in the basket.
The rest of the above code is checking if the product exists and if variables are present.
Finally to mention: Again we use a special template for the order items' listing:
<div class="product_basket">
<img src="<?=$image?>" alt=""/>
<span class="title"><?=$title?></span>
<span class="amount">Amount: <?=$amount?></span>
<span class="amount">Price: <?=$price?></span>
<span class="amount">Total: <?=$amount * $price?></span>
<a href="<?=$add?>">add one more</a>
<a href="<?=$remove?>">remove one</a>
</div>
That's it for the listing/editing part of the basket. The result looks like this:
Checkout
Well that's actually the most interesting part when implementing a shop system: How to get the customer's money. The sample provides you with interfaces to PayPal and Gate2Shop (because we worked with them in the past) and can be easily extended to support other providers too. For the development stage there's a testing payment provider too.
The basic workflow for order payment is:
- Get the customer's address data
- Store it along with the basket data into the database
- Start the checkout process for the selected payment provider
- React on payment messages from the provider
Complicated? No:
<?php
use ScavixWDF\Base\Template;
use ScavixWDF\JQueryUI\uiButton;
use ScavixWDF\JQueryUI\uiMessage;
class Basket extends ShopBase
{
function BuyNow()
{
$this->content( Template::Make('checkout_form') );
}
function StartCheckout($fname,$lname,$street,$zip,$city,$email,$provider)
{
if( !$fname || !$lname || !$street || !$zip || !$city || !$email )
redirect('Basket','Index',array('error'=>'Missing some data'));
$cust = new SampleCustomer();
$cust->fname = $fname;
$cust->lname = $lname;
$cust->street = $street;
$cust->zip = $zip;
$cust->city = $city;
$cust->email = $email;
$cust->price_total = 0;
$cust->Save();
$order = new SampleShopOrder();
$order->customer_id = $cust->id;
$order->created = 'now()';
$order->Save();
$ds = model_datasource('system');
foreach( $_SESSION['basket'] as $id=>$amount )
{
$prod = $ds->Query('products')->eq('id',$id)->current();
$item = new SampleShopOrderItem();
$item->order_id = $order->id;
$item->price = $prod->price;
$item->amount = $amount;
$item->title = $prod->title;
$item->tagline = $prod->tagline;
$item->body = $prod->body;
$item->Save();
$order->price_total += $amount * $prod->price;
}
$order->Save();
$_SESSION['basket'] = array();
log_debug("Handing control over to payment provider '$provider'");
$p = new $provider();
$p->StartCheckout($order,buildQuery('Basket','PostPayment'));
}
function PostPayment()
{
log_debug("PostPayment",$_REQUEST);
$this->content("<h1>Payment processed</h1>");
$this->content("Provider returned this data:<br/>" +
"<pre>".render_var($_REQUEST)."</pre>");
}
function Notification($provider)
{
log_debug("Notification",$_REQUEST);
$provider = new $provider();
if( $provider->HandleIPN($_REQUEST) )
die("OK");
die("ERR");
}
}
That code creates a form asking for the customers address data and which payment provider he wants to use:
So far, so good. If you click 'Buy now' (with the Testing provider, as the others are missing some config) you will see nothing spectacular, but an order will be created.
To make it work in this simple manner the SampleShopOrder
class must implement the IShopOrder
interface. It provides some simple methods that allows generic handling of the payment process:
<?php
use ScavixWDF\Model\Model;
use ScavixWDF\Payment\IShopOrder;
use ScavixWDF\Payment\ShopOrderAddress;
class SampleShopOrder extends Model implements IShopOrder
{
const UNKNOWN = 0;
const PENDING = 10;
const PAID = 20;
const FAILED = 30;
const REFUNDED = 40;
public function GetTableName() { return 'orders'; }
public function GetAddress()
{
$res = new ShopOrderAddress();
$res->Firstname = $this->fname;
$res->Lastname = $this->lname;
$res->Address1 = $this->street;
$res->Zip = $this->zip;
$res->City = $this->city;
$res->Email = $this->email;
return $res;
}
public function GetCurrency() { return 'EUR'; }
public function GetInvoiceId() { return "I".$this->id; }
public function GetLocale() { return 'en-US'; }
public function GetTotalPrice($price = false)
{
if( $price !== false )
return $price * ( (1+$this->GetVatPercent()) / 100 );
return $this->price_total * ( (1+$this->GetVatPercent()) / 100 );
}
public function GetTotalVat() { return $this->price_total * ($this->GetVatPercent()/100); }
public function GetVatPercent() { return 19; }
public function ListItems() { return SampleShopOrderItem::Make()->eq('order_id',$this->id)->orderBy('id'); }
public function SetCurrency($currency_code) { }
public static function FromOrderId($order_id)
{
return SampleShopOrder::Make()->eq('id',$order_id)->current();
}
public function SetFailed($payment_provider_type, $transaction_id, $statusmsg = false)
{
$this->status = self::FAILED;
$this->updated = $this->deleted = 'now()';
$this->Save();
}
public function SetPaid($payment_provider_type, $transaction_id, $statusmsg = false)
{
$this->status = self::PAID;
$this->updated = $this->completed = 'now()';
$this->Save();
}
public function SetPending($payment_provider_type, $transaction_id, $statusmsg = false)
{
$this->status = self::PENDING;
$this->updated = 'now()';
$this->Save();
}
public function SetRefunded($payment_provider_type, $transaction_id, $statusmsg = false)
{
$this->status = self::REFUNDED;
$this->updated = $this->deleted = 'now()';
$this->Save();
}
public function DoAddVat() { return true; }
}
You will also need to create a class for order items, our is called SampleShopOrderItem
and implements the IShopOrderItem
interface.
<?php
use ScavixWDF\Model\Model;
use ScavixWDF\Payment\IShopOrderItem;
class SampleShopOrderItem extends Model implements IShopOrderItem
{
public function GetTableName() { return 'items'; }
public function GetAmount($currency) { return $this->price; }
public function GetDiscount() { return 0; }
public function GetHandling() { return 0; }
public function GetName() { return $this->title; }
public function GetQuantity() { return $this->amount; }
public function GetShipping() { return 0; }
}
Finally the payment module has to be configured:
<?php
$CONFIG["payment"]["order_model"] = 'SampleShopOrder';
$CONFIG["payment"]["gate2shop"]["merchant_id"] = '<your_merchant_id>';
$CONFIG["payment"]["gate2shop"]["merchant_site_id"] = '<your_merchant_site_id>';
$CONFIG["payment"]["gate2shop"]["secret_key"] = '<your_secret_key>';
$CONFIG["payment"]["paypal"]["paypal_id"] = '<your_paypal_id>';
$CONFIG["payment"]["paypal"]["notify_handler"] = array('Basket','Notification');
The Administration
For a shop system you will need to be able to create products and have some kind of access to your customers data and their orders.
Once you enter the administrative page it will ask you for credentials and (as mentioned above) they are hardcoded: use 'admin' as username and 'admin' as password.
<?php
use ScavixWDF\Base\AjaxAction;
use ScavixWDF\Base\AjaxResponse;
use ScavixWDF\Base\Template;
use ScavixWDF\Controls\Form\Form;
use ScavixWDF\JQueryUI\Dialog\uiDialog;
use ScavixWDF\JQueryUI\uiButton;
use ScavixWDF\JQueryUI\uiDatabaseTable;
use ScavixWDF\JQueryUI\uiMessage;
class Admin extends ShopBase
{
private function _login()
{
if( $_SESSION['logged_in'] )
return true;
redirect('Admin','Login');
}
function Login($username,$password)
{
if( $username && $password )
{
if( $username==cfg_get('admin','username') && $password==cfg_get('admin','password') )
{
$_SESSION['logged_in'] = true;
redirect('Admin');
}
$this->content(uiMessage::Error("Unknown username/passsword"));
}
$form = $this->content(new Form());
$form->content("Username:");
$form->AddText('username', '');
$form->content("<br/>Password:");
$form->AddPassword('password', '');
$form->AddSubmit("Login");
}
}
Each method in the Admin class calls the '_login' method, which redirects to 'Admin/Login' if no admin user has logged in. That method builds a login form without a template, just using plain Control syntax.
Really ugly in such raw development state, but works.
Now we will start with the first part mentioned above: the product management.
<?php
use ScavixWDF\Base\AjaxAction;
use ScavixWDF\Base\AjaxResponse;
use ScavixWDF\Base\Template;
use ScavixWDF\Controls\Form\Form;
use ScavixWDF\JQueryUI\Dialog\uiDialog;
use ScavixWDF\JQueryUI\uiButton;
use ScavixWDF\JQueryUI\uiDatabaseTable;
use ScavixWDF\JQueryUI\uiMessage;
class Admin extends ShopBase
{
function Index()
{
$this->_login();
$this->content("<h1>Products</h1>");
$this->content(new uiDatabaseTable(model_datasource('system'),false,'products'))
->AddPager(10)
->AddRowAction('trash', 'Delete', $this, 'DelProduct');
$this->content(uiButton::Make('Add product'))->onclick = AjaxAction::Post('Admin', 'AddProduct');
$this->content("<h1>Orders</h1>");
$this->content(new uiDatabaseTable(model_datasource('system'),false,'orders'))
->AddPager(10)
->OrderBy = 'id DESC';
$this->content("<h1>Customers</h1>");
$this->content(new uiDatabaseTable(model_datasource('system'),false,'customers'))
->AddPager(10)
->OrderBy = 'id DESC';
}
function AddProduct($title,$tagline,$body,$price)
{
$this->_login();
if( $title && $tagline && $body && $price )
{
if( isset($_FILES['image']) && $_FILES['image']['name'] )
{
$i = 1; $image = __DIR__.'/../images/'.$_FILES['image']['name'];
while( file_exists($image) )
$image = __DIR__.'/../images/'.($i++).'_'.$_FILES['image']['name'];
move_uploaded_file($_FILES['image']['tmp_name'], $image);
$image = basename($image);
}
else
$image = '';
$ds = model_datasource('system');
$ds->ExecuteSql("INSERT INTO products(title,tagline,body,image,price)VALUES(?,?,?,?,?)",
array($title,$tagline,$body,$image,$price));
redirect('Admin');
}
$dlg = new uiDialog('Add product',array('width'=>600,'height'=>450));
$dlg->content( Template::Make('admin_product_add') );
$dlg->AddButton('Add product', "$('#frm_add_product').submit()");
$dlg->AddCloseButton("Cancel");
return $dlg;
}
function DelProduct($table,$action,$model,$row)
{
$this->_login();
default_string('TITLE_DELPRODUCT','Delete Product');
default_string('TXT_DELPRODUCT','Do you really want to remove this product? This cannot be undone!');
if( !AjaxAction::IsConfirmed('DELPRODUCT') )
return AjaxAction::Confirm('DELPRODUCT', 'Admin', 'DelProduct', array('model'=>$model));
$ds = model_datasource('system');
$prod = $ds->Query('products')->eq('id',$model['id'])->current();
$prod->Delete();
if( $prod->image )
{
$image = __DIR__.'/../images/'.$prod->image;
if( file_exists($image) )
unlink($image);
}
return AjaxResponse::Redirect('Admin');
}
}
The 'Index
' method creates a database table and a button to add another product. Simple but effective. The databasetable control allows us to add a row action, so we use that for a 'delete' trigger that will call 'DelProduct' when clicked.
A click on the 'Add product' button will display a dialog with a form to enter all product data. Then (when dialog is accepted) the new product will be added to the database and the browser is redirected to refresh the product listing.
And guess what: that's it for the products admin basic part. Of course we are missing the 'edit' functionality and much more, but it shows the way we need to go for a full admin interface.
Following that thought, we just display two more tables here: Orders and Customers. Well...just to show the basic idea:
What's next?
That's enough for a sample, but how do you go on? Well there are some 'standard' things to do:
- Register at PayPal and/or Gate2Shop and update the shop configuration so that they become usable
- Implement more code that make the payment workflow useful (send emails,...)
- Extend the Admin-Interface to be able to manage products and orders
- ....
Change-log
- 2013/05/8: Initial publishing
- 2013/05/23: Fixed broken links, updated code snippets to match the newest version
- 2014/10/31: Added namespacing code