Radio Button Value Checked from a Database in PHP

Form input with PHP

When building software user interfaces, I create HTML forms to manage any app settings. I’ll usually have a few text and radio inputs like this:

settings input form

Using PHP, I grab a database record and access its properties to populate the form. Filling out the text inputs is straight-forward:

<?php $clickToCallRecord = $db_service->getClickToCallRecord($installation_complete_id); ?>

<div class="form-group">
    <label>Phone number:</label>
    <input class="form-control" type="tel" name="phone_number" value="<?php echo $clickToCallRecord['phone_number']; ?>" placeholder="(123)-456-7890">
</div>
<div class="form-group">
    <label>Message:</label>
    <input class="form-control" type="text" name="message_text" value="<?php echo $clickToCallRecord['message_text']; ?>" placeholder="Call Us Now">
</div>
<div class="form-group">
    <label>Background Color:</label>
    <input class="form-control" type="text" value="<?php echo $clickToCallRecord['bg_color']; ?>" name="bg_color">
</div>

Checking the correct radio input requires our code to evaluate the data value. I add a PHP variable to each of the inputs as attributes. Those variables will render to either “checked” or blank:

<?php
    $top_checked = "";
    $bottom_checked = "";
    $position = $clickToCallRecord['position'];
    if($position == "top"){
        $top_checked = "checked";
        $bottom_checked = "";
    }else{
        $top_checked = "";
        $bottom_checked = "checked";
    }

    $show_checked = "";
    $hide_checked = "";
    $display = $clickToCallRecord['display'];
    if($display == "show"){
        $show_checked = "checked";
        $hide_checked = "";
    }else{
        $show_checked = "";
        $hide_checked = "checked";
    }

?>

<div class="form-group">
    <label>Position:</label>
    <div>
        <div class="form-check form-check-inline">
          <input <?php echo $top_checked; ?> class="form-check-input" type="radio" name="position" value="top">
          <label class="form-check-label" for="matchtype">Top</label>
        </div>
        <div class="form-check form-check-inline">
          <input <?php echo $bottom_checked; ?> class="form-check-input" type="radio" name="position"  value="bottom">
          <label class="form-check-label" for="matchtype">Bottom</label>
        </div>
    </div>
</div>
<hr />
<div class="form-group">
    <label>Display:</label>
    <div>
        <div class="form-check form-check-inline">
          <input <?php echo $show_checked; ?> class="form-check-input" type="radio" name="display" value="show">
          <label class="form-check-label" for="matchtype">Show</label>
        </div>
        <div class="form-check form-check-inline">
          <input <?php echo $hide_checked; ?> class="form-check-input" type="radio" name="display"  value="hide">
          <label class="form-check-label" for="matchtype">Hide</label>
        </div>
    </div>
</div>

This example comes from a Shopify app that helps stores increase conversions by adding a UI element prompting customers to call a phone number.

How to create a Shopify app with PHP

Build a Shopify App with PHP

Developing a marketplace app for your SAAS will grow organic traffic and lets users find you. Potential customers can discover your digital product where they are already looking. Software services that occupy the eCommerce space have a chance to help Shopify store owners grow their businesses. Creating a public Shopify app benefits both developers and the merchant user-base in the Shopify App Store ecosystem.

Recently, Shopify announced that it is decreasing the share of profit that it takes from developers. Each year, developers keep all of their revenue up to the first one-million dollars.

Why Build a Marketplace App?

The short answer: Discoverability.

A few years ago, I built a fitness tracking app for a niche sport. It was a hobby project to better track my BJJ training.  Since then, I continue to average ~10 registrations weekly without any marketing efforts.

Consistent BJJ Tracker sign-ups are driven from Google Play. Even though it is only a web app (PWA), I was able to bundle it into an APK file using Trusted Web Activities and Digital Asset Links. Having an app listed in a marketplace leads to new users finding it naturally.

My next side project was a SAAS for split testing & conversion optimization. It helps websites A/B test to figure out what front-end changes lead to more sales, sign-ups, etc. The Shopify App Store was as perfect fit to attract shop owners to use SplitWit. I decided to build a public, embedded, Shopify app to reach new prospects.

I’ll explain how I did it, along with examples of building another one, all using PHP. This guide will make launching a Shopify App Store app easy, fast, repeatable.

SplitWit on Shopify

Creating a Public Shopify App with PHP

Embedded Shopify apps display in a merchant’s admin dashboard. They are meant to “add functionality to Shopify stores“. They are hosted on the developer’s infrastructure and are loaded via iFrame within Shopify. You can create a new app in your Shopify Partners dashboard to get started.

create a shopify app

Since the SplitWit SAAS already existed as a subscription web app built on the LAMP stack, I only had to handle Shopify specific authorization and payments. I could essentially load the existing app in the dashboard’s iFrame after authentication. The new code I wrote contains methods for checking installation status, building the oAuth URL, subscribing users to recurring application charges, and more.

When I created my next Shopify app, Click to Call, I leveraged that code and refactored it to be more reusable. Configurable parameters let me set the database and API credentials dynamically.

Step 1: Check Installation Status

When the app’s url loads in the merchant admin dashboard, the first step is to check installation status for that shop.

public function checkInstallationStatus(){
	$conn = $this->conn;
	$shop = $_GET['shop'];

	//check if app is already installed or not
	$statement = $conn->prepare("SELECT * FROM `shopify_installation_complete` WHERE shop = :shop");
	$statement->execute(['shop' => $shop]);
	$count = $statement->rowCount();
	if($count == 0){
		//app is not yet installed
		return false;
	}else{
		//app is already installed
		$row = $statement->fetch();
		return $row;
	}

}

You’ll notice we don’t hit any Shopify API for this. Instead, our own database is queried. We manually track if the app has already been installed in a MySql table.

shopify installation record in a mysql database table

Two database tables are used to manage the Shopify App installation:

CREATE TABLE IF NOT EXISTS `shopify_authorization_redirect` (
    `shopify_authorization_redirect_id` int NOT NULL AUTO_INCREMENT,
    `shop` varchar(200),
    `nonce` varchar(500),
    `scopes` varchar(500),
    created_date datetime DEFAULT CURRENT_TIMESTAMP,
    updated_date datetime ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`shopify_authorization_redirect_id`)
);

 
CREATE TABLE IF NOT EXISTS `shopify_installation_complete` (
    `shopify_installation_complete_id` int NOT NULL AUTO_INCREMENT,
    `splitwit_account_id` int,
    `splitwit_project_id` int,
    `shop` varchar(200),
    `access_token` varchar(200),
    `scope` varchar(200),
    `expires_in` int,
    `associated_user_scope` varchar(200),
    `associated_user_id` BIGINT,
    `associated_user_first_name` varchar(200),
    `associated_user_last_name` varchar(200),
    `associated_user_email` varchar(200),
    `associated_user_email_verified` varchar(10),
    `associated_user_account_owner` varchar(10),
    `associated_user_account_locale` varchar(10),
    `associated_user_account_collaborator` varchar(10),
    created_date datetime DEFAULT CURRENT_TIMESTAMP,
    updated_date datetime ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`shopify_installation_complete_id`)
);

The root file (index.php) looks like this:

<?php

require '/var/www/html/service-layer/shopify-app-service.php';
include 'shopify-creds.php'; // $api_key, $secret, $app_db, $app_slug
use SplitWit\ShopifyService\ShopifyService;
$shopify_service = new ShopifyService($api_key, $secret, $app_db);

$already_installed = $shopify_service->checkInstallationStatus();

if(!$already_installed){
    $install_redirect_url = $shopify_service->buildAuthorizationUrl(false, $app_slug);
}else{
    $install_redirect_url = $shopify_service->buildAuthorizationUrl(true, $app_slug);
}
header('Location: ' . $install_redirect_url );

?>

After determining the merchant’s installation status, the next step is to authenticate them.

oAuth Authentication

The Shopify developer resources explain “how to ask for permission” with oAuth. The merchant user needs to be redirected to a Shopify URL:

https://{shop}.myshopify.com/admin/oauth/authorize?client_id={api_key}&scope={scopes}&redirect_uri={redirect_uri}&state={nonce}&grant_options[]={access_mode}

I wrote a PHP class “ShopifyService” (shopify-app-service.php) to handle all of the Shopify specific logic. The method buildAuthorizationUrl() builds the Shopify authorization URL. It accepts a boolean parameter set according to the merchant’s installation status. That value toggles the authorization URL’s redirect URI, directing the code flow through either first-time installation or re-authentication.

The built URL includes query params: an API key, a nonce, the scope of permission being requested, and a redirect URI. The shop name is the subdomain, and can be pulled as GET data delivered to your app.

The API key can be found in the developer’s partner dashboard in the app’s overview page.

A nonce (“number used once”) is used as an oAuth state parameter. It serves to “link requests and callbacks to prevent cross-site request forgery attacks.” We save that random value in our database to check against during the oAuth callback.

The redirect URI (the oAuth callback) is dynamic based on the users installation status.

public function buildAuthorizationUrl($reauth = false, $slug= "shopify-app"){
	$conn = $this->conn;
	$requestData = $this->requestData;
	$scopes = "write_script_tags"; //write_orders,read_customers, read_content
	$nonce = bin2hex(random_bytes(10));
	$shop = $requestData['shop'];

	//first check if there is already a record for this shop. If there is, delete it first.
	$statement = $conn->prepare("SELECT * FROM `shopify_authorization_redirect` WHERE shop = :shop");
	$statement->execute(['shop' => $shop]);
	$count = $statement->rowCount();
	
	if($count > 0){
		$statement = $conn->prepare("DELETE FROM `shopify_authorization_redirect` WHERE shop = :shop");
		$statement->execute(['shop' => $shop]);
	}

	$statement = $conn->prepare("INSERT INTO `shopify_authorization_redirect` (shop, nonce, scopes) VALUES (:shop, :nonce, :scopes)");
	$statement->bindParam(':shop', $shop);
	$statement->bindParam(':nonce', $nonce);
	$statement->bindParam(':scopes', $scopes);
	$statement->execute();

	
	$redirect_uri = "https://www.splitwit.com/".$slug."/authorize-application";
	
	if($reauth){ //change the redirect URI
		$redirect_uri = "https://www.splitwit.com/".$slug."/reauthorize-application";
	}

	$redirect_url = "https://".$shop."/admin/oauth/authorize?client_id=". $this->api_key ."&scope=".$scopes."&redirect_uri=". $redirect_uri ."&state=".$nonce . "&grant_options[]=per-user";

	return $redirect_url;

}

Both possible redirect URLs needed to be white-listed in the “App setup” page.

white listed URLs

A location header routes the user. If the app hasn’t been installed yet, Shopify prompts the merchant to confirm authorization.

install shopify app

If the app was already installed then the oAuth grant screen is skipped entirely and the merchant is immediately routed to the /reauthorize-application resource instead (and ultimately lands on the app home screen).

Install App & Register the Merchant User

What actually happens when “Install app” is clicked?  The user is redirected from Shopify permissions screen back to our app ( /authorize-application ), calling the authorizeApplication() function. That method receives four values as GET parameters: ‘shop’, ‘state’, ‘hmac’, ‘code’

The ‘shop’ name is used to look up the nonce value we saved when the Shopify authorization URL was first built. We compare it to the ‘state’ parameter. This security step ensures that the callback request is valid and is not fraudulent. We also check that the shop name provided matches a valid Shopify hostname. Here is the relevant code, pulled from authorizeApplication():

$conn = $this->conn; 
$requestData = $this->requestData;
$requiredKeys = ['code', 'hmac', 'state', 'shop'];
foreach ($requiredKeys as $required) {
    if (!in_array($required, array_keys($requestData))) {
        throw new Exception("The provided request data is missing one of the following keys: " . implode(', ', $requiredKeys));
    }
}

//lookup and validate nonce
$shop = $requestData['shop'];

$statement = $conn->prepare("SELECT * FROM `shopify_authorization_redirect` WHERE shop = :shop");
$statement->execute(['shop' => $shop]);
$count = $statement->rowCount();
if($count == 0){
    throw new Exception("Nonce not found for this shop.");
}
$row = $statement->fetch();
$nonce = $row['nonce'];
//

//make sure the 'state' parameter provided matches the stored nonce
$state = $requestData['state'];
if($state !== $nonce){
    throw new Exception("Nonce does not match provided state.");
}
//

//validate the shop name
$pattern = "/[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com[\/]?/";
if(!preg_match($pattern, $shop)) {
    throw new Exception("The shop name is an invalid Shopify hostname.");
}

Every request or redirect from Shopify” includes a HMAC value that can be used to verify its authenticity. Here is how I do it in PHP:

