Linux Do-It-Yourself: Part XV - Easy E-Commerce with PHP

Building on previous articles in the series, author Scott Courtney will demonstrate how to build PayPal forms using PHP and MySQL and how to extract data from the catalog. (July 2003, PDF)

 

Do-It-Yourself Part XIV: A Virtual Shopping Cart Using PHP Session Variables

by Scott Courtney

As of last month’s installment, Ice Floe Housing is up and running, with an online catalog web site and a user-friendly form to maintain the database. Penny Penguin is well on her way to having a web-savvy business! This month, we’ll see how to use session variables in PHP to create an efficient virtual “shopping basket” for the Ice Floe Housing web site. Let’s begin by understanding three things: why we need session variables, how they work in PHP, and why this is a good approach to implementing a web shopping basket.

Most web pages are fetched from a server at the request of the client browser using HyperText Transfer Protocol, or HTTP, the current version of which is 1.1. HTTP is a simple, text-oriented protocol that actually has a lot in common with the old Telnet protocol. This is typical of many Internet application protocols, and offers the advantage of easy debugging using the standard Telnet client. A simple transaction in HTTP looks something like this:

 

GET /catalog.php HTTP/1.1
Host: www.icefloehousing.com

HTTP/1.1 200 OK
Date: Fri, 04 Apr 2003 22:10:09 GMT
Server: Apache/1.3.26 (Unix) PHP/4.2.3
X-Powered-By: PHP/4.2.3
Set-Cookie: PHPSESSID=4417051a204a24e289848c1ad8b15a17; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: text/html

<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
<meta http-equiv="description" content="Ice Floe Housing offers residential structures for cold climates.">
<meta http-equiv="keywords" content="igloos, Ice Floe Housing, ice, homes">
<title>Ice Floe Housing, Inc.</title>
</head>

(rest of response removed for brevity)

A simple HTTP request, response headers, and response

The “Host:” header indicates to the server which web server is being contacted. Modern web servers often have multiple sites on a single physical computer, so “www.domain1.com” and “www.sample.com” might both connect to the same IP address. IP connections are made to a numeric address, not to a hostname, so the “Host:” line is needed to allow the web server software (in this case, Apache) to know which of the virtual servers is desired. The HTTP protocol has other headers that can be specified in the request and in the response, specifying the preferred language and character sets, the revision dates of documents, and other “metadata” (that is, information about the document, which is not actually part of the document).

Regardless of the specifics, however, there is one major problem with using HTTP for transactional applications, such as a virtual storefront. HTTP treats each transaction completely independently from every other. That is, each time the user selects a new URL, or reloads the previous URL, the web server sees a completely new connection. With HTTP 1.1, it is possible to fetch multiple documents (such as an HTML page and associated graphics) with a single socket connection, but this doesn’t change the fundamentals of the process, because the connection is dropped after a short time (typically just a couple of seconds) of inactivity, or when the browser has all the files it needs to render the current page for the user.

So, if the user adds an item to a virtual shopping basket, then goes back to the catalog, the server would have no way to “remember” that item. Even storing the entry in a database won’t help, because the browser’s next request to the server has no association to its previous request. IP address of client? Nope! Some clients, such as Linux or Unix, are multiuser, and even on Windows, the same person may have more than one browser running at a time. Behind a firewall, using masqueraded addresses, there may be hundreds or even thousands of computers that appear to have the same IP address, from a web server’s perspective.

One solution to this thorny problem is to use a pseudo-random identity number, generated each time a particular instance of the browser first connects to a particular web server. This “session ID” is passed back and forth between the browser and the server with each transaction, establishing persistent identity for the browser for as long as neither it nor the server is restarted. This does not uniquely identify the human being who is using the browser, so it is still possible (and quite common) to browse a web site anonymously (that is, without being logged in with a personal account) and still have an active session.

The HTTP protocol has no intrinsic support for sessions, but there are two ways the session ID can be transferred. One is to encode it within every URL link on the site, as a GET parameter. This works, but makes the URLs really long and ugly (www.sample.com/catalog.php?session=24f7ca5f6ff1a5afb9032 for example). It also means that URLs saved in a bookmark (or favorites) list will contain a session identifier that won’t be valid on the next visit to the site.

