Sale conversion optimization — PHP integration with precise.sale

This article is mainly directed to programmers who want to increase the profit from sales in a store written in PHP. We show here how to integrate with the precise.sale service on the example of a simple store, we will write the simulation of users visiting the store and present the results of the optimization.

The repository with the code is available under the link:

In this article we will describe in sequence all the steps leading to the final code due to simplify writing yourself a suitably modified version. We will start with the absolute basics — the page presenting the product, we will add a page to confirm the purchase, then we connect the reporting of visits and purchases to the service precise.sale and at the end we will write a script simulating visits and purchases in our store.


1. Main page and saving visits to the file

We want the main page to display only one product, it should have a price, and each display should raise the visits count, which will later be used in the conversion optimization. The files we will need are:

  • index.php containing basic logic
  • template/index.html containing an HTML template with tags indicating where to place the data
  • src/Helpers.php storing methods to manipulate data and the template

index.php satisfy the following tasks

a) reading the price and the number of visits

b) inject them into the template

c) recording the number of visits increased by 1

<?php

require_once
'src/Helpers.php';

$state = Helpers::loadState();

echo Helpers::render('index',$state);

$state["visits"]++;

Helpers::saveState($state);

Three static methods of the Helpers class have been used here, but we will leave them at the end because important for them is content of file:

template/index.html

<html>
<head><title>Exemplary PHP shop</title></head>
<body>

<main>
<h1>Product</h1>
<p>Price <span>{{price}}</span></p>
<a href="/buy" onclick="(function (event) {alert('You bought product!'); event.preventDefault();})(event)">Buy</a>
</main>
<footer>
<p>Shop was visited {{visits}} times.</p>
</footer>

</body>
</html>

We see that this is a simple HTML but in the place of price and number of visits has double braces. This is a syntax commonly used in various template engines like twig, handlebars and vue. Using these brackets, we define the names of variables in which the Helpers::render method substitutes the values passed in the array given as the second argument.

It’s time to look at the src/Helpers.php file

<?php

class
Helpers
{
const DB = __DIR__ . '/../db.json';

static function render($file, $data) {
$text = file_get_contents(__DIR__ . '/../template/' . $file . '.html');
foreach ($data as $key => $val) {
$text = preg_replace('/{{'.$key.'}}/',$val, $text );
}
return $text;
}

static function loadState() {
if(file_exists(self::DB)) {
$json = file_get_contents(self::DB);
} else {
$default = '{"price":3,"visits":0}';
file_put_contents(self::DB, $default);
$json = $default;
}
return json_decode($json,true);
}

static function saveState($state) {
file_put_contents(self::DB, json_encode($state));
}
}

At the beginning we have a constant with the address of the file `db.json`, due to the fact that I wanted to create the simplest possible example, we will not use the database, and the whole state of the application will be kept in this file.

The first method from index.php, I mean Helpers::loadState is responsible for reading this file, because it does not exist initially, it returns the default value, that is 0 visits and the price is set to 3. The first run of script creates this file so as not to cause errors future.

The next Helpers::render method accepts the name of the template file and the data to be inserted into it. It reads the template and iterating on the keys of the table passed as the second argument changes the occurrence of this key surrounded by double braces on the value of the array.

The last Helpers::saveState method saves the application state, the price and number of visits to the `db.json` file.

At this stage, the store can:

  • display the price of one product
  • has a button to buy it
  • in the footer shows the number of views
  • subsequent impressions are counted

In this way, we wrote the basis for further development of the project, i.e. servicing the button for purchase and displaying the page confirming the purchase.


2. The buying page and preparation for integration

Now we will add a confirmation page to the project and connect the class to login visits, purchases and changing prices. The methods of this class will be added in the third step.

First of all, because two URLs are to be served, not one, the additional file appears in the project:

  • src/Controller.php

It has two static methods, buy and index. The logic associated with these actions will be passed to him. Only the routing will be found in the index.php file. So that’s how index.php looks like

<?php

require_once
'src/Controller.php';

$path = $_SERVER["REQUEST_URI"];

if($path === "/buy") {
echo Controller::buy();
} else {
echo Controller::index();
}

and src/Controller.php took over its functions from the previous step

<?php

require_once
__DIR__ . '/Api.php';
require_once __DIR__ . '/Helpers.php';

class Controller
{
static function index() {
$state = Helpers::loadState();
$state["visits"]++;
$apiClient = new Api();
if($state["visits"] % 10 === 0) { 
$state["price"] = $apiClient->reprice($state["price"]);
}
$apiClient->logVisit();

Helpers::saveState($state);

return Helpers::render('index',$state);
}

static function buy() {
$state = Helpers::loadState();

(new Api())->logBuy($state);

return Helpers::render('confirm',$state);
}
}