public function verifyHmac($requestData){
	// verify HMAC signature. 
	// https://help.shopify.com/api/getting-started/authentication/oauth#verification
	if( !isset($requestData['hmac'])){
		return false;
	}

	$hmacSource = [];

	foreach ($requestData as $key => $value) {
	    
	    if ($key === 'hmac') { continue; }

	    // Replace the characters as specified by Shopify in the keys and values
	    $valuePatterns = [
	        '&' => '%26',
	        '%' => '%25',
	    ];
	    $keyPatterns = array_merge($valuePatterns, ['=' => '%3D']);
	    $key = str_replace(array_keys($keyPatterns), array_values($keyPatterns), $key);
	    $value = str_replace(array_keys($valuePatterns), array_values($valuePatterns), $value);

	    $hmacSource[] = $key . '=' . $value;
	}

	sort($hmacSource);
	$hmacBase = implode('&', $hmacSource);
	$hmacString = hash_hmac('sha256', $hmacBase, $this->secret);
	// Verify that the signatures match
    if ($hmacString !== $requestData['hmac']) {
        return false;
    }else{
    	return true;
    }
}

That method is called in the class construct function, to be sure it happens every time.

The ‘code’ parameter is the access code. It is exchanged for an access token by sending a request to the shop’s access_token endpoint. We record that token to the ‘shopify_installation_complete’ table along with relevant data.

To fully complete the installation, app specific project records are saved. For SplitWit, this means a user-account is created along with an initial project. Any JavaScript tags are injected onto the merchant’s site using the Shopify admin API ScriptTag resource. Linking a privately hosted JS file, unique to each merchant, allows our app to dynamically update the shop website. You can learn about how that snippet tag works in another post that explains how the visual editor is built.

Lastly, a webhook is created to listen for when this app in uninstalled ( ‘topic’ => ‘app/uninstalled’ ) in order to call our “uninstallApplication” method. Webhooks allow you to listen for certain events in a shop, and run code based on data about what happened.

Once installation is complete, our server returns a header that reloads the app.

shopify app

Below is the original authorizeApplication() method. Eventually, I moved app specific logic into its own files after refactoring this class to support another SAAS, Click to Call.

public function authorizeApplication(){
	$conn = $this->conn; 
	$requestData = $this->requestData;
	$requiredKeys = ['code', 'hmac', 'state', 'shop'];
        foreach ($requiredKeys as $required) {
           if (!in_array($required, array_keys($requestData))) {
             throw new Exception("The provided request data is missing one of the following keys: " . implode(', ', $requiredKeys));
           }
        }

	//lookup and validate nonce
	$shop = $requestData['shop'];
	
	$statement = $conn->prepare("SELECT * FROM `shopify_authorization_redirect` WHERE shop = :shop");
	$statement->execute(['shop' => $shop]);
	$count = $statement->rowCount();
	if($count == 0){
        throw new Exception("Nonce not found for this shop.");
	}
	$row = $statement->fetch();
	$nonce = $row['nonce'];
	//
	
	//make sure the 'state' parameter provided matches the stored nonce
	$state = $requestData['state'];
	if($state !== $nonce){
        throw new Exception("Nonce does not match provided state.");
	}
	//
	
	//validate the shop name
	$pattern = "/[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com[\/]?/";
	if(!preg_match($pattern, $shop)) {
        throw new Exception("The shop name is an invalid Shopify hostname.");
	}
	//

	$already_installed = $this->checkInstallationStatus();
	//if it is already installed, then lets update the access token 
        if(!$already_installed){
    	  //install the app
    	
	  //exchange the access code for an access token by sending a request to the shop’s access_token endpoint
	  $code = $requestData['code'];
	  $post_url = "https://" . $shop . "/admin/oauth/access_token";
		
	  $params = [
            'client_id'    => $this->api_key,
            'client_secret'    => $this->secret,
            'code'    => $code
          ];

          $curl_response_json = $this->curlApiUrl($post_url, $params);
	  $access_token = $curl_response_json['access_token'];
		
          $statement = $conn->prepare("INSERT INTO `shopify_installation_complete` (shop, access_token, scope, expires_in, associated_user_scope, associated_user_id, associated_user_first_name, associated_user_last_name, associated_user_email, associated_user_email_verified, associated_user_account_owner, associated_user_account_locale, associated_user_account_collaborator) VALUES (:shop, :access_token, :scope, :expires_in, :associated_user_scope, :associated_user_id, :associated_user_first_name, :associated_user_last_name, :associated_user_email, :associated_user_email_verified, :associated_user_account_owner, :associated_user_account_locale, :associated_user_account_collaborator)");
		
	  $statement->bindParam(':shop', $shop);
		
	  $statement->bindParam(':access_token', $access_token);
	  $statement->bindParam(':scope', $curl_response_json['scope']);
	  $statement->bindParam(':expires_in', $curl_response_json['expires_in']);
	  $statement->bindParam(':associated_user_scope', $curl_response_json['associated_user_scope']);
	  $statement->bindParam(':associated_user_id', $curl_response_json['associated_user']['id']);
	  $statement->bindParam(':associated_user_first_name', $curl_response_json['associated_user']['first_name']);
	  $statement->bindParam(':associated_user_last_name', $curl_response_json['associated_user']['last_name']);
	  $statement->bindParam(':associated_user_email', $curl_response_json['associated_user']['email']);
	  $statement->bindParam(':associated_user_email_verified', $curl_response_json['associated_user']['email_verified']);
	  $statement->bindParam(':associated_user_account_owner', $curl_response_json['associated_user']['account_owner']);
	  $statement->bindParam(':associated_user_account_locale', $curl_response_json['associated_user']['locale']);
	  $statement->bindParam(':associated_user_account_collaborator', $curl_response_json['associated_user']['collaborator']);

	  $statement->execute();
	  $installation_complete_id = $conn->lastInsertId();
		 
	  if(isset($curl_response_json['associated_user']['email']) && strlen($curl_response_json['associated_user']['email']) > 0){

		$store_name = explode(".", $shop);
		$store_name = ucfirst($store_name[0]);

		//create account
		$method = "thirdPartyAuth";
		$user_service_url = "https://www.splitwit.com/service-layer/user-service.php?third_party_source=shopify&method=" . $method . "&email=".$curl_response_json['associated_user']['email']."&companyname=" .$store_name . "&first=" . $curl_response_json['associated_user']['first_name'] . "&last=" . $curl_response_json['associated_user']['last_name'] ;
			
		$params = [];

		$curl_user_response_json = $this->curlApiUrl($user_service_url, $params);
		
		$account_id = $curl_user_response_json['userid']; 
			
		$method = "createProject";
			
		$project_service_url = "https://www.splitwit.com/service-layer/project-service.php?method=" . $method . "&accountid=" . $account_id;

		$params = [
	            'projectname'    => $store_name . " Shopify",
	            'projectdomain'    => "https://".$shop,
	            'projectdescription'    => ""
	        ];

		$curl_project_response_json = $this->curlApiUrl($project_service_url, $params);
		$project_id = $curl_project_response_json['projectid'];
		$snippet = $curl_project_response_json['snippet'];
			
			

		//inject JS snippet into site
		// https://shopify.dev/docs/admin-api/rest/reference/online-store/scripttag#create-2020-04
		$create_script_tag_url = "https://" . $this->api_key . ":" . $this->secret . "@" . $shop . "/admin/api/2020-04/script_tags.json";
		$params = [
                    'script_tag' => [
                     'event' => 'onload',
                     'src' => 'https://www.splitwit.com/snippet/' . $snippet
                    ]
        	];

        	$headers = array(
		  'X-Shopify-Access-Token:' . $access_token,
		  'content-type: application/json'
		);

		$json_string_params = json_encode($params);

		$create_script_curl_response_json = $this->curlApiUrl($create_script_tag_url, $json_string_params, $headers);
		
		//shopify app should only ever have access to this one project.
		//write accountID and ProjectID to this shopify_installation_complete record.

		$statement = $conn->prepare("UPDATE `shopify_installation_complete` SET splitwit_account_id = ?, splitwit_project_id = ? WHERE shopify_installation_complete_id = ?");

		$statement->execute(array($account_id, $project_id, $installation_complete_id));
			
    	}
		
    	//create webhook to listen for when app in uninstalled.
	//https://{username}:{password}@{shop}.myshopify.com/admin/api/{api-version}/{resource}.json
	// https://shopify.dev/docs/admin-api/rest/reference/events/webhook#create-2020-04
	$create_webhook_url = "https://" . $this->api_key . ":" . $this->secret . "@" . $shop . "/admin/api/2020-04/webhooks.json";
	$params = [
                'webhook' => [
                    'topic' => 'app/uninstalled',
                    'address' => 'https://www.splitwit.com/service-layer/shopify-app-service?method=uninstallApplication',
                    'format' => 'json'
                ]
        ];

	$headers = array(
		'X-Shopify-Access-Token:' . $access_token,
		'content-type: application/json'
	);
		
	$json_string_params = json_encode($params);

    	$create_webhook_curl_response_json = $this->curlApiUrl($create_webhook_url, $json_string_params, $headers);
		
	//installation complete.
   }

   header('Location: ' . "https://" . $shop . "/admin/apps/splitwit");
	
}

You’ll notice that I call a custom method that abstracts the PHP cURL (client URL library) methods. This helps me avoid repeating the same code in multiple places.

public function curlApiUrl($url, $params, $headers = false, $use_post = true, $use_delete = false, $use_put = false){
		
	$curl_connection = curl_init();
	// curl_setopt($curl_connection, CURLOPT_FOLLOWLOCATION, true);
	if($headers){
	    curl_setopt($curl_connection, CURLOPT_HTTPHEADER, $headers);
	    curl_setopt($curl_connection, CURLOPT_HEADER, false);
	}
	curl_setopt($curl_connection, CURLOPT_URL, $url);
    
       // TODO: refactor these three conditions into one, that accepts the RESTful request type!!
       if($use_post){
	    curl_setopt($curl_connection, CURLOPT_POST, true);
	    curl_setopt($curl_connection, CURLOPT_POSTFIELDS, $params);
	}
        if($use_delete){
	    curl_setopt($curl_connection, CURLOPT_CUSTOMREQUEST, "DELETE");
	}
        if($use_put){
	    curl_setopt($curl_connection, CURLOPT_CUSTOMREQUEST, "PUT");
	    curl_setopt($curl_connection, CURLOPT_POSTFIELDS, $params);
	}
	//end TODO

	curl_setopt($curl_connection, CURLOPT_RETURNTRANSFER, true);
	$curl_response = curl_exec($curl_connection);
	$curl_response_json = json_decode($curl_response,true);
	curl_close($curl_connection);
	return $curl_response_json;
}

The merchant will be able to find the app in the ‘Apps’ section of their dashboard. Shopify remembers that permission was granted by the merchant.

installed apps dashboard

Returning User Log in

The oAuth grant screen will not show again when the app is selected in the future.  As the installation status returns true, our code will flow into the reAuthenticate() method. The same validation checks are performed and a new access token is received.

   
public function reAuthenticate(){
    $conn = $this->conn; 
    $requestData = $this->requestData;
    $requiredKeys = ['code', 'hmac', 'state', 'shop'];
    foreach ($requiredKeys as $required) {
        if (!in_array($required, array_keys($requestData))) {
            throw new Exception("The provided request data is missing one of the following keys: " . implode(', ', $requiredKeys));
            // return;
        }
    }

    //lookup and validate nonce
    $shop = $requestData['shop'];
    
    $statement = $conn->prepare("SELECT * FROM `shopify_authorization_redirect` WHERE shop = :shop");
    $statement->execute(['shop' => $shop]);
    $count = $statement->rowCount();
    if($count == 0){
        throw new Exception("Nonce not found for this shop.");
    }
    $row = $statement->fetch();
    $nonce = $row['nonce'];
    //
    
    //make sure the 'state' parameter provided matches the stored nonce
    $state = $requestData['state'];
    if($state !== $nonce){
        throw new Exception("Nonce does not match provided state.");
    }
    //
    
    //validate the shop name
    $pattern = "/[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com[\/]?/";
    if(!preg_match($pattern, $shop)) {
        throw new Exception("The shop name is an invalid Shopify hostname.");
    }

    //exchange the access code for an access token by sending a request to the shop’s access_token endpoint
    $code = $requestData['code'];
    $post_url = "https://" . $shop . "/admin/oauth/access_token";
    
    $params = [
        'client_id'    => $this->api_key,
        'client_secret'    => $this->secret,
        'code'    => $code
    ];

    $curl_response_json = $this->curlApiUrl($post_url, $params);
    $access_token = $curl_response_json['access_token'];
    
    $statement = $conn->prepare("UPDATE `shopify_installation_complete` SET access_token = ? WHERE shop = ?");
    $statement->execute(array($access_token, $shop));

    header('Location: ' . "/home?shop=".$shop);
}

The merchant is routed to the app’s /home location. A few session variables are set and the user interface is loaded.

<?php
require '/var/www/html/service-layer/shopify-app-service.php';
use SplitWit\ShopifyService\ShopifyService;
$shopify_service = new ShopifyService();
include '/var/www/html/head.php'; 
?>
<style>
	.back-to-projects{
		display: none;
	}   
</style>