A better approach is the use of a “cookie,” which is simply a very small piece of data stored by the server on the client’s disk drive, and automatically sent by the browser with each future request header in the same domain where the cookie was generated. There is a cookie sent with the response headers in the previous example. Look for the line with “Set-Cookie: PHPSESSID=”. If the browser accepts the cookie, all subsequent requests during the current session will contain that session ID value. PHP is smart enough to automatically rewrite all of the URLs on the site, before sending the data to the browser, if the browser happens not to accept this cookie.

It’s important to understand that the session ID is the only data sent back and forth with the cookie. PHP allows server-side programs to store large amounts of data in a built-in array called $_SESSION[], but the array itself is not sent back and forth. This has two advantages. First, it is impossible for the browser to directly read or write the data in this array; only the PHP scripts on the server can do that. Session variables are therefore fairly secure, if the PHP code is written sensibly. Second, sending only the session ID saves a great deal of network bandwidth on each HTTP transaction.

So session variables are a great place to store the temporary data for an online shopping basket! Another advantage is that, if the user changes his or her mind and goes elsewhere on the web, or shuts down the browser, or loses the Internet connection altogether, the session will automatically expire and be deleted after a period of time. If the temporary data went into MySQL tables, there would need to be code in the application to manage that process.

Penny Penguin can easily create her shopping basket, with PHP’s session support doing most of the hard work. The support functions are in /home/penny/icefloehousing.org/include/basket.inc and the actual shopping basket page is in /home/penny/icefloehousing.org/include/basket.php (don’t be confused by the similar filenames). Penny also has to edit the header.inc file so that it refers to basket.inc in a new include() statement. Also added to header.inc is the following very simple line:

 

session_start();

This line informs PHP that we intend to use the session features and that it should attempt to send the appropriate cookie to the browser or modify linked URLs as needed. It is possible to configure PHP to have this happen automatically with every page, but it doesn’t hurt to add this line to your code just to be safe.

The heavy lifting for the shopping basket is done in basket.inc. The initBasket() and clearBasket() functions ensure that a sub-array, with the name “BASKET”, exists within the automatically-defined $_SESSION[] array. Note that $_SESSION[], like PHP’s other built-in arrays, is automatically declared global to all functions. clearBasket() can be called separately to empty the shopping basket.

 

function initBasket() {
	if (! is_array($_SESSION["BASKET"])) {
		clearBasket();
	}
}

function clearBasket() {
	$_SESSION["BASKET"] = array();
}

initBasket() makes sure the shopping basket data array exists, while clearBasket() empties it.

The setBasket() function modifies the quantity of an item in the shopping basket array. Each element of the basket array has a catalog number (cat_number column from the database) as a key, and the current order quantity as the value. setBasket() can either add a new item, change the quantity of an existing item, or delete an item. The function deletes the entry for any item whose quantity is being set to zero.

 

function setBasket($cat_num,$qty) {
	# Be sure the basket array exists
	initBasket();
	if ($qty == 0) {
		unset($_SESSION["BASKET"][$cat_num]);
	} else {
		$_SESSION["BASKET"][$cat_num] = $qty;
	}
}

setBasket() provides an easy way to manage entries in the shopping basket array.

These are fairly simple functions. Things get more interesting with getBasketCatalogData(). This function looks at the list of catalog numbers in the current shopping basket and builds an SQL query that will obtain detailed catalog information for each of these items. The statement is of the form “SELECT …. FROM …. WHERE cat_number IN (…)”, and all of the applicable catalog numbers are placed within that “IN” clause. This is an efficient way to select a very small number of records, having no common feature, from a table that might contain many thousands of rows. Note that the function is rather selective in what it returns -- the “summary” and “detail” columns from the catalog table are omitted, because we don’t need to show that information on the shopping basket page. Also, this function’s name begins with an ampersand (&) to indicate that it returns an array by reference rather than by value. This improves performance by eliminating a memory-to-memory copy.

 