The only difference is the appearance of the Api class, which in every tenth visit changes the price to what the reprice returns and logs every visit using the logVisit method. In the Controller::buy method, the logic is very similar to the main page, we load the state, we log in this time purchase, not a visit, and render the view.

The file with the purchase confirmation view is a super simple template

template/confirm.html

<html>
<head><title>Exemplary PHP shop</title></head>
<body>

<main>
<h1>You bought product</h1>
<p>Congratulations</p>
<a href="/">Come back to shop</a>
</main>
<footer>
<p>Shop was visited {{visits}} times.</p>
</footer>

</body>
</html>

In template/index.html only one thing has changed. Instead of a pop-up window after clicking the purchase, we now have a redirection, in other words we have cut out the entire JavaScript, so instead

<a href="/buy" onclick="(function (event) {alert('You bought product!'); event.preventDefault();})(event)">Buy</a>

it’s just

<a href="/buy">Buy</a>

Api class from the src/Api.php file, has been left to describe. At this stage we do not implement its methods, because we will do it in the future

<?php

class
Api
{
public function reprice($currentPrice) {
return 0;
}

public function logVisit() {

}

public function logBuy($state) {

}
}

In this step

  • we have separated the code into a controller, auxiliary methods (helper) and the API class containing methods for communication with the precise.sale service
  • we added a second view with a purchase summary
  • we moved the logic of the action to the controller, and we put routing in the index

Now we are ready to connect to the precise.sale service and add the body of Api class methods.


3. Connection to precises.sale, support for environment variables

The precise sale service identifies the client and checks his authorization using a key. Entering it directly in the code is bad practice so that even the desire to maximize simplification does not justify mixing code and API key in one file. To avoid this, we will use a simple library: vlucas/phpdotenv. We install it with the command:

composer req vlucas/phpdotenv "^2.5"

At the beginning of the index.php file we attach lines

require_once 'vendor/autoload.php';

$dotenv = new Dotenv\Dotenv(__DIR__);
$dotenv->load();

We now need two files: .env that will contain our variables and a .env.dist that will be added to the repository and will act as a table of contents for variables that should contain .env and their default values. This should look like .env.dist

###> precise sale ###
PRECISE_SALE_URL=https://api.precise.sale
PRECISE_SALE_API_KEY=xxx
###< precise sale ###

.env is the same except that the value of the API key should be set according to what key will be generated in the service. In order to create such a key, we set up an account on the website

https://precises.sale

Go to the platform “platforms”, click the “integrate” button in the “custom” tab. A new platform should appear on the list at the bottom. After clicking the “pick” button next to it, we will be able to view the remaining views from the perspective of this platform. The blurred string in the third column is the API key that we will need and which we will paste as the value of the PRECISE_SALE_API_KEY variable in the .env file

Creating a platform in the precise.sale panel

The second step will be to create a product in the “products” tab

Creating a product in the precise.sale panel

We want to define his id, it is important that it is a value 1, because this value will be entered in the Api class code, the sale price is set to 3, and the base price (the one from which the margin is calculated) for 2.

After clicking on the product, you should see the “Start optimizing price” button on the right. We click it.

The product should be approved for price optimization with the green button on the right.

We change the frequency (conversion frequency set in milliseconds) to 6000, for our test applications we do not want to wait for the conversion of the whole day’s price, and 6 seconds is the minimum acceptable value. The smaller setting will not change anything. We confirm by clicking “Update algorithm parameters”.

If the .env file is ready and we inserted the correct API_KEY into it, we can go to its reading in the Api class. Thanks to the phpdotenv package, access to these variables is very easy. We will write both API_KEY and the URL to the Api class property by adding its following constructor.

private $URL;
private $KEY;

public function __construct()
{
$this->URL=getenv('PRECISE_SALE_URL');
$this->KEY=getenv('PRECISE_SALE_API_KEY');
}

Of course, we can not forget about adding a line

.env
vendor

to a .gitignore file if we use a git in our project.

In this step, we did not change too much in the code, but we set the variables that we will need in the next step when implementing the methods for visiting the visit, logging in the purchase and changing the price.


4. Implementation of methods for logging visits and purchases and changing prices

We will need API documentation for integration, it is available at:

Of the tips described there, we will need to send visits via POST to /visit. We already log in with the help of PUT /order/:id because they contain the identifiers used by the store, so it can be treated as synchronization rather than creating a new resource.

The reprice method will contain two requests. The first one checks if there are any products whose price should be changed. GET /product?to_reprice=true, the second is sent to confirm the price change and it is PUT /product/:id with the new price.