<body class="dashboard-body">
    <?php 
    // log the user out...
    $sess_service = new UserService();
    $sess_service -> logout();
    //logout destroys the session. make sure to start a new one.
    if (session_status() == PHP_SESSION_NONE) {
        session_start();
    }
    // ...then log them in
    $already_installed = $shopify_service->checkInstallationStatus();
    $shopify_service->makeSureRecordsExist($already_installed);
    $projectid = $shopify_service->splitwit_project_id; 
    $accountid = $shopify_service->splitwit_account_id; 
    $_SESSION['accountid'] = $accountid;
    $_SESSION['userid'] =  $accountid;
    $_SESSION['email'] = $already_installed['associated_user_email'];
    
    $sess_service -> login();
    $_SESSION['active'] = true;
    include '/var/www/html/includes/experiments-ui.php'; 
    
    ?>
</body>
</html>

The method makeSureRecordsExist() checks that the SplitWit user account and project records exist, as a failsafe. The .back-to-projects CTA is hidden because Shopify users only have access to one project for their shop. The app is installed for free, while premium functionality requires a subscription after a one-week trial.

free trial UI in shopify

When building my second Shopify app, I started with an empty home screen UI:

<?php
require '/var/www/html/service-layer/shopify-app-service.php';
include 'shopify-creds.php';
use SplitWit\ShopifyService\ShopifyService;
$shopify_service = new ShopifyService($api_key, $secret, $app_db);
?>
<html>
<head>
</head>
<body>
   
</body>
<script>


    if(self!==top){
        // if loaded outside of an iframe, redirect away to a marketing page
        window.location = "https://www.splitwit.com";
    }


</script>
</html>

Subscription Payment

SplitWit’s codebase is originally used as a non-Shopify, stand-alone, web app SAAS. It uses Stripe as a payment gateway. Shopify requires apps to use their Billing API instead. To remedy this, I’m able to write Shopify specific front-end code with a simple JavaScript check. I leverage the browser’s window.self property to check if my app’s code is running in the top most window (opposed to being nested in an iFrame).

if(self!==top){
	// shopify app
	$(".activate-cta").remove();
	if(window.pastDueStatus || window.customerId.length === 0){
		$(".activate-loading").show();

		$.ajax({
			url:"/service-layer/shopify-app-service?method=createRecurringApplicationCharge",
			complete: function(response){
				console.log(response)
				//user is redirected to shopify confirmation screen
				$(".activate-loading").hide();
				$(".activate-cta-top").attr("href", response.responseText).show();
			}
		});
	}
	 
	$(".back-cta").click(function(){
		window.history.back();
	})
	$(".reset-pw-cta").hide()

}else{

	$(".activate-cta").click(function(){
		$(".stripe-payment-modal").show();
	});
	
	$(".back-cta").hide();
}

If it’s not the top most window, I assume the code is running in Shopify. I’ll change the click-event listener on the .activate-cta element to create a recurring subscription charge. An AJAX call is made to our PHP end-point that hits Shopify’s RecurringApplicationCharge API.

public function createRecurringApplicationCharge(){
	
	$conn = $this->conn;
	$statement = $conn->prepare("SELECT * FROM `shopify_installation_complete` WHERE splitwit_account_id = :splitwit_account_id");
	$statement->execute(['splitwit_account_id' => $_SESSION['accountid']]);
	$row = $statement->fetch();
	$shop = $row['shop'];
	$access_token = $row['access_token'];
	
	$create_recurring_charge_url = "https://" . $this->api_key . ":" . $this->secret . "@" . $shop . "/admin/api/2020-04/recurring_application_charges.json";
	$params = [
        'recurring_application_charge' => [
            'name' => 'Basic Plan',
            'price' => 25.0,
            // 'return_url' => "https://" . $shop . "/admin/apps/splitwit",
            // 'test' => true,
            'return_url' => "https://www.splitwit.com/service-layer/shopify-app-service?method=confirmSubscription"
        ]
	];
	$headers = array(
		'X-Shopify-Access-Token: ' . $access_token,
	 	'content-type: application/json'
	);
	$json_string_params = json_encode($params);

	$create_recurring_charge_curl_response_json = $this->curlApiUrl($create_recurring_charge_url, $json_string_params, $headers);
	echo $create_recurring_charge_curl_response_json['recurring_application_charge']['confirmation_url'];
}

The charge ID (delivered by the Shopify request to our ‘return_url’), payment processor, and subscription expiry date are saved to our database on call-back before returning a location header to reload the app.

public function confirmSubscription(){
	
	$conn = $this->conn;
	$statement = $conn->prepare("SELECT * FROM `shopify_installation_complete` WHERE splitwit_account_id = :splitwit_account_id");
	$statement->execute(['splitwit_account_id' => $_SESSION['accountid']]);
	$row = $statement->fetch();
	$shop = $row['shop'];
 
 	$charge_id = $_REQUEST['charge_id'];
	//write shopify billing ID to db
	$sql = "UPDATE `account` SET payment_processor = ?, billing_customer_id = ?, current_period_end = ?, past_due = 0 WHERE accountid = ?"; 
	$result = $conn->prepare($sql); 
	$current_period_end = new \DateTime();  //we need the slash here (before DateTime class name), since we're in a different namespace (declared at the top of this file)
	$current_period_end->modify( '+32 day' );
	$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 
	$payment_processor = "shopify";
	$result->execute(array($payment_processor, $charge_id, $current_period_end, $_SESSION['accountid']));
	
	//redirect to app
	header('Location: ' . "https://" . $shop . "/admin/apps/splitwit");

}

That charge ID (saved to our database in a column titled “billing_customer_id”) can later be passed back to Shopify to delete the recurring charge.

Cancel a Subscription

Once a subscription is active, I can check  the payment processor saved the the account’s DB record to toggle the “cancel account” functionality from Stripe to Shopify.

<?php if ($account_row['payment_processor'] == "shopify"){ ?>
	//hit shopify service

	$(".cancel-cta").click(function(){
		//
		$.ajax({
			url:"/service-layer/shopify-app-service?method=cancelSubscription",
			complete: function(response){
				window.location.reload();
			}
		});
	});

<?php }else{ ?>
	//hit the stripe service
	
	$(".cancel-cta").click(function(){
		$(".cancel-subscription-modal").show();
	});

<?php }?>

The cancelSubscription method hits the same Shopify recurring_application_charges API, but uses a DELETE request. It also deletes the Shopify billing ID from our records.

public function cancelSubscription(){
	
	$conn = $this->conn;
	$statement = $conn->prepare("SELECT * FROM `shopify_installation_complete` WHERE splitwit_account_id = :splitwit_account_id");
	$statement->execute(['splitwit_account_id' => $_SESSION['accountid']]);
	$row = $statement->fetch();
	$shop = $row['shop'];
	$access_token = $row['access_token'];

	$statement = $conn->prepare("SELECT * FROM `account` WHERE accountid = :accountid");
	$statement->execute(['accountid' => $_SESSION['accountid']]);
	$account_row = $statement->fetch();
	$charge_id = $account_row['billing_customer_id'];


	$delete_recurring_charge_url = "https://" . $this->api_key . ":" . $this->secret . "@" . $shop . "/admin/api/2020-04/recurring_application_charges/#" . $charge_id . ".json";

	$params = [];
	$headers = array(
		'X-Shopify-Access-Token: ' . $access_token,
	 	'content-type: application/json'
	);
	$json_string_params = json_encode($params);
	$delete = true;

	$delete_recurring_charge_curl_response_json = $this->curlApiUrl($delete_recurring_charge_url, $json_string_params, $headers, $delete);

	//delete shopify billing ID from db
	$empty_string = "";
	$sql = "UPDATE `account` SET payment_processor = ?, billing_customer_id = ? WHERE accountid = ?"; 
	$result = $conn->prepare($sql); 
	$result->execute(array($empty_string, $empty_string, $_SESSION['accountid']));
	
	echo $delete_recurring_charge_curl_response_json;


}

I can use these same recurring application API end-point functions with minimal adjustments for other Shopify apps that I build. After refactoring, I am able to specify an app database as a GET parameter in the AJAX calls to my Shopify PHP service.

Uninstall the App

delete shopify app

Merchants can choose to delete apps from their shop. This will remove it from their list of installed apps. If they try installing it again, they will be re-promoted for permissions. When an app is deleted, a webhook is notified so that code can handle server-side uninstall logic:

The payment processor and billing ID associated with the merchant’s account is set to an empty string. The ‘shopify_installation_complete’ shop record is deleted.

public function uninstallApplication(){
	$conn = $this->conn; 
	
	$res = '';
	$hmac_header = $_SERVER['HTTP_X_SHOPIFY_HMAC_SHA256'];
	$topic_header = $_SERVER['HTTP_X_SHOPIFY_TOPIC'];
	$shop_header = $_SERVER['HTTP_X_SHOPIFY_SHOP_DOMAIN'];
	$data = file_get_contents('php://input'); //similar to $_POST
	$decoded_data = json_decode($data, true);
	$verified = $this->verifyWebhook($data, $hmac_header);

	if( $verified == true ) {
	  if( $topic_header == 'app/uninstalled' || $topic_header == 'shop/update') {
	    if( $topic_header == 'app/uninstalled' ) {
			$domain = $decoded_data['domain'];

			$statement1 = $conn->prepare("SELECT * FROM `shopify_installation_complete` WHERE shop = ?");
			$statement1->execute(array($domain));
			$row = $statement1->fetch();
			$accountid = $row['splitwit_account_id'];

			//delete shopify billing ID from db
			$empty_string = "";
			$result = $conn->prepare("UPDATE `account` SET payment_processor = ?, billing_customer_id = ? WHERE accountid = ?"); 
			$result->execute(array($empty_string, $empty_string, $accountid));

			$statement = $conn->prepare("DELETE FROM `shopify_installation_complete` WHERE shop = ?");
			$statement->execute(array($domain));

	    } else {
	      $res = $data;
	    }
	  }
	} else {
	  $res = 'The request is not from Shopify';
	}

}

Any webhook requests have the HMAC delivered as a header (instead of a query param, as in the case of oAuth requests) and is processed differently. “The HMAC verification procedure for OAuth is different from the procedure for verifying webhooks“. The method verifyWebhook() takes care of it:

public function verifyWebhook($data, $hmac_header){
  $calculated_hmac = base64_encode(hash_hmac('sha256', $data, $this->secret, true));
  return hash_equals($hmac_header, $calculated_hmac);
}

Cache Busting

When project changes are recorded in the app, the merchant’s snippet file is updated. We need to be sure that their website recognizes the latest version. In a separate class (that handles project & snippet logic) I make a HTTP request to my method that re-writes the script tag.

public function updateSnippetScriptTag(){
	$projectid = $_GET['projectid'];
	$conn = $this->conn;
	$sql = "SELECT * FROM `shopify_installation_complete` WHERE splitwit_project_id = ?"; 
	$result = $conn->prepare($sql); 
	$result->execute(array($projectid));
	$row = $result->fetch(\PDO::FETCH_ASSOC);
	$number_of_rows = $result->rowCount();
	if($number_of_rows == 1){
		$access_token = $row['access_token'];
		$shop = $row['shop'];
		$sql = "SELECT * FROM `project` WHERE projectid = ?"; 
		$project_result = $conn->prepare($sql); 
		$project_result->execute(array($projectid));
		$project_row = $project_result->fetch(\PDO::FETCH_ASSOC);
		$snippet = $project_row['snippet'];			

		$script_tag_url = "https://" . $this->api_key . ":" . $this->secret . "@" . $shop . "/admin/api/2020-04/script_tags.json";
		$headers = array(
		  'X-Shopify-Access-Token:' . $access_token,
		  'content-type: application/json'
		);
		$params = [];
		$json_string_params = json_encode($params);
		$use_post = false;
		//get existing script tag
		$get_script_curl_response_json = $this->curlApiUrl($script_tag_url, $json_string_params, $headers, $use_post);
		$tags = $get_script_curl_response_json['script_tags'];
	
		foreach ($tags as $tag) {
			$id = $tag['id'];
			$delete_script_tag_url = "https://" . $this->api_key . ":" . $this->secret . "@" . $shop . "/admin/api/2020-04/script_tags/" . $id . ".json";
			$use_delete = true;
			$delete_script_curl_response_json = $this->curlApiUrl($delete_script_tag_url, $json_string_params, $headers, $use_post, $use_delete);
		}
		 
		//add snippet
		$snippet = "https://www.splitwit.com/snippet/" . $snippet . "?t=" . time();
		$params = [
			'script_tag' => [
				'event' => 'onload',
				'src' => $snippet 
			]
		];
		$json_string_params = json_encode($params);
		$create_script_curl_response_json = $this->curlApiUrl($script_tag_url, $json_string_params, $headers);	 

	}
}

Once our Shopify app is built and tested we can begin to prepare for submission to the Shopify App Market.

Preparing for production

Shopify allows you to test your app on a development store.

test your app

After debugging your code locally, make sure it works end-to-end in Shopify’s environment.

test your app on Shopify

Even though the app is “unlisted”, and has not yet been accepted into the Shopify App Market, you’ll still be able to work through the entire UX flow.

install an unlisted app

GDPR mandatory webhooks