function &getBasketCatalogData($db) {
	initBasket();
	$cat_num_list = array_keys($_SESSION["BASKET"]);
	$detail = array();
	if (count($cat_num_list)) {
		$in_list = implode(',',$cat_num_list);
		$in_list = "('" . ereg_replace(",","','",$in_list) . "')";
		$sql1 = "select catalog.cat_number, catalog.name name, catalog.price,";
		$sql1 .= " catalog.lot_size, catalog.lot_unit_price,";
		$sql1 .= " manufacturers.name mfg, product_types.name prod_type";
		$sql1 .= " from catalog left join manufacturers on catalog.mfg_id=manufacturers.id";
		$sql1 .= " left join product_types on catalog.product_type=product_types.id";
		$sql1 .= " having cat_number in " . $in_list;
		$sql1 .= " order by cat_number";
		$result = $db->getAll($sql1,DB_FETCHMODE_ASSOC);
		if (DB::isError($result)) {
			print("
ERROR: Database query failure. SQL=" . htmlspecialchars($sql1) . "
\n");
			$detail = array();
		} else {
			foreach($result as $row) {
				$cat_num = $row["cat_number"];
				$detail[$cat_num] = $row;
			}
		}
	}
	return $detail;
}

getBasketCatalogData() retrieves the detailed catalog information, minus the long text fields, for the items in the shopping basket.

The catalog data is returned from getBasketCatalogData() as an associative array, with the catalog number as the key. The function setQuantities() merges the quantity data from the session array $_SESSION[“BASKET”] into the catalog detail array, so that later code will have a unified array with all of the needed information.

 

function setQuantities(&$detail_array) {
	initBasket();
	reset($_SESSION["BASKET"]);
	while (list($cat_num,$qty) = each($_SESSION["BASKET"])) {
		$detail_array[$cat_num]["qty"] = $qty;
	}
}

setQuantities() merges the shopping cart information into the product detail array so that other code can use the combined data more easily.

Penny needs two more support functions, not shown here, to copy quantities between the shopping cart form (which allows the user to easily change how many of each item they want) and the session variable containing the actual shopping cart array. Similarly to what was done in last month’s catalog editing form, the field names are mangled by a string replacement function so that catalog numbers with dashes don’t produce invalid HTML code. The two functions, getFormQuantity() and setSessionQuantities(), are fairly simple and are available in the download file for this article.

The most interesting function is getShoppingBasketListHTML(). It takes the detail array, containing all the catalog data plus the quantities from the shopping basket, and turns it into HTML. This HTML is mostly just display fields in a table, except that the quantities are actually text input fields so that the user can change them. As each line of HTML (a table row per line) is generated, the function examines the order quantity to see if the user could save money by ordering all or part of that quantity in lots rather than as individual units. These savings are reflected in the unit and total costs, and are also pointed out to the shopper so that they know Penny is giving them a good deal on their purchase!

 

function getShoppingBasketListHTML($detail_array) {
	reset($detail_array);
	$savings = 0;
	$total_cost = 0;
	$lot_items = array();
	$html = "\n";
	$html .= "\n";
	$html .= "\t\n";
	$html .= "\n";
	while (list($cat_num,$row) = each($detail_array)) {
		$field_name = "FORM__QTY__" . $cat_num;
		$field_name = ereg_replace('-','___',$field_name);
		$lot_size = $row["lot_size"];
		if ($lot_size) {
			$lots = intval($row["qty"] / $lot_size);
			$non_lot_qty = $row["qty"] % $lot_size;
		} else {
			$lots = 0;
			$non_lot_qty = $row["qty"];
		}
		$extended_cost = $row["qty"] * $row["price"];
		if ($lots) {
			$lot_qty = $lots * $lot_size;
			$lot_cost = $lot_qty * $row["lot_unit_price"];
			$non_lot_cost = $non_lot_qty * $row["price"];
			$final_cost = $lot_cost + $non_lot_cost;
			$savings += ($extended_cost - $final_cost);
		} else {
			$final_cost = $extended_cost;
		}
		$total_cost += $final_cost;
		$unit_cost = $final_cost / $row["qty"];
		$html .= "\n";
		$html .= "\t\n";
		$html .= "\t\n";
		$html .= "\t\n";
		$html .= "\t\n";
		$html .= "\t\n";
		$html .= "\n";
	}
	# Now display some summary information at the bottom.
	$html .= "\n";
	$html .= "\t\n";
	$html .= "\t\n";
	$html .= "\t\n"; 
	$html .= "
Cat. Num. Description Quantity Price Each Final Cost
" . $row["cat_number"] . " " . htmlspecialchars($row["name"]); if ($lots) { $html .= "
"; $html .= "Savings of $" . sprintf("%9.2f",$extended_cost-$final_cost); $html .= " on this item by grouping into lots of " . $lot_size . "."; $html .= ""; } $html .= "
"; $html .= getTextFieldHTML($field_name,$row["qty"],5,5); $html .= " "; $price_html = '$' . sprintf("%8.2f",$unit_cost); $html .= ereg_replace(' ',' ',$price_html) . " "; $price_html = '$' . sprintf("%10.2f",$final_cost); $html .= ereg_replace(' ',' ',$price_html) . "
"; $html .= "Change quantities as desired above, then press Update to recalculate pricing."; $html .= " Enter a zero (0) quantity to delete any item."; if ($savings > 0) { $html .= "

Your order saves a total of $" . sprintf("%10.2f",$savings); $html .= " by grouping items into lot quantities. Unit prices shown above"; $html .= " are adjusted downward to reflect the discount."; } $html .= "

TOTAL COST $" . sprintf("%11.2f",$total_cost) . "

\n"; return $html; }

getShoppingBasketListHTML() turns the detail data array into multiple HTML table rows, including an extra summary row at the bottom of the table.

getShoppingBasketHTML() augments the results from getShoppingBasketListHTML() to create a complete HTML form with submit and reset buttons. Together, these two functions do almost all of the work for the basket.php page.

 

function getShoppingBasketHTML($detail_array) {
	$html = getFormTag();
	$html .= getShoppingBasketListHTML($detail_array);
	$html .= "
\n";
	$html .= getFormTableTag();
	$html .= "";
	$html .= "" . getButtonHTML("SUBMIT","UPDATE","Update Quantities") . "\n";
	$html .= "" . getButtonHTML("RESET","RESET","Reset") . "\n";
	$html .= "\n";
	$html .= "\n";
	$html .= getFormTag("/catalog.php","GET");
	$html .= getFormTableTag();
	$html .= "";
	$html .= "

getShoppingBasketHTML() finishes the process of creating the shopping basket display and edit form.

Once all the library functions are done, only two tasks remain. First, the catalog.php page from last month has to be modified to add a very simple form to its item detail page. That form has a text input field for the desired quantity, a hidden field for the catalog number, and a submit button that, when clicked, takes the user to the shopping basket page and carries along the catalog number and quantity. The changes are trivial, and they are reflected in the new version of catalog.php that is included with this month’s download file. Figure 1 shows how the new catalog detail page, with the option to add items to the shopping basket, will look in the browser.

The second task is to add the code framework in basket.php. This code simply responds to the POST request, updating quantities as needed. For the sake of simplicity, we don’t have a way for the user to actually place an order. To do that, all that is needed is to add a form with nothing but a submit-type button, taking the user to another script that would actually process the order. Because all of the data is in session variables, it is not necessary for the order data to be part of that form. The order processing script can actually use getBasketCatalogData() to query the database before placing the final order.

 

<?php
include($_SERVER["DOCUMENT_ROOT"] . "/../include/header.inc");

$db =& DB::connect("mysql://icefloe:tux@localhost/icefloe");
if (!is_object($db)) {
	die("ERROR Failed to create database object.\n");
}

initBasket();

# See if we are adding an item to the cart.
$add_cat_num = getSimpleFormValue("cat_number");
$add_qty = abs(intval(getSimpleFormValue("qty")));
if ($add_qty && !empty($add_cat_num)) {
	if (isset($_SESSION["BASKET"][$add_cat_num])) {
		$_SESSION["BASKET"][$add_cat_num] += $add_qty;
	} else {
		$_SESSION["BASKET"][$add_cat_num] = $add_qty;
	}
}

# Handle the update function, if any were posted.
setSessionQuantities();

if (!empty($_GET["cat_num"])) {
	setBasket($_GET["cat_num"],$_GET["qty"]);
}

$detail = getBasketCatalogData($db);
setQuantities($detail);


print(getShoppingBasketHTML($detail));

include($_SERVER["DOCUMENT_ROOT"] . "/../include/footer.inc");
?>

Since most of the work is done by the library functions and the session variable features of PHP itself, the basket.php page script is amazingly short and simple.

Penny has accomplished a lot, with relatively little code, by letting session variables do the hard part for the shopping basket. Figure 2 shows how the finished shopping basket page looks in the browser. While falling short of being a complete e-commerce or online shopping application, Penny’s simple shopping basket demonstrates a number of useful techniques that can be applied to other applications.

As with the other articles in this series, a complete source code archive can be downloaded. The code archive contains some functions not discussed in the text, due to space constraints, and also has more detailed comments about the workings of many of the library functions. The code is released as freeware under the GNU General Public License (http://www.gnu.org/licenses/gpl.html). Have fun!

FIGURE 1 (diy14-1.png) “The catalog item detail display now contains a simple form to allow the user to add this item to the shopping cart.”
FIGURE 2 (diy14-2.png) “The basket.php page lets the user review the contents of their basket, and change quantities as desired. It also shows discounts from buying in lot quantities.”