We will use the very popular Guzzle packet to send HTTP requests.

composer req guzzlehttp/guzzle "^6.3"

If this is your first meeting with this package, I recommend at least a cursory look at its documentation before continuing to read.

It’s time to present the contents of the src/Api.php file, that is the implementation of the methods

  • repirce
  • logVisit
  • logBuy
<?php

const
BASH_RED = "\e[0;31m";
const BASH_END = "\033[0m";

At the beginning, we define special tags for coloring the errors in the console. Inserting them into a string and printing in the Linux system will allow you to view red, clearly visible logs.

class Api
{
private $URL;
private $KEY;
private $client;

public function __construct()
{
$this->URL=getenv('PRECISE_SALE_URL');
$this->KEY=getenv('PRECISE_SALE_API_KEY');
$this->client = new GuzzleHttp\Client();
}

Next, the $URL and $KEY join the $client variable containing the Guzzle client instance. It will allow us to send http requests more easily than curl.

Now we will show a quite simple logVisit method that combines elements such as preparing and sending requests and handling errors.

/**
*
@throws \GuzzleHttp\Exception\GuzzleException
*/
public function logVisit() {

$data = [
"url" => (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]",
"ip" => $_SERVER["REMOTE_ADDR"],
"userAgent" => $_SERVER["HTTP_USER_AGENT"] ?? null,
"productId" => 1
];

$res = $this->client->request('POST', $this->URL.'/visit', [
'json' => $data,
'headers' => [
'Authorization' => 'Bearer '.$this->KEY
]
]);

if($res->getStatusCode() !== 201) {
file_put_contents("php://stdout", BASH_RED."Invalid visit logging. Check URL and API KEY in .env file.".BASH_END."\n");
}
}

The $ data variable stores basic information about the displayed product, such as its id, or url, but also about the IP address and the device from which the page is viewed. This data is optional, but it is worth adding it, because it allows you to conduct more complex analyzes.

Then, a POST request is sent to the /visit address containing the data, and the Authorization header with the key added in the previous step.

The server should respond with the code 201, if this does not happen, then an error message will be displayed on the console in which the server is placed.

The same construction can be seen in the shopping logistic method

/**
*
@param $state
*
@throws \GuzzleHttp\Exception\GuzzleException
*
@throws \Exception
*/
public function logBuy($state) {
$id = random_int(1, 1e10);

$data = [
"id" => $id,
"grand_total" => $state["price"],
"products" => [[
"id" => 1,
"price" => $state["price"],
"quantity" => 1
]],
];

$res = $this->client->request('PUT', $this->URL.'/order/'.$id, [
'json' => $data,
'headers' => [
'Authorization' => 'Bearer '.$this->KEY
]
]);

if($res->getStatusCode() !== 200) {
file_put_contents("php://stdout", BASH_RED."Invalid buy logging. Check URL and API KEY in .env file.".BASH_END."\n");
}
}

The difference is that we’re drawing id here because it’s the store’s duty to produce it, which is justified by the fact that the store itself usually stores orders and these identifiers already exist in it.

Apart from this difference, similarly we create a variable $ data this time filled with the details of the order and not the visit. Similarly, we send the request by changing only the method to PUT and the address to /order/:id

Now, we will consider any response code different from 200 for error.

The method to change the price is more complex, therefore we will break it into parts:

/**
*
@param $currentPrice double
*
@return double
*
@throws \GuzzleHttp\Exception\GuzzleException
*/
public function reprice($currentPrice) {
$res = $this->client->request('GET', $this->URL.'/product?to_reprice=true', [
'headers' => [
'Authorization' => 'Bearer '.$this->KEY,
'Content-Type' => 'application/json'
]
]);

$propositions = json_decode((string) $res->getBody());

First of all, this method initially sends a GET request to the address /product?to_reprice=true. $res->getBody() is a buffer, so we project it onto a character string and decode the data structure stored in the JSON format.

It is a array. It goes to the $propositions variable. In our case, it will be empty or has one element, because we only have one product. It may turn out, however, that the algorithm does not see the point of changing its price.

if(count($propositions) < 1) {
file_put_contents("php://stdout", "Reprice finished without changes."."\n");
return $currentPrice;
}

Exactly this scenario is considered in the above conditional instruction. If there is no need to change the price, the reprice method returns the current price and ends its operation. Otherwise, you have to return the new price, but let the server know in advance that this price has been changed.

else {

$price = $propositions[0]->proposed_price;
$id = 1;

$data = [
"id" => $id,
"price" => $price
];

$res = $this->client->request('PUT', $this->URL.'/product/1', [
'json' => $data,
'headers' => [
'Authorization' => 'Bearer '.$this->KEY,
]
]);

if($res->getStatusCode() !== 200) {
file_put_contents("php://stdout", BASH_RED."Invalid confirmation that price is changed.".BASH_END."\n");
return $currentPrice;
} else {
return $price;
}
}

We draw the price from the proposed proposition of the first product from the $propositions array, set id to 1 and update the product by sending the appropriate PUT request. If something goes wrong, we return the previous price, if the server has saved the changes, then the new one.

That’s how we came to the end of integration. Now we can use our store and observe how the next purchases and visits appear in the panel. Unfortunately, random clicking does not correspond to the behavior of customers who, when they spend real money on shopping, will make their decision to click on the purchase button, among other things, what price they will display.

We will simulate such behavior in the last step of this tutorial.


5. Simulation of visits and purchases

We will store the shop locally with a command

php -S localhost:8020

The traffic will be generated by running a script in the second console

simulate_visitors.php

It will be a super simple script, but it will need two functions for its operation: a random true / false value generating with a given probability and calculating the probability of buying at a given price. The key point is that this probability is not known either to the precise.sale algorithm or store owner. The fact that we accept a model is the result of the desire to simulate the market, while the task precise.sale service is the fastest, cheapest and most thorough examination of this model.

Here is the first function in the simulate_visitors.php file

<?php

/**
* Function that give true with probability from first argument
*
*
@param float $probability
*
@param int $length
*
@return bool
*/
function trueWithProbability($probability=0.1, $length=10000)
{
$test = mt_rand(1, $length);
return $test<=$probability*$length;
}

mt_rand returns the value from the given range, if it will fit in the range scaled by the expected probability, it returns true, otherwise false. Another function is conversion

/**
* Selling in different price level
*
* // in 0 -> 0.5 |---
* // between 0 and 20 linear | ---
* // in 20 -> 0 | ---
* // greater than 20 -> 0 | ---------
*
*
@param $p
*
@return double
*/
function s($p)
{
return max(0.0, 0.5 - 1/40 * $p);
}

We assume a linear model, that is, for price 0, every second visitor will order a product, for a price of 20 or more, and between these prices the probability of purchase will fall linearly.

Then set the store’s address to match the one we set in the first console

const SHOP_URL = "http://localhost:8020";

$visits = 0;
$buys = 0;

The loop simulates loading the home page. We read the price from it, if it is suitable for the visitor to become the ordering party, he clicks on the purchase button.

for($i=0; $i<20; $i++) {

$page = file_get_contents(SHOP_URL);

preg_match("/<span>(\d+\.?\d*)<\/span>/", $page, $matches);

$price = (float)$matches[1];

$visits++;
echo "Visits $visits\t Buys: $buys\t Price: ".$price."\n";

if (trueWithProbability(s($price))) {
file_get_contents(SHOP_URL . "/buy");
$buys++;
}

}

This time instead of Guzzle, we used file_get_contents, which allows you to use network resources by sending a GET request underneath instead of a file path, we’ll provide the URL address.


Presentation of conversion optimization results

After executing several executions of such a program, we should see more or less such charts in the product view:

Of course, they may be a bit different due to the random simulation characteristics, but the important thing is that on the first one we see the conversion mapping as a function of the price that falls linearly.

On the other, the profit, which, according to the previously derived patterns, should be maximum for the price equal to 11. U the parabola actually presented has a maximum very close to this value.

The size of dots corresponds to the number of measurements (visits) for a given price point.

After doing this script several times, we can expect that the value of the price will coincide with the number 11. The results can be seen here.

https://pastebin.com/raw/pJpwFNht

And their summary is presented in the table:

Prices indicated by precise.sale in subsequent iterations.

It is easy to calculate that in the scenario considered here, we observe almost a five-fold increase in profit after 260 sales. The price rose from 3 to 10.63. It is true that 42.5% of customers made a purchase earlier, and now the conversion is only 22.5%, but they leave much more money. Profit for the price calculated in the last step is only 1.7 ‰ lower than the maximum that could be achieved if the price was equal to 11.

Conversion and profit charts, depending on the price for the data in the table.

The table also shows a very low profit for the last indicated price. It should be emphasized that this is the result of statistically occurring deviations from the average sales and if we were selling at 10.63 long enough, the average profit per visit would be 4.76.

In the same way for the price of 3.15, we observed a higher conversion than it would have been from the adopted model. Such situations are normal and the task of the algorithm to take into account measurement uncertainties.

Summary

We showed how to write the integration of the shop platform written in PHP with the service precise.sale, how to create a shop platform in it, generate a key for it, create a product and simulating customers visiting the store see how the algorithm chooses better prices for him.