Each app developer is responsible for making sure that the apps they build for the Shopify platform are GDPR compliant.” Every app is required to provide three webhook end-points to help manage the data it collects. These end-points make requests to to view stored customer data, delete customer data, and delete shop data.  After handling the request, an HTTP status of 200/OK should be returned. PHP lets us do that with its header() function:

header("HTTP/1.1 200 OK");

These GDPR webhook subscriptions can be managed on the “App setup” page.

gdpr webhook settings

App Listing

Before submitting your app to the Shopify App Market, you’ll need to complete “Listing Information”. This section includes the app’s name, icon, description, pricing details, and more. It is encouraged to include screenshots and a demonstration video. Detailed app review instructions, along with screenshots and any on-boarding information, will help move the approval process along more quickly.

app review instructions in the app listing section of Shopify

Approval Process

Complete the setup and listing sections, and submit your app.

shopify app listing issues

You’ll receive an email letting you know that testing will begin shortly.

email from shopify

You may be required to make updates based on feedback from Shopify’s review process. After making any required changes, your application will be listed on the Shopify App Store. Below is an example of feedback that I had received:

Required changes from Shopify's app review process

To remedy the first required change I added additional onboarding copy to the app’s listing and included a demonstration YouTube video.

The second point was fixed by stopping any links from opening in new tabs. (Although, the reviewer’s note about ad blocking software stopping new tabs from opening is bogus).

The third issue was resolved by making sure the graphic assets detailed in my app listing were consistent.

Soon after making these changes, my app was finally approved and listed.

Keep Building

While writing this article I extended and refactored my PHP code to support multiple apps. I added configuration files to keep database settings modular. The Shopify PHP class can serve as back-end to several implementations. If you have any questions about how to build a Shopify app, or need my help, send me a message.

Update:

I wrote a subsequent post about building another Shopify app. It’s called SplitWit Click to Call. It explains the creative details that go into shipping a fulling working SAAS. I dive into new features that are only available to Shopify themes running the latest OS2.0 experience.

Additional References

https://github.com/LukeTowers/php-shopify-api

https://weeklyhow.com/shopify-uninstall-webhook-api-tutorial

https://github.com/markrogoyski/awesome-php

 

 

Using Stripe for SAAS payments

stripe for saas payment

Letting users pay for your software service is important part of building a “Software as a Service” business. Accepting payment requires a third-party service, such as Stripe. Their PHP library makes it easy to accept credit cards and subscribe users to monthly payment plans. My examples use version 6.43. The Stripe JavaScript library is used to create secure UI elements that collect sensitive card data.

Before any coding, log into your Stripe account. Create a product with a monthly price. That product’s API ID is used to programmatically charge users and subscribe them recurring billing.

stripe product dashboard

User Interface (front end)

In this part Stripe collects the payment information, and returns a secure token that can be used server-side to create a charge. Payment does not actually happen until the token is processed on the back-end.

My software product gives users a 7-day free trial before core functionality is disabled. When they decide to activate their account they are presented with a credit card input user interface.

activate account subscription

It is built with basic HTML and CSS.

<style type="text/css">
	#card-element{
		width: 100%;
		margin-bottom: 10px; 
	}
	.StripeElement {
	  box-sizing: border-box;

	  height: 40px;

	  padding: 10px 12px;

	  border: 1px solid transparent;
	  border-radius: 4px;
	  background-color: white;

	  box-shadow: 0 1px 3px 0 #e6ebf1;
	  -webkit-transition: box-shadow 150ms ease;
	  transition: box-shadow 150ms ease;
	}

	.StripeElement--focus {
	  box-shadow: 0 1px 3px 0 #cfd7df;
	}

	.StripeElement--invalid {
	  border-color: #fa755a;
	}

	.StripeElement--webkit-autofill {
	  background-color: #fefde5 !important;
	}
</style>

<div id="stripe-payment-modal" class="modal stripe-payment-modal" style="display: none;">

	<!-- Modal content -->
	<div class="modal-content">
		<p>
		  <button type="button" class="dismiss-modal close" >&times;</button>
		</p>
		<p>Activate your account subscription.</p>
		<p><?php echo $price_point; ?> per month.</p>
		<form id="payment-form">
		  <div class="form-row">
		    <!-- <label for="card-element">
		      Credit or debit card
		    </label> -->
		    <div id="card-element">
		      <!-- A Stripe Element will be inserted here. -->
		    </div>

		    <!-- Used to display Element errors. -->
		    <div id="card-errors" role="alert"></div>
		  </div>

		  <button type="button" class="btn submit-payment">Submit Payment</button>
		</form>

  	</div>

</div>

The actual input elements are generated by Stripe’s JavaScript. The Stripe form handles real-time validation and generates a secure token to be sent to your server.

<script src="https://js.stripe.com/v3/"></script>
<script type="text/javascript">

	$(document).ready(function() {
		// var stripe = Stripe('pk_test_xxxx'); //sandbox
		var stripe = Stripe('pk_live_xxxx');

		var elements = stripe.elements();

		// Custom styling can be passed to options when creating an Element.
		var style = {
		  base: {
		    color: '#32325d',
		    fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
		    fontSmoothing: 'antialiased',
		    fontSize: '16px',
		    '::placeholder': {
		      color: '#aab7c4'
		    }
		  },
		  invalid: {
		    color: '#fa755a',
		    iconColor: '#fa755a'
		  }
		};

		// Create an instance of the card Element.
		var card = elements.create('card', {style: style});

		// Add an instance of the card Element into the `card-element` <div>.
		card.mount('#card-element');

		// Handle real-time validation errors from the card Element.
		card.addEventListener('change', function(event) {
		  var displayError = document.getElementById('card-errors');
		  if (event.error) {
		    displayError.textContent = event.error.message;
		  } else {
		    displayError.textContent = '';
		  }
		});

		// Handle form submission.
		var form = document.getElementById('payment-form');
		form.addEventListener('submit', function(event) {
		  event.preventDefault();

		  stripe.createToken(card).then(function(result) {
		    if (result.error) {
		      // Inform the user if there was an error.
		      var errorElement = document.getElementById('card-errors');
		      errorElement.textContent = result.error.message;
		    } else {
		      // Send the token to your server.
		      stripeTokenHandler(result.token);
		    }
		  });
		});

		// Submit the form with the token ID.
		function stripeTokenHandler(token) {
		  // Insert the token ID into the form so it gets submitted to the server
		  var form = document.getElementById('payment-form');
		  var hiddenInput = document.createElement('input');
		  hiddenInput.setAttribute('type', 'hidden');
		  hiddenInput.setAttribute('name', 'stripeToken');
		  hiddenInput.setAttribute('value', token.id);
		  form.appendChild(hiddenInput);
		 
		  var data = $("#payment-form").serialize();
		  $.ajax({
		  	url:"/service-layer/stripe-service?method=subscribe",
		  	method: "POST",
		  	data: data,
		  	complete: function(response){
		  		console.log(response);
		  		window.location.reload();
		  	}
		  })
		}

		$(".submit-payment").click(function(){
			stripe.createToken(card).then(function(result) {
		    if (result.error) {
		    	// Inform the customer that there was an error.
		    	var errorElement = document.getElementById('card-errors');
		    	errorElement.textContent = result.error.message;
		    } else {
				$(".submit-payment").attr("disabled", "disabled").html('Working <i class="fas fa-spinner fa-spin"></i>');
		      	// Send the token to your server.
		      	stripeTokenHandler(result.token);
		    }
		  });
		});

	});

</script>

After referencing the CDN JS library, the Stripe object accepts a public API key. That object then creates a customizable element that can be mounted into an existing <div> on your webpage. In your JavaScript, you can either listen for the form to be submitted or for an arbitrary button to be clicked. Then, we rely on the Stripe object to create a card token, which we can pass along to our back-end service.

You can find test payment methods in Stripe’s documentation.

Payment

Creating a subscription

Once the token is passed along to the server, it can be used to subscribe to the monthly product. We will need to load the PHP library and provide our secret API key. The key can be found in Stripe’s web dashboard.

require_once('/stripe-php-6.43.0/init.php');
\Stripe\Stripe::setApiKey('sk_live_XXXXXXX');

A Stripe customer ID is needed to create the subscription. Our code checks if the user record already has a Stripe customer ID saved to our database (in case they signed up previously, and cancelled).  If not, we call the “customer create” method first.

function subscribe(){
	$stripe_token = $_POST['stripeToken'];
	$conn = $this->connection;
	
	if(isset($_SESSION['email'])){
		$email = $_SESSION['email'];
	}else{
		die("No email found.");
	}
	
	if(strlen($email)>0){
		$sql = "SELECT * FROM `account` WHERE email = ?"; 
		$result = $conn->prepare($sql); 
		$result->execute(array($email));
		$row = $result->fetch(PDO::FETCH_ASSOC);
	}
	$customer_id = $row['billing_customer_id'];
	//check if this account already has a billing_customer_id
	if(strlen($customer_id) < 1){
		//if not, create the customer
		$customer = \Stripe\Customer::create([
		  'email' => $email,
		  'source' => $stripe_token,
		]);
		$customer_id = $customer['id'];
		//write stripe ID to db
		$sql = "UPDATE `account` SET billing_customer_id = ? WHERE email = ?"; 
		$result = $conn->prepare($sql); 
		$result->execute(array($customer_id, $email));
	}

	// Create the subscription
	$subscription = \Stripe\Subscription::create([
	  'customer' => $customer_id,
	  'items' => [
	    [
	      // 'plan' => 'plan_FjOzMSMahyM7Ap', //sandbox.
	      'plan' => 'price_1He7vwLjg3FTECK8lb3GDQhV', //"basic" plan. setup in Stripe dashboard.
	    ],
	  ],
	  'expand' => ['latest_invoice.payment_intent'],
	  'billing_cycle_anchor' => time()
	]);
	$subscription_status = $subscription['status'];
	$subscription_id = $subscription['id'];
	if($subscription_status == "active"){
		//set current_period_end to 32 days (1 month plus some leeway) in the future. set past_due as false 
		$sql = "UPDATE `account` SET stripe_subscription_id = ?, current_period_end = ?, past_due = 0 WHERE email = ?"; 
		$result = $conn->prepare($sql);
		$past_due = false;
		$current_period_end = new DateTime;  
		$current_period_end->modify( '+32 day' );
		$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 
		$result->execute(array($subscription_id, $current_period_end, $email));
	}
}

With the subscription complete, their account’s “past due” property is marked as false and “current period end” is recorded to about 1 month in the future. The Stripe subscription ID is recorded for later use and reference.

Subscription life-cycle workflow

The application knows if an account is paying for premium service based on that “past due” property. After a user first signs up, that value is managed by a nightly scheduled cron job. If the “current period end” date is in the past, “past due” is marked as true, all projects are turned off, and a notification email is sent.

function checkPastDue(){	
	$sql = "SELECT * FROM `account` WHERE past_due = '0'";
	$result = $conn->prepare($sql); 
	$result->execute(); 
	$rows = $result->fetchAll(PDO::FETCH_ASSOC);
	$number_of_rows = $result->rowCount();
	
	include 'send-email-service.php';	

	foreach ($rows as $key => $value) {
		$current_period_end = $value['current_period_end'];
		$date = new DateTime($current_period_end);
		$now = new DateTime();
		if($date < $now) {
		   
		    //extend their trial 1 time, for an additional week
		    $extended_trial = $value['extended_trial'];
		    $accountid = $value['accountid'];
		    $email = $value['email'];
		    $billing_customer_id = $value['billing_customer_id'];
		    if($extended_trial == 0 && strlen($billing_customer_id) == 0){

		    	$sql = "UPDATE `account` SET extended_trial = '1' WHERE accountid = ?";
				$result1 = $conn->prepare($sql); 
				$result1->execute(array($accountid)); 

				$current_period_end = new DateTime;  
				$current_period_end->modify( '+8 day' );
				$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 

		    	$sql = "UPDATE `account` SET current_period_end = ? WHERE accountid = ?";
				$result1 = $conn->prepare($sql); 
				$result1->execute(array($current_period_end, $accountid)); 
				 
				$SendEmailService = new SendEmailService();
				
				$subject = "SplitWit trial extended!";

				$body = "Your SplitWit trial was supposed to expire today. As a courtesy, we're extending it another 7 days!<br><br>";
				
				$altBody = "Your SplitWit trial was supposed to expire today. We're extending it another 7 days!";

				$SendEmailService -> sendEmail($subject, $body, $altBody, $email);


		    }else{
				
				$sql = "UPDATE `account` SET past_due = '1' WHERE accountid = ?";
				$result1 = $conn->prepare($sql); 
				$result1->execute(array($accountid)); 
				
				//turn off all experiments
				$status = "Not running";
				$sql = "UPDATE `experiment` set status = ? where accountid = ?";
				$result2 = $conn->prepare($sql); 
				$result2->execute(array($status, $accountid));


				//update all snippets for this account (1 snippet per project)
				$sql = "SELECT * FROM `project` WHERE accountid = ?";
				$result3 = $conn->prepare($sql); 
				$result3->execute(array($accountid));
				$rows3 = $result3->fetchAll(PDO::FETCH_ASSOC);
				foreach ($rows3 as $key3 => $value3) {
					$projectid = $value3['projectid'];
			    	$write_snippet_service = new ProjectService();
					$write_snippet_service -> writeSnippetFile(false, false, $projectid);
				}
				
				$SendEmailService = new SendEmailService();
				$subject = "SplitWit account past due";

				$body = "Your SplitWit account is past due. Please login to your account and update your payment information to continue running A/B experiments.<br><br>";
				
				$body .= "A/B testing helps you increase conversion rates and avoid unnecessary risk. <a href='https://www.splitwit.com/blog/'>Check out the SplitWit blog for experiment ideas</a>. Remember, everything is testable!";
				 
				$body .= "<br><br><a href='https://www.splitwit.com/'><img src='https://www.splitwit.com/img/splitwit-logo.png'></a>";

				$altBody = "Your SplitWit account is past due. Please login to your account and update your payment information to continue running A/B experiments. A/B testing helps you increase conversion rates and avoid unnecessary risk. Check out the SplitWit blog for experiment ideas: https://www.splitwit.com/blog/ ";

				$SendEmailService -> sendEmail($subject, $body, $altBody, $email);

		    }
			
		}
	}

}

The “current period end” date is updated each month after the customer is invoiced.

webhook payment success

When the Stripe “payment succeeded” event happens, a webhook triggers our custom end-point code.

function webhookPaymentSuccess(){
	$payload = @file_get_contents("php://input"); 
	$endpoint_secret = "whsec_XXXX";

	$sig_header = $_SERVER["HTTP_STRIPE_SIGNATURE"];
	$event = null;

	try {
	  $event = \Stripe\Webhook::constructEvent(
	    $payload, $sig_header, $endpoint_secret
	  );
	} catch(\UnexpectedValueException $e) {
	  // Invalid payload
	  http_response_code(400); // PHP 5.4 or greater
	  exit();
	} catch(\Stripe\Error\SignatureVerification $e) {
	  // Invalid signature
	  http_response_code(400); // PHP 5.4 or greater
	  exit();
	}
	
	if($event->type == 'invoice.payment_succeeded'){

		$invoice = $event->data->object;
		$customer_id = $invoice['customer'];
		//update their accocunt current_period_end
		$conn = $this->connection;
		$sql = "UPDATE `account` SET  current_period_end = ?, past_due = 0 WHERE billing_customer_id = ?"; 
		$result = $conn->prepare($sql);
		$past_due = false;
		$current_period_end = new DateTime;  
		$current_period_end->modify( '+32 day' );
		$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 
		$result->execute(array($current_period_end, $customer_id));
	}else{
		http_response_code(400);
	    exit();
	}
	
	http_response_code(200);
	// var_dump($payload);
}

Although there is a webhook available for payment failure, the scheduled cron job handles that scenario.

If a user decides to cancel their subscription, we use their Stripe subscription ID and update their account records.

function cancelSubscription(){
	include '/var/www/html/service-layer/project-service.php';
	$conn = $this->connection;
	if(isset($_SESSION['userid'])){
		$accountid = $_SESSION['userid'];
	}else{
		die("No userid found.");
	}
	
	if(strlen($accountid)>0){
		
		$sql = "SELECT * FROM `account` WHERE accountid = ?"; 
		$result = $conn->prepare($sql); 
		$result->execute(array($accountid));
		$row = $result->fetch(PDO::FETCH_ASSOC);
	}
	$stripe_subscription_id = $row['stripe_subscription_id'];
	$subscription = \Stripe\Subscription::retrieve($stripe_subscription_id);
	$subscription->cancel();
	
	//#TODO: We should let the cron job handle this, so the user gets the rest of their month's service.
	//turn off experiments and update snippets. clear stripe IDs. set current_period_end to yesterday. set past_due = 1
	$current_period_end   = new DateTime;  
	$current_period_end->modify( '-1 day' );
	$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 
	$sql = "UPDATE `account` SET billing_customer_id = '', stripe_subscription_id = '', past_due = 1, current_period_end = ? WHERE accountid = ?"; 
	$result = $conn->prepare($sql); 
	$result->execute(array($current_period_end, $accountid));

	//turn off all experiments
	$status = "Not running";
	$sql = "UPDATE `experiment` set status = ? where accountid = ?";
	$result2 = $conn->prepare($sql); 
	$result2->execute(array($status, $accountid));

	//update all snippets for this account (1 snippet per project)
	$sql = "SELECT * FROM `project` WHERE accountid = ?";
	$result3 = $conn->prepare($sql); 
	$result3->execute(array($accountid));
	$rows3 = $result3->fetchAll(PDO::FETCH_ASSOC);
	foreach ($rows3 as $key3 => $value3) {
		$projectid = $value3['projectid'];
    	$write_snippet_service = new ProjectService();
		$write_snippet_service -> writeSnippetFile(false, false, $projectid);
	}

	$this->status = "complete";
}

Being able to charge money for your web based software is an important step in building a SAAS business. Using a Stripe as your payment infrastructure makes it easy. Build stuff that people love and you can get paid to do it!

Update

I recently integrated Stripe payments for one of my apps, BJJ Tracker. I used version 13.0.0 of Stripe’s PHP library, which requires a slightly different code syntax. For this use-case I only needed to create a one-time payment instead of a subscription. I was able to create a charge on the fly, and did not need to create a “product” in the Stripe dashboard:

$stripe = new \Stripe\StripeClient('sk_test_XXX');
$customer = $stripe->customers->create([
	'email' => $_SESSION['email'],
	'source' => $stripe_token
]);
$charge = $stripe->charges->create([
	'amount' => 1000,
	'currency' => 'usd',
	'description' => 'BJJ Tracker',
	'customer' => $customer->id,
]);

$sql = "UPDATE `users` SET paid = ?, customer_id = ? WHERE ID = ?"; 
$result = $conn->prepare($sql);
 
$result->execute(array($charge->id, $customer->id, $_SESSION['userid']));

 

Visual Website Editor in PHP

visual editor in php

Tools for non-programmers to manage websites are growing in demand. No-code solutions increase bandwidth. Many SaaS products promise marketers and project managers a way to get things done without a developer. A visual website editor baked into an app is an important technology for digital product builders. The ability for a software service to interact with a client’s website can be a critical selling point.

SplitWit A/B Testing is an example of a web app that had that requirement. It lets user specify an existing website and displays it in an editor window. The user can then click on elements, make changes, and add new content. Those changes are actually applied to the live website by a JavaScript snippet. This post explains how it was built using PHP and jQuery.

Showing the website via iFrame

Displaying an already existing website on your app is easy with an iFrame. But, since users should be able to view and edit any page they choose, challenges arise. If a non-SSL URL is used (http:// instead of https://), the editor app will throw a “mixed-content warning”. If the page has X-FRAME-OPTIONS set to DENY, then the request will fail. To get around these issues I load an internal page into the iFrame that contains all of the DOM code from the one specified.

var pageIframe = $('<iframe id="page-iframe" style="" src="/page-to-edit.php?baseUrl='+baseUrl+'&url=<?php echo $url; ?>"></iframe>').appendTo(".page-iframe-wrapper");

The page to be edited is entered into an input field.  That 3rd party webpage’s URL is passed along to my internal “page-to-edit.php” as a query parameter. From there, I get its full code using PHP Simple HTML DOM Parser. You’ll notice that I also pass along a “base url” – which is the root domain of the page. I’m able to grab it using JavaScript.

<?php
$url = "";
if(isset($_GET['url']) && strlen($_GET['url']) > 0){
  $url = $_GET['url'];
}
?>
function removeSubdirectoryFromUrlString(url, ssl){
  
  var ssl = ssl || false;
  if(url.indexOf("https://")){
    ssl = true;
  }

  url = url.replace("http://", "");
  url = url.replace("https://", "");
  var pathArray = url.split("/")
  url = pathArray[0];
  if(ssl){
    url = "https://" + url;
  }else{
    url = "http://" + url;
  }

  return url;
}

var url = "<?php echo $url; ?>";
var ssl = false;
var pageIframe;
 
if(url.length > 0){

  if(url.indexOf("https:") !== -1){
    ssl = true
  }
   
  var baseUrl = removeSubdirectoryFromUrlString(url, ssl);
  if(baseUrl.slice(-1) !== "/"){
    baseUrl = baseUrl + "/";
  }
}

I need to append that to any assets (images, scripts, etc.) that use relative references – or else they won’t load.

<?php 
// Report all errors
error_reporting(E_ALL);
ini_set("display_errors", 1);
require 'simple_html_dom.php';


class HtmlDomParser {
	
	/**
	 * @return \simplehtmldom_1_5\simple_html_dom
	 */
	static public function file_get_html() {
		return call_user_func_array ( '\simplehtmldom_1_5\file_get_html' , func_get_args() );
	}

	/**
	 * get html dom from string
	 * @return \simplehtmldom_1_5\simple_html_dom
	 */
	static public function str_get_html() {
		return call_user_func_array ( '\simplehtmldom_1_5\str_get_html' , func_get_args() );
	}
}

$base_url = $_GET['baseUrl'];
$url = $_GET['url'];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, TRUE);
$html = curl_exec($ch);
$redirectedUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
$url = $redirectedUrl;

$parse = parse_url($url);

$base_url = $parse["scheme"] . "://" . $parse["host"];

$html = @HtmlDomParser::file_get_html($url);

if(substr($base_url, 0, 1) == "/"){
    $base_url = substr_replace($base_url ,"",-1);
}

if($html === FALSE) { 
    echo "Sorry, we do not have permission to analyze that website. ";
    return;
}
foreach($html->find('script') as $element){
    $src = $element->src;
    // echo "<script>console.log('starting src: ".$src."')</script>";
    
    if (strlen($src) > 0 && strpos($src, '//') === false){
        if(substr($src, 0, 1) !== "/"){
            $src = "/" . $src;
        }
        $element->src = $base_url . $src;
    }
    if(strlen($element->integrity) > 0){
        $element->integrity = "";
    }
    // echo "<script>console.log('final src: ".$base_url . $src."')</script>";

    // echo $element->src . "\n";
} 
foreach($html->find('link') as $element){
    $src = $element->href;
    
    if (strlen($src) > 0 && strpos($src, '//') === false){
        if(substr($src, 0, 1) !== "/"){
            $src = "/" . $src;
        }
        $element->href = $base_url . $src;
    }
    if(strlen($element->integrity) > 0){
        $element->integrity = "";
    }
   
}
foreach($html->find('a') as $element){
    $src = $element->href;
    
    if (strlen($src) > 0 && strpos($src, '//') === false){
        if(substr($src, 0, 1) !== "/"){
            $src = "/" . $src;
        }
        $element->href = $base_url . $src;
    } 
  
}
foreach($html->find('img') as $element){
    $src = $element->src;
    if (strlen($src) > 0 && strpos($src, '//') === false){
        if(substr($src, 0, 1) !== "/"){
            $src = "/" . $src;
        }

        $element->src = $base_url . $src;
    } 
   
}
foreach($html->find('source') as $element){
    $src = $element->srcset;
    $sources = explode(",",$src);
    $src = trim($sources[0]);

    if (strlen($src) > 0 && strpos($src, '//') === false){
        if(substr($src, 0, 1) !== "/"){
            $src = "/" . $src;
        }

        $element->srcset = $base_url . $src;
    } 
}

echo $html;

?>

I check against five different element types for assets that could need to be updated: <script>, <link>, <a>, <img>, and <source>. In version 1.0, I had missed the <link> and <source> elements.

Now that I was able to load any website into my editor UI, I had to think about layout. I split the screen in half, having the right side display the website, and the left side showing editor options.

visual website editor

Update: Eventually, I noticed that some pages were not loading properly. I found it to be two issues: a security error & a permission-denied response. The security error was a PHP problem. The permission-denied (401) response was due to server settings. Some sites don’t want to be accessed by bots and try to ensure that an actual user is making the request. I was able to fix it by passing context settings to the ‘file_get_html’ method.

$arrContextOptions=array(
    "ssl"=>array(
        "verify_peer"=>false,
        "verify_peer_name"=>false,
    ),
    "http" => array(
        "header" => "User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.102 Safari/537.36"
    )
); 
$html = file_get_html($url, false, stream_context_create($arrContextOptions));

Element selection

Page-clicks in the editor window need to be intercepted to stop links from being followed and any other actions from happening. Instead, any thing clicked is selected by the editor and its details shown in the options panel.

highlight element selected

As elements are moused over, a semi-transparent red highlight is applied as a visual cue. It is achieved by injecting CSS into the iFrame and adding mouseenter and mouseout event listeners.

pageIframe.on('load', function(){
    var style = "<style>.highlighted{background-color:rgba(255, 0, 0, 0.5);} </style>";         
    pageIframe.contents().find("body").prepend(style);
     
    pageIframe.contents().find("body *").mouseenter(function(){
	$(this).addClass('highlighted'); 
    }).mouseout(function(){
	$(this).removeClass('highlighted');   
    });

});

When something is clicked, normal functionality is stopped by using the web browser’s preventDefault() method. Once the element is grabbed (using the this keyword), we want to be sure that we’re as deep into the DOM tree as possible. That way, our “Text / HTML” content won’t contain unnecessary code tags.

To do so, we use a while loop to iterate an arbitrary number of times. Eight iterations seems to be the number that gets us to the heart of any selected content, without taking too much time.

First, we check if the element has any child nodes – if it does, we grab the first one. If that node happens to be a <style> or <script> tag, we move onto the second instead. As long as that new node is not a formatting element (<strong>, <em>, etc.), we set it as the new element before continuing our loop.

testSelectorEl = $(this);
var i = 8;
while(i > 0){
	if ( $(testSelectorEl).children().length > 0 ) {
	  nextEl = $(testSelectorEl).find(">:first-child");
	  if(nextEl.is( "style" ) || nextEl.is( "script" ) || nextEl.is( "noscript" )){
	     nextEl = $(testSelectorEl).find(">:nth-child(2)");
	  }
	  if ( !nextEl.is( "u" ) && !nextEl.is( "i" ) && !nextEl.is( "strong" ) && !nextEl.is( "em" )) {
	    testSelectorEl = nextEl;
	  }
	}
	i--;
}

The above code is added to the click event listener on all elements within the iFrame. Next, below that code, we determine a unique CSS selector by which we can later reference the element. This is important for writing any changes to the JavaScript snippet to effect changes on the 3rd-party website.

var node = testSelectorEl;
var path = "";
while (node.length) {
  var realNode = node[0], name = realNode.localName;
  if (!name) break;
  name = name.toLowerCase();

  var parent = node.parent();

  var siblings = parent.children(name);
  if (siblings.length > 1) { 
      name += ':eq(' + siblings.index(realNode) + ')';
  }

  path = name + (path ? '>' + path : '');
  node = parent;
}
var value = path;
$(".selector-input").val(value);

The final iFrame on-load function looks like this:

var url = "<?php echo $url; ?>";
var ssl = false;
var pageIframe;
 
if(url.length > 0){

  if(url.indexOf("https:") !== -1){
    ssl = true
  }
   
  var baseUrl = removeSubdirectoryFromUrlString(url, ssl);
  if(baseUrl.slice(-1) !== "/"){
    baseUrl = baseUrl + "/";
  }

  var style = "<style>.highlighted{background-color:rgba(255, 0, 0, 0.5);} </style>";


  var pageIframe = $('<iframe id="page-iframe" style="" src="/new-page-to-edit.php?baseUrl='+baseUrl+'&url=<?php echo $url; ?>"></iframe>').appendTo(".page-iframe-wrapper");
     
  pageIframe.on('load', function(){

    pageIframe.contents().find("body").prepend(style);
       
    pageIframe.contents().find("body *").mouseenter(function(){

      $(this).addClass('highlighted'); 

      testSelectorEl = $(this);
        
    }).mouseout(function(){

      $(this).removeClass('highlighted');   

    }).click(function(e){

      e.preventDefault();
      e.stopPropagation();
      
      //dig deeper down the dom
      var i = 8;
      while(i > 0){
        if ( $(testSelectorEl).children().length > 0 ) {
          nextEl = $(testSelectorEl).find(">:first-child");
          if(nextEl.is( "style" ) || nextEl.is( "script" ) || nextEl.is( "noscript" )){
             nextEl = $(testSelectorEl).find(">:nth-child(2)");
          }
          if ( !nextEl.is( "u" ) && !nextEl.is( "i" ) && !nextEl.is( "strong" ) && !nextEl.is( "em" )) {
            testSelectorEl = nextEl;
          }
        }
        i--;
      }
      
      var node = testSelectorEl;
      var path = "";
      while (node.length) {
          var realNode = node[0], name = realNode.localName;
          if (!name) break;
          name = name.toLowerCase();

          var parent = node.parent();

          var siblings = parent.children(name);
          if (siblings.length > 1) { 
              name += ':eq(' + siblings.index(realNode) + ')';
          }

          path = name + (path ? '>' + path : '');
          node = parent;
      }
      var value = path;
                
      $(".selector-input").val(value); //for html insert section (redundant for change element section)
      if(! $(".insert-html-wrap").is(':visible')){
        selectNewElement(value); // prepare editor options        
        $(".page-editor-info").offset().top; //scroll user to selector input
      }
      return false;
    });

   
   //make sure images load   
   pageIframe.contents().find("img").each(function(){
      var src = $(this).attr("src");
      if(src && src.length > 0 && src.indexOf("//") == -1){  //if not absolute reference
        if(src.charAt(0) !== "/"){
          src = "/" + src;
        }
        $(this).attr("src", baseUrl + src);
      }
   });
   
   //make sure links aren't followed  
   pageIframe.contents().find("a").each(function(){
      var href = $(this).attr("href");
      $(this).attr("href", "");
      $(this).attr("data-href", href);
   });

   pageIframe.contents().find("body").attr("style", "cursor: pointer !important");
    
   $(".loading-iframe").hide();

  }); //page-iframe load
   
}else{
  //no URL found
   $(".loading-iframe").hide();
}

Lastly, we prepare the editor options panel. This involves setting the original content for the selected element and removing any newly added features (modals, sticky-bars) that were not saved.

Editor Options

After an element is clicked its content and style properties are loaded into the editor panel. The original values are stored in memory. As users edit values a change indicator icon is revealed and the right-panel editor view is updated in real time.

var testSelectorEl;
var testSelectorElPath = "";
var testSelectorElHtml = "";
var testSelectorElImage = "";
var testSelectorElLink = "";
var originalVisibilityState = "";
var originalValues = [];
originalValues['height'] = "";
originalValues['width'] = "";
originalValues['margin'] = "";
originalValues['padding'] = "";
originalValues['border'] = "";
originalValues['font-family'] = "";
originalValues['font-weight'] = "";
originalValues['font-style']= "";
originalValues['text-decoration'] = "";
originalValues['background'] = "";
originalValues['css'] = "";
originalValues['classes'] = "";

$(".html-input").keyup(function(){
    var value = $(this).val();  
    if (value !== testSelectorElHtml){
      $(this).parent().find(".change-indicator").show();
    }else{
      $(this).parent().find(".change-indicator").hide();
    }

    if($(".change-indicator").is(":visible")){
      $(".element-change-save-btn").removeAttr("disabled");
    }else{
      $(".element-change-save-btn").attr("disabled", "disabled");
    }

    var selector = $(".selector-input").val();
    var iFrameDOM = $("iframe#page-iframe").contents()
    iFrameDOM.find(selector).html(value);
    
  });

Besides changing existing elements, new ones can also be added. The “insert content” section, also based on selecting an element, lets users add new text, html, or images before or after whatever has been clicked. In both sections, the CSS selector can also be manually typed (instead of clicking). Adding or editing images is handled by a custom built image upload gallery that leverages AWS S3 and PHP.

insert content

Out-of-the-box elements, such as sticky bars and modals, can also be added with a few clicks and configurations. The HTML and CSS for those are pre-built, with variables for any options that may be set. Any changes made are saved to the database in relation to the user account, project, experiment, and variation.

function addSticky(){
	$conn = $this->connection;
	$accountid = $this->accountid;
	$variationid = $_GET['variationid'];
	$experimentid = $_GET['experimentid'];
	$text = $_POST['text'];
	$color = $_POST['color'];
	$background = $_POST['background'];
	$position = $_POST['position'];
	$linkurl = $_POST['linkurl'];

	$insertStatement = "INSERT INTO `variationchange` (accountid, variationid, experimentid, selector, changetype, changecode, content) VALUES (:accountid, :variationid, :experimentid, :selector, :changetype, :changecode, :content)";
	$changetype = "stickybar";
	$selector = "body";

	$link_opening = "";
	$link_closing = "";

	if(strlen($linkurl) > 0){
		$link_opening = "<a style='color:".$color."' href='".$linkurl."'>";
		$link_closing = "</a>";
	}

	$sticky_html = "<div style='font-weight:bold;".$position.":0;position:fixed;z-index:100000;left:0px;text-align:center;padding:8px 20px;width:100%;background:".$background.";color:".$color."' id='splitwit-sticky'><p style='margin:0px'>".$link_opening.$text.$link_closing."</p></div>";

	$changecode = '$("body").append("'.$sticky_html.'")';

	$stmt = $conn->prepare($insertStatement);	
	$stmt->bindParam(':accountid', $accountid);
	$stmt->bindParam(':variationid', $variationid);
	$stmt->bindParam(':experimentid', $experimentid);
	$stmt->bindParam(':selector', $selector);
	$stmt->bindParam(':changetype', $changetype);
	$stmt->bindParam(':changecode', $changecode);
	$stmt->bindParam(':content', $text);
	$stmt->execute();	

	$this->writeSnippetFile($variationid);
}
		

Writing changes to the snippet

Every project has a unique JavaScript file that users must add to their webpage. The file is hosted by SplitWit, so website owners only need to copy/paste a snippet. The WordPress and Shopify plugins automatically add the snippet, making it even more friendly to non-developers.

Each project may contain multiple experiments with their own changes, metrics, variations, and conditions. After the data is produced by a SQL join statement, it is massaged into a nested object and parsed in JSON. That JavaScript output is concatenated to the necessary libraries and helper functions.

function writeSnippetFile($variationid=false, $experimentid=false, $projectid=false){
	
	$conn = $this->connection;
	$variationid = $variationid || false;
	$experimentid = $experimentid || false;
	
	if(isset($_GET['variationid'])){
		$variationid = $_GET['variationid'];
	}
	if(isset($_GET['experimentid'])){
		$experimentid = $_GET['experimentid'];
	}


	if($variationid){
		$variationid = $_GET['variationid'];
		$sql = "SELECT experiment.projectid FROM `variation` right join `experiment` on variation.experimentid = experiment.experimentid WHERE variationid = ?"; 
		$result = $conn->prepare($sql); 
		$result->execute(array($variationid));
		$experiment_row = $result->fetch(PDO::FETCH_ASSOC);
	}elseif($experimentid){
		$experimentid = $_GET['experimentid'];
		$sql = "SELECT projectid FROM `experiment` WHERE experimentid = ?"; 
		$result = $conn->prepare($sql); 
		$result->execute(array($experimentid));
		$experiment_row = $result->fetch(PDO::FETCH_ASSOC);
	}
	
	if(!$projectid){
		$projectid = $experiment_row['projectid'];
	}

	$sql = "SELECT experiment.experimentid, experiment.status, experimentcondition.experimentconditionid, variation.variationid, variationchange.variationchangeid, variationchange.changecode, variation.css, variation.javascript, experimentcondition.url, experimentcondition.matchtype, experimentcondition.conditiontype, metric.metricid, metric.type, metric.input, metric.urlmatch FROM `experiment` left join `metric` on metric.experimentid = experiment.experimentid join `experimentcondition` on experimentcondition.experimentid = experiment.experimentid join `variation` on variation.experimentid = experiment.experimentid left join `variationchange` on variationchange.variationid = variation.variationid WHERE experiment.projectid = ?"; 
	
	// echo "<br />"."variationid: ".$variationid . "<br />";
	// echo "<br />"."experimentid: ".$experimentid . "<br />";
	// echo "<br />"."projectid: ".$projectid . "<br />";

	// $this->status = "SQL: " . $sql;
	$result = $conn->prepare($sql); 
	$result->execute(array($projectid));
	 
	$experiment_row = $result->fetchAll(PDO::FETCH_ASSOC);
	 
	//turn flat array, into a nested one
	$endResult = array();
	foreach($experiment_row as $row){
	    if (!isset($endResult[$row['experimentid']])){
	        $endResult[$row['experimentid']] = array(
	            'experimentid' => $row['experimentid'],
	            'status' => $row['status'],
	            'conditions' => array(),
	            'variations' => array(),
	            'metrics' => array()
	        );
	    }

	    if (!isset($endResult[$row['experimentid']]['conditions'][$row['experimentconditionid']])){
		    $endResult[$row['experimentid']]['conditions'][$row['experimentconditionid']] = array(
		        'experimentconditionid' => $row['experimentconditionid'],
		        'url' => $row['url'],
		        'matchtype' => $row['matchtype'],
		        'conditiontype' => $row['conditiontype']
		    );
		}

	    if (!isset($endResult[$row['experimentid']]['variations'][$row['variationid']])){
		    $endResult[$row['experimentid']]['variations'][$row['variationid']] = array(
		        'variationid' => $row['variationid'],
		        'javascript' => $row['javascript'],
		        'css' => $row['css'],
		        'changes' => array()
		    );
		}

	    if (!isset($endResult[$row['experimentid']]['variations'][$row['variationid']]['changes'][$row['variationchangeid']])){
		   	$endResult[$row['experimentid']]['variations'][$row['variationid']]['changes'][$row['variationchangeid']] = array(
		        'variationchangeid' => $row['variationchangeid'],
		        'changecode' => $row['changecode']
		    );
		}

	    if (!isset($endResult[$row['experimentid']]['metrics'][$row['metricid']])){
		    $endResult[$row['experimentid']]['metrics'][$row['metricid']] = array(
		        'metricid' => $row['metricid'],
		        'type' => $row['type'],
		        'input' => $row['input'],
		        'urlmatch' => $row['urlmatch']
		    );
		}
		
	}

	$json_output = json_encode($endResult);
	// echo $json_output;
	// get snippet file name.
	$sql = "SELECT snippet from `project` where projectid=?";
	$result = $conn->prepare($sql); 
	$result->execute(array($projectid));
	$projectrow = $result->fetch(PDO::FETCH_ASSOC);
	$filename = $projectrow['snippet'];
	// echo $filename;
	$snippet_template = file_get_contents("/var/www/html/snippet/snippet-template.min.js");
	// concat json to snippet template. write file			
	$myfile = fopen("/var/www/html/snippet/".$filename, "w") or die("Unable to open file!");
	//if there are any animation changes, include the necessary library.
	$txt = "";
	if(strpos($json_output, "addClass('animated") !== false){
		$txt .= 'var head=document.getElementsByTagName("head")[0],link=document.createElement("link");link.rel="stylesheet";link.type="text/css";link.href="https://www.splitwit.com/css/animate.min.css";link.media="all";head.appendChild(link); ';
	}
	$txt .= "window.splitWitExperiments = ".$json_output . "\n" . $snippet_template;
	
	fwrite($myfile, $txt) or die("Unable to save file!");

	fclose($myfile);

	//if this project is for a shopify app, we need to update the snippet cache
	$update_snippet_url = "https://www.splitwit.com/service-layer/shopify-app-service.php?method=updateSnippetScriptTag&projectid=".$projectid;
	$params = [];
	$curl_update_snippet = $this->curlApiUrl($update_snippet_url, $params);

}

One of the helper functions is a method that checks if the current URL matches the experiment’s conditions to run on:

function testUrl(testurl, conditions){
	
	if(testurl.search(/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/) < 0){
		console.log("no domain")
		return window.inputError($(".test-url-input"), "Please test a valid URL.");
	}
	var valid = false;

	for (i = 0; i < conditions.length; i++) { 
		 var url = conditions[i].url;
		 var matchtype = conditions[i].matchtype;
		 var conditiontype = conditions[i].conditiontype;

		 //exact match
		 if(matchtype == "exact" && conditiontype == "target" && url == testurl){
		 	valid = true;
		 }
		 if(matchtype == "exact" && conditiontype == "exclude" && url == testurl){
		 	valid = false;
		 }

		 //basic match
		 if(matchtype == "basic"){
		 	//strip off querystring, hashtags, protocols, and www from url and testurl - then compare
		 	var cleanTestUrl = testurl.toLowerCase();
		 	var cleanUrl = url.toLowerCase();

		 	if (cleanTestUrl.indexOf("?") > 0) {
					cleanTestUrl = cleanTestUrl.substring(0, cleanTestUrl.indexOf("?"));
				}
		 	if (cleanUrl.indexOf("?") > 0) {
					cleanUrl = cleanUrl.substring(0, cleanUrl.indexOf("?"));
				}
		 	if (cleanTestUrl.indexOf("&") > 0) {
					cleanTestUrl = cleanTestUrl.substring(0, cleanTestUrl.indexOf("&"));
				}
		 	if (cleanUrl.indexOf("&") > 0) {
					cleanUrl = cleanUrl.substring(0, cleanUrl.indexOf("&"));
				}
		 	if (cleanTestUrl.indexOf("#") > 0) {
					cleanTestUrl = cleanTestUrl.substring(0, cleanTestUrl.indexOf("#"));
				}
		 	if (cleanUrl.indexOf("#") > 0) {
					cleanUrl = cleanUrl.substring(0, cleanUrl.indexOf("#"));
				}
				cleanTestUrl = cleanTestUrl.replace(/^(?:https?:\/\/)?(?:www\.)?/i, "");
				cleanUrl = cleanUrl.replace(/^(?:https?:\/\/)?(?:www\.)?/i, "");
				cleanTestUrl = cleanTestUrl.replace(/\/$/, "");
				cleanUrl = cleanUrl.replace(/\/$/, ""); //remove trailing slash

				// console.log(cleanTestUrl);
				// console.log(cleanUrl);
				if(conditiontype == "target" && cleanUrl == cleanTestUrl){
					valid = true;
				}
				if(conditiontype == "exclude" && cleanUrl == cleanTestUrl){
					valid = false;
				}

		 }
		 
		 //substring match
		 if(matchtype == "substring"){
		 	if(testurl.includes(url) && conditiontype == "target"){
					valid = true;
		 	}
		 	if(testurl.includes(url) && conditiontype == "exclude"){
					valid = false;
		 	}
		 }
		 
	} //end conditions loop
	
	$(".test-url-msg").hide();		
	if(valid){
		$(".valid-msg").fadeIn();
	}else{
		$(".invalid-msg").fadeIn();
	}

}

While the snippet code is specific to this use-case (experimental A/B UI changes), the visual editor can be used in a variety of other contexts. You can look through more of the code I used in this GitHub repository I created.

Build an image upload gallery

media gallery upload with s3

Allowing users to upload images to your app can be a pivotal feature. Many digital products rely on it. This post will show you how to do it using PHP and AWS S3.

image upload gallery

After launching version 1.0 of SplitWit, I decided to enhance the platform by adding features. An important A/B experiment involves swapping images. This is particularly useful on ecommerce stores that sell physical products.

Originally, users could only swap images by entering a URL. To the average website owner, this would seem lame. For SplitWit to be legit, adding images on the fly had to be a feature.

I wrote three scripts – one to upload files, one to fetch them, and one to delete them. Each leverages a standalone PHP class written by Donovan Schönknecht, making it easy to interact with AWS S3. All you’ll need is your S3 bucket name and IAM user credentials. The library provides methods to do everything you need.

AWS S3

Amazon S3 stands for “simple storage service”. It provides data storage that is scalable, secure, highly available, and performant.

A new bucket can be created directly from the management console.

create new s3 bucket

You’ll want to create a new IAM user to programmatically interact with this bucket. Make sure that new user is added to a group that includes the permission policy “AmazonS3FullAccess”. You can find the access key ID and secret in the “Security credentials” tab.

IAM user in AWS with permissions for S3

Uploading image files

When users select an image in the visual editor, they are shown a button to upload a new file. Clicking on it opens the gallery modal.

<div id="image-gallery-modal" class="modal image-gallery-modal" style="display: none;">
  <div class="modal-content">
    <h3>Your image gallery</h3>
    <p><strong>Upload a new file:</strong></p>
    <input class="uploadimage" id="uploadimage" type="file" name="uploadimage" />
    <p class="display-none file-error"></p>
    <div><hr /></div>
    <div class="image-gallery-content"></div>
    <p class="loading-images"><i class="fas fa-spinner fa-spin"></i> Loading images...</p>
  </div>
</div>

The HTML file-type input element presents a browser dialog to select a file. Once selected, the image data is posted to the S3 upload script. The newly uploaded image then replaces the existing image in the visual editor. 

$(".uploadimage").change(function(){
    
    var file = $(this)[0].files[0];   
    var formData = new FormData();
    formData.append("file", file, file.name);
    formData.append("upload_file", true);         

    $.ajax({
      type: "POST",
      url: "/s3-upload.php",
      xhr: function () {
        var myXhr = $.ajaxSettings.xhr();
        if (myXhr.upload) {
            // myXhr.upload.addEventListener('progress', that.progressHandling, false);
        }
        return myXhr;
      },
      success: function (response) {
        console.log(response);
        
        document.getElementById("uploadimage").value = "";

        if(response !== "success"){
          $(".file-error").text(response).show();
          setTimeout(function(){ $(".file-error").fadeOut();}, 3000)
          return;
        }
        
        $("#image-gallery-modal").hide();
        loadS3images();
        var newImageUrl = "https://splitwit-image-upload.s3.amazonaws.com/<?php echo $_SESSION['userid'];?>/" + file.name;
        $("input.img-url").val(newImageUrl);
        $(".image-preview").attr("src", newImageUrl).show();
        $(".image-label .change-indicator").show();

        //update editor (right side)
        var selector = $(".selector-input").val();
        var iFrameDOM = $("iframe#page-iframe").contents()
        if($(".element-change-wrap").is(":visible")){
          iFrameDOM.find(selector).attr("src", newImageUrl).attr("srcset", "");
          $(".element-change-save-btn").removeAttr("disabled");
        }
        if($(".insert-html-wrap").is(":visible")){
          var position = $(".position-select").val();
          var htmlInsertText = "<img style='display: block; margin: 10px auto;' class='htmlInsertText' src='"+newImageUrl+"'>";
          iFrameDOM.find(".htmlInsertText").remove();
          if(position == "before"){
            iFrameDOM.find(selector).before(htmlInsertText);
          }
          if(position == "after"){
            iFrameDOM.find(selector).after(htmlInsertText);
          }
        }
      },
      error: function (error) {
        console.log("error: ");
        console.log(error);
      },
      async: true,
      data: formData,
      cache: false,
      contentType: false,
      processData: false,
      timeout: 60000
  });

});

The upload script puts files in the same S3 bucket, under a separate sub-directory for each user account ID. It checks the MIME type on the file to make sure an image is being uploaded.

<?php
require 's3.php';
 
$s3 = new S3("XXXX", "XXXX"); //access key ID and secret

// echo "S3::listBuckets(): ".print_r($s3->listBuckets(), 1)."\n";

$bucketName = 'image-upload';

if(isset($_FILES['file'])){
	$file_name = $_FILES['file']['name'];   
	$uploadFile = $_FILES['file']['tmp_name']; 

	if ($_FILES['file']['size'] > 5000000) { //5 megabyte
     	   echo 'Exceeded filesize limit.';
     	   die();
    	}
    	$finfo = new finfo(FILEINFO_MIME_TYPE);
	if (false === $ext = array_search(
	        $finfo->file($uploadFile),
	        array(
	            'jpg' => 'image/jpeg',
	            'png' => 'image/png',
	            'gif' => 'image/gif',
	        ),
	        true
	    )) {
	    	if($_FILES['file']['type'] == ""){
	    		echo 'File format not found. Please re-save the file.';
	    	}else{
		    	echo 'Invalid file format.';
		    }
     	    die();
	 }

	//create new directory with account ID, if it doesn't already exist
	session_start();
	$account_id = $_SESSION['userid'];

	if ($s3->putObjectFile($uploadFile, $bucketName, $account_id."/".$file_name, S3::ACL_PUBLIC_READ)) {
		echo "success";
	}

}
?>

After upload, the gallery list is reloaded by the loadS3images() function.

Fetching image files from S3

When the image gallery modal first shows, that same loadS3images() runs to populate any images that have been previously uploaded.

function loadS3images(){

  $.ajax({
      url:"/s3-get-objects.php",
      complete: function(response){
        gotImages = true;
        $(".loading-images").hide();
        var data = JSON.parse(response.responseText);
        var x;
        var html = "<p><strong>Select existing file:</strong></p>";
        var l = 0;
        for (x in data) {
          l++;
          var name = data[x]["name"];
          nameArr = name.split("/");
          name = nameArr[1];
          var imgUrl = "https://splitwit-image-upload.s3.amazonaws.com/<?php echo $_SESSION['userid'];?>/" + name;
          html += "<div class='image-data-wrap'><p class='filename'>"+name+"</p><img style='width:50px;display:block;margin:10px;' src='' class='display-none'><button type='button' class='btn select-image'>Select</button> <button type='button' class='btn preview-image'>Preview</button> <button type='button' class='btn delete-image'>Delete</button><hr /></div>"
        }
        if(l){
          $(".image-gallery-content").html(html);
        }

      }
    });
}

It hits the “get objects” PHP script to pull the files in the account’s directory.

<?php
require 's3.php';
 
$s3 = new S3("XXX", "XXX"); //access key ID and secret

$bucketName = 'image-upload';
session_start();
$account_id = $_SESSION['userid'];
$info = $s3->getBucket($bucketName, $account_id);
echo json_encode($info);

?>

Existing images can be chosen to replace the one currently selected in the editor. There are also options to preview and delete.

Delete an S3 object

When the delete button is pressed for a file in the image gallery, all we need to do is pass the filename along. If the image is currently being used, we also remove it from the editor.

$(".image-gallery-content").on("click", ".delete-image", function() {
    var parent = $(this).parent();
    var filename = parent.find(".filename").text();
    var currentImageUrl = $(".img-url").val();
    if(currentImageUrl =="https://splitwit-image-upload.s3.amazonaws.com/<?php echo $_SESSION['userid'];?>/" + filename){
      $(".img-url").val(testSelectorElImage);
      $(".image-preview").attr("src", testSelectorElImage);
      var selector = $(".selector-input").val();
      var iFrameDOM = $("iframe#page-iframe").contents()
      iFrameDOM.find(selector).attr("src", testSelectorElImage);
    }
    $.ajax({
      method:"POST",
      data: { 
        'filename': filename, 
      },
      url: "/s3-delete.php?filename="+filename,
      complete: function(response){
        parent.remove();
        if(!$(".image-data-wrap").length){
          $(".image-gallery-content").html("");
        }
      }
    })

}); 

 

<?php
require 's3.php';
 
$s3 = new S3("XXX", "XXX"); //access key ID and secret

$bucketName = 'image-upload';
session_start();
$account_id = $_SESSION['userid'];
$filename = $_POST['filename'];
if ($s3->deleteObject($bucketName, $account_id."/".$filename) ){
	echo "S3::deleteObject(): Deleted file\n";
}

?>

 

Reset password flow in PHP

reset password

My email account is a skeleton key to anything online I’ve signed up for. If I forget a password, I can reset it. Implementing this feature for a web app takes just a few steps.

When users enter an incorrect password, I prompt them to reset it.

incorrect password warning

Clicking the reset link calls a “forgot password” back-end service.

$(document).on("click",".reset-pw-cta", function(){
	var email = $(this).attr("data");
	$.ajax({
		url:"/service-layer/user-service.php?method=forgotPw&email="+email,
		complete:function(response){
			console.log(response.responseText)
			window.showStatusMessage("A password reset email as been sent to " + email);
		}
	})
});

A token is created in our ‘password recovery’ database table. That token is related back to an account record.

password recovery database table

As a security practice, recovery tokens are deleted nightly by a cron job.

An email is then sent containing a “reset password” link embedded with the token. AWS SES and PHPMailer is used to send that message.

function forgotPw(){
	$email = $this->email;
	$row = $this->row;
	$number_of_rows = $this->number_of_rows;
	$conn = $this->connection;
	if($number_of_rows > 0){
		$this->emailFound = 1;
		$userid = $row['ID'];
		$this->userid = $userid;

		//create reset token
		$timestamp = time();
		$expire_date = time() + 24*60*60;
		$token_key = md5($timestamp.md5($email));
		$statement = $conn->prepare("INSERT INTO `passwordrecovery` (userid, token, expire_date) VALUES (:userid, :token, :expire_date)");
		$statement->bindParam(':userid', $userid);
		$statement->bindParam(':token', $token_key);
		$statement->bindParam(':expire_date', $expire_date);
		$statement->execute();

		//send email via amazon ses
		include 'send-email-service.php';	
		$SendEmailService = new SendEmailService();

		$reset_url = 'https://www.bjjtracker.com/reset-pw.php?token='.$token_key;
	        $subject = 'Reset your password.';
	        $body    = 'Click here to reset your password: <a href="'.$reset_url.'">'. $reset_url .'</a>';
	        $altBody = 'Click here to reset your password: ' . $reset_url;
	        $this->status = $SendEmailService -> sendEmail($subject, $body, $altBody, $email);


	}else{
		$this->emailFound = 0;
	}
}

That link navigates to a page with a “reset password” form.

reset password form

Upon submission the new password and embedded token are passed along to the server.

$(document).ready(function() {
    $(".reset-button").click(function(){
      var newPassword = $(".password-reset-input").val();
      if(newPassword.length < 1){
        var notifications = new UINotifications();
        notifications.showStatusMessage("Please don't leave that blank :( ");
        return;
      }
      var data = $(".resetpw-form").serialize();
      $.ajax({
        url: "/service-layer/user-service.php?method=resetPw&token=<?php echo $_GET['token']; ?>",
        method: "POST",
        data: data,
        complete: function(response){
          // console.log(response);
          window.location = "/";
        }
      });
    });
    $("input").keypress(function(e) {
      if(e.which == 13) {
        e.preventDefault();
          $(".reset-button").click();
      }
  });
  

});

The correct recovery record is selected by using the token value. That provides the user ID of the account that we want to update. The token should be deleted once the database is updated.

function resetPw(){
	$conn = $this->connection;
	$token = $_GET['token'];
	$password = $_POST['password'];
	$passwordHash = password_hash($password, PASSWORD_DEFAULT);
	$statement = $conn->prepare("SELECT * FROM `passwordrecovery` where token = ?");
	$statement->execute(array($token));
	$row = $statement->fetch(PDO::FETCH_ASSOC);
	$userid = $row['userid'];

	$update_statement = $conn->prepare("UPDATE `users` SET password = ? where ID = ?");
	$update_statement->execute(array($passwordHash, $userid));

	$delete_statement = $conn->prepare("DELETE FROM `passwordrecovery` where token = ?");
	$delete_statement->execute(array($token));
}

This is a secure and user-friendly workflow to allow users to reset their passwords.

Custom UI notifications

UI feedback alerts

Showing brief notifications to website visitors is an important UI/UX component. They’re useful for providing feedback. They can communicate success, failure, or warnings.

Don Norman (The Design of Everyday Things) mentions that “Feedback is essential, but not when it gets in the way of other things, including a calm and relaxing environment” and goes on to say “Feedback is essential, but it has to be done correctly”.

A common use-case is data validation. Specifically, when logging in or signing up. If the user enters an invalid email address, or wrong login credentials, we need to let them know. The built in browser alert() is clunky and unsophisticated. Plugins are bloated and over-engineered. I wrote some basic HTML, CSS, and JavaScript that gets the job done and looks great.

My code provides two versions of the alert. The first is a basic sticky bar that fades in and out at the top of the page.

example of alert message for an invalid email address

The other flashes in the middle of the screen. I call it “in-your-face” alerts and reserve them for positive success messages.

example of a flashing UI alert to provide positive feedback to users

The CSS adds styles for both versions. Both utilize ‘position: fixed’ to stay in a set location on the page. The “in-your-face” example uses a pulse animation to achieve its effect.

<!-- UI-notifications.css -->
<style>
body{
  margin: 0px;
}
.status-message{
  display: none;
  color: white;
  text-align: center;
  font-size: 16px;
  padding: 8px;
  border-top: 1px solid white;
  border-bottom: 1px solid white;
  position: fixed;
  width: 100%;
  top: 0px;
  padding: 28px 8px;
  background-color: #b12650;
  z-index: 1000;
}
.status-message-inner{
  margin: 0px;
}

.status-message-close{
  cursor: pointer;
  position: fixed;
  right: 10px;
}
.in-your-face{
  display: none;
  position: fixed;
  top: 45%;
  width: 100%;
  text-align: center;
  font-size: 48px;
  color: white;
  z-index: 2;
}
.in-your-face-inner{
    background: #005b96;
    width: 80%;
    margin: 0 auto;
    opacity: .85;
    padding: 10px;
}
@keyframes pulse{
  50%  {transform: scale(1.2);}

}
.pulse{
  animation: pulse 0.5s ease-in infinite;
}
</style>
<!-- end UI-notifications.css -->

The javascript relies on jQuery as a dependency. It is written as a class, with a constructor and two methods. Each method takes message text as a parameter.

class UINotifications {
	constructor() {
		window.jQuery || document.write('<script src="js/vendor/jquery-1.11.2.min.js"><\/script>');
		var statusMessageHtml = '<div class="status-message"><p class="status-message-inner"><span class="status-message-text">Welcome to My App</span><span class="status-message-close">X</span></p></div>';
		var inYourFaceHtml = '<div class="in-your-face pulse"><p class="in-your-face-inner"><span class="in-your-face-text">Great Job!</span></p></div>';

		$(document).on("click", ".status-message-close", function(){
			$(".status-message").fadeOut();
		});

		this.statusMessage = $("<div/>").html(statusMessageHtml);
		this.inYourFace = $("<div/>").html(inYourFaceHtml);
		
		$('body').prepend(this.inYourFace);
		$('body').prepend(this.statusMessage);

	}

 	showStatusMessage(message){
 		var notifications = this;
	  	var message = message || "Default Message"
	  	var statusMessageTimeout;
	  	
		if(notifications.statusMessage.find(".status-message").is(':visible')){
	     clearTimeout(statusMessageTimeout);
	    }

		notifications.statusMessage.find(".status-message .status-message-text").html(message);
		notifications.statusMessage.find(".status-message").fadeIn();
		
	    statusMessageTimeout = setTimeout(function(){
	       notifications.statusMessage.find(".status-message").fadeOut(); 
	    }, 5000)
		
	}
	showInYourFace(message, callback){
		var notifications = this;
		var inYourFaceTimeout;
		var inYourFaceRandoms = ["Good work!", "Hard work!", "Nice job!", "Hustle!"]

		var message = message || inYourFaceRandoms[Math.floor(Math.random()*inYourFaceRandoms.length)];;
		var callback = callback || function(){};

		if(notifications.inYourFace.find(".in-your-face").is(':visible')){
	     clearTimeout(inYourFaceTimeout);
	    }

		notifications.inYourFace.find(".in-your-face .in-your-face-text").html(message);
		notifications.inYourFace.find(".in-your-face").show();
		
	    inYourFaceTimeout = setTimeout(function(){
	       notifications.inYourFace.find(".in-your-face").fadeOut(function(){
	       	callback();
	       }); 

	    }, 1000)
	}
}

This is a simple and lightweight solution to showing web app visitors informative alerts without using a plugin. Please, checkout the code and use it in your next project.

You can find the code on GitHub.

Error establishing connection to database – WordPress solution

solutions for wordpress database errors

A crashed database is a problem I’ve encountered across multiple WordPress websites. When trying to load the site you’re faced with a dreaded “Error establishing a database connection” message. Restarting the DB service usually clears things up. But, sometimes it won’t restart at all – which is why I started automating nightly data dumps to an S3 bucket.

Recently, one particular site kept going down unusually often. I assumed it was happening due to low computing resources on the EC2 t3.micro instance. I decide to spin up a a new box with more RAM (t3.small) and migrate the entire WordPress setup.

Since I couldn’t be sure of what was causing the issue, I needed a way to monitor the health of my WordPress websites. I decided to write code that would periodically ping the site, and if it is down send an email alert and attempt to restart the database.

warning message when a website can't connect to the database

The first challenge was determining the status of the database. Even if it crashed, my site would still return a 200 OK response. I figured I could use cURL to get the homepage content, and then strip out any HTML tags to check the text output. If the text did match the error message, I could take further action.

Next, I needed to programmatically restart MySql. This is the command I run to do it manually: sudo service mariadb restart 

After doing some research, I found that I could use shell_exec() to run it from my PHP code. Unfortunately, Apache wouldn’t let the (non-password using) web server user execute that without special authorization. I moved that command to its own restart-db.sh file, and allowed my code to run it by adding this to the visudo file: apache ALL=NOPASSWD: /var/www/html/restart-db.sh

My visudo file was located at /usr/sbin/visudo. It is a tool found on most Linux systems to safely update the /etc/sudoers file, which is the configuration file for the sudo command. To edit this file, I don’t open it in vim like I would with other editable files. Instead, I run the file as its own command: sudo visudo. Once it is open, you can press the i key to enter “insert” mode. It is considered “safe” because it edits the sudoers file following a strict procedure.

edit the visduo file

I also needed to make the file executable by adjusting permissions: sudo chmod +x /var/www/html/restart-db.sh

Once those pieces were configured, my code would work:

<?php

$url = "https://www.antpace.com/blog/";
$curl_connection = curl_init();

curl_setopt($curl_connection, CURLOPT_URL, $url);

curl_setopt($curl_connection, CURLOPT_RETURNTRANSFER, true);
$curl_response = curl_exec($curl_connection);
$plain_text = strip_tags($curl_response);

if(strpos($plain_text, "Error establishing a database connection") !== false){
	echo "The DB is down.";
        
        //restart the database
        shell_exec('sudo /var/www/html/restart-db.sh');
        
        //send notification email
        include 'send-email.php';
        send_email();
}else{
	echo "The DB is healthy.";
}

?>

You can read more about how to send a notification email in another post that I wrote on this blog.

The contents of restart-db.sh looks like this:

#!/bin/bash

sudo service mariadb restart

Create the cron job

A cron job is a scheduled task in Linux that runs at set times. For my PHP code to effectively monitor the health of the database, it needs to run often. I decided to execute it every five minutes. Below are three shell commands to create a cron job.

The first creates the cron file for the root user:

sudo touch /var/spool/cron/root

The next appends my cron command to that file:

echo "*/5 * * * * sudo wget -q https://www.antpace.com/check-db-health.php" | sudo tee -a /var/spool/cron/root

And, the last sets the cron software to listen for that file:

sudo crontab /var/spool/cron/root

Alternatively, you can create, edit, and set the cron file directly by running sudo crontab -e . The contents of the cron file can be confirmed by running sudo crontab -l .

Pro-tip: If your WordPress site does continually crash, you probably do need to upgrade to an instance with more RAM. Also, consider using RDS for the database.

Update

I previously used the localhost loop back address in my cron file: */5 * * * * sudo wget -q 127.0.0.1/check-db-health.php. After setting up 301 redirects to prevent traffic from hitting my public DNS, that stopped working. It is more reliable to use an explicit domain name URL: */5 * * * * sudo wget -q https://www.antpace.com/check-db-health.php