Table of Contents
Price tracking is one of the most practical applications of web scraping. Instead of manually checking a product page every day hoping the price dropped, a tracker does it automatically – fetches the price, compares it to yesterday’s price, and emails you the moment it changes.
This guide builds a complete PHP price tracker from scratch. By the end you’ll have a working system that monitors multiple products, stores full price history in MySQL, calculates percentage changes, sends email alerts on price drops, and runs automatically on a daily schedule with a cron job.
Every section builds on the previous one. Follow through in order and you’ll have a deployable price monitoring tool by the end.
What We’re Building
The finished PHP price tracker does five things:
- Fetches product pages – sends HTTP requests to product URLs and retrieves the HTML
- Extracts prices – parses the HTML and pulls out the current price using XPath
- Stores price history – saves every price reading to MySQL with a timestamp so you can track changes over time
- Detects price drops – compares the current price to the previous reading and calculates the percentage change
- Sends email alerts – notifies you automatically when a price drops below a threshold you define
What You Need
- PHP 7.4 or higher with cURL enabled
- MySQL database
- A server or hosting account with cron job access
- Basic PHP and SQL knowledge
All examples use books.toscrape.com – a site built for scraping practice. The price extraction code adapts to any site by changing the XPath selector to match that site’s HTML structure.
If you haven’t built a PHP scraper before, read the PHP web scraper beginner guide first – it covers the fetch and parse foundation that the price tracker builds on.
Database Setup – Creating the Price History Table
The price tracker needs two tables. One for the products you want to monitor – their URLs, names, and alert thresholds. One for price history – every price reading with a timestamp so you can see how prices change over time.
Creating the Database Tables
Run this SQL once to set up the structure:
-- Table 1: Products to monitor
CREATE TABLE tracked_products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
url VARCHAR(500) NOT NULL UNIQUE,
price_selector VARCHAR(255) NOT NULL, -- XPath to find the price
alert_threshold DECIMAL(10,2) DEFAULT NULL, -- email alert if price drops below this
active TINYINT DEFAULT 1, -- 0 = paused, 1 = active
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Table 2: Price history
CREATE TABLE price_history (
id INT AUTO_INCREMENT PRIMARY KEY,
product_id INT NOT NULL,
price DECIMAL(10,2) NOT NULL,
currency VARCHAR(10) DEFAULT 'GBP',
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES tracked_products(id) ON DELETE CASCADE,
INDEX idx_product_recorded (product_id, recorded_at)
);
The price_selector column stores the XPath query for each product separately. This lets you track products from different websites that use different HTML structures – each product has its own selector rather than hardcoding one globally.
The alert_threshold column defines the price at which you want an email alert. Set it to null to track the product without alerts, or set it to a price and you’ll get notified when the price drops to or below that amount.
The index on (product_id, recorded_at) speeds up queries that fetch the latest price for a product or pull price history over a date range – both of which the tracker runs on every check.
Connecting to the Database
<?php
function get_db_connection() {
$host = 'localhost';
$dbname = 'price_tracker';
$username = 'your_username';
$password = 'your_password';
try {
$pdo = new PDO(
"mysql:host=$host;dbname=$dbname;charset=utf8mb4",
$username,
$password,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
return $pdo;
} catch (PDOException $e) {
echo "Database connection failed: " . $e->getMessage() . PHP_EOL;
return null;
}
}
$pdo = get_db_connection();
if (!$pdo) {
exit("Cannot continue without database connection." . PHP_EOL);
}
echo "Database connected." . PHP_EOL;
?>
Output:
Database connected.
Adding Products to Track
Insert the products you want to monitor. Do this once manually – the tracker script reads from this table automatically on every run:
<?php
function add_product($pdo, $name, $url, $priceSelector, $alertThreshold = null) {
$sql = "INSERT INTO tracked_products
(name, url, price_selector, alert_threshold)
VALUES
(:name, :url, :selector, :threshold)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
price_selector = VALUES(price_selector),
alert_threshold = VALUES(alert_threshold)";
try {
$stmt = $pdo->prepare($sql);
$stmt->execute([
':name' => $name,
':url' => $url,
':selector' => $priceSelector,
':threshold' => $alertThreshold,
]);
echo "Product added: $name" . PHP_EOL;
return true;
} catch (PDOException $e) {
echo "Failed to add product: " . $e->getMessage() . PHP_EOL;
return false;
}
}
$pdo = get_db_connection();
// Add books from books.toscrape.com
// The XPath targets the price element on individual book pages
add_product(
$pdo,
'A Light in the Attic',
'https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html',
'//*[contains(@class,"price_color")]',
45.00 // alert if price drops below £45.00
);
add_product(
$pdo,
'Tipping the Velvet',
'https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html',
'//*[contains(@class,"price_color")]',
50.00 // alert if price drops below £50.00
);
add_product(
$pdo,
'Sharp Objects',
'https://books.toscrape.com/catalogue/sharp-objects_997/index.html',
'//*[contains(@class,"price_color")]',
null // track price but no alert
);
?>
Output:
Product added: A Light in the Attic
Product added: Tipping the Velvet
Product added: Sharp Objects
Verifying the Setup
<?php
$pdo = get_db_connection();
// List all tracked products
$stmt = $pdo->query("SELECT id, name, alert_threshold, active FROM tracked_products");
$products = $stmt->fetchAll();
echo "Tracked products:" . PHP_EOL;
echo str_repeat('-', 50) . PHP_EOL;
foreach ($products as $product) {
$threshold = $product['alert_threshold']
? "Alert at £" . $product['alert_threshold']
: "No alert";
$status = $product['active'] ? "Active" : "Paused";
echo "#{$product['id']} {$product['name']} — $threshold — $status" . PHP_EOL;
}
?>
Output:
Tracked products:
--------------------------------------------------
#1 A Light in the Attic — Alert at £45.00 — Active
#2 Tipping the Velvet — Alert at £50.00 — Active
#3 Sharp Objects — No alert — Active
Fetching and Extracting the Current Price
With products in the database the tracker needs two things for each one: fetch the product page and pull the price out of the HTML. The XPath selector stored in the database tells it exactly where to look.
Fetching the Product Page
<?php
function fetch_page($url) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_ENCODING => '',
CURLOPT_LOW_SPEED_LIMIT => 500,
CURLOPT_LOW_SPEED_TIME => 10,
CURLOPT_HTTPHEADER => [
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language: en-US,en;q=0.5',
'Connection: keep-alive',
],
]);
$html = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) {
echo "cURL error on $url: $error" . PHP_EOL;
return false;
}
if ($httpCode !== 200) {
echo "HTTP $httpCode on $url" . PHP_EOL;
return false;
}
return $html;
}
?>
Extracting the Price From HTML
<?php
function extract_price($html, $xpathSelector) {
if (!$html) {
return false;
}
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML($html);
libxml_clear_errors();
$xpath = new DOMXPath($dom);
$priceNode = $xpath->query($xpathSelector)->item(0);
if (!$priceNode) {
echo "Price element not found - selector may need updating." . PHP_EOL;
return false;
}
$rawPrice = trim($priceNode->textContent);
// Strip currency symbols and whitespace
// Handles £, $, €, and similar symbols
$cleanPrice = preg_replace('/[^0-9.]/', '', $rawPrice);
if (!is_numeric($cleanPrice)) {
echo "Could not parse price from: '$rawPrice'" . PHP_EOL;
return false;
}
return (float) $cleanPrice;
}
// Test it
$html = fetch_page("https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html");
$price = extract_price($html, '//*[contains(@class,"price_color")]');
if ($price) {
echo "Current price: £" . number_format($price, 2) . PHP_EOL;
}
?>
Output:
Current price: £51.77
Handling Sites With Different Price Formats
Price formats vary across sites. Some show $1,299.99, some show 1.299,99 (European format), some show £51.77. The preg_replace('/[^0-9.]/', '', $rawPrice) approach strips everything except digits and decimal points – which handles most formats correctly. For European comma-decimal formats add one more step:
<?php
function clean_price($rawPrice) {
$price = trim($rawPrice);
// Remove currency symbols and spaces
$price = preg_replace('/[£$€\s]/', '', $price);
// Handle European format: 1.299,99 → 1299.99
if (preg_match('/\d+\.\d{3},\d{2}/', $price)) {
$price = str_replace('.', '', $price); // remove thousand separator
$price = str_replace(',', '.', $price); // convert decimal separator
}
// Handle standard comma thousand separator: 1,299.99 → 1299.99
if (preg_match('/\d+,\d{3}\.\d{2}/', $price)) {
$price = str_replace(',', '', $price);
}
return is_numeric($price) ? (float) $price : false;
}
// Test different formats
$formats = ['£51.77', '$1,299.99', '1.299,99', '€ 89.00'];
foreach ($formats as $format) {
$cleaned = clean_price($format);
echo "'$format' → " . ($cleaned !== false ? $cleaned : 'Could not parse') . PHP_EOL;
}
?>
Output:
'£51.77' → 51.77
'$1,299.99' → 1299.99
'1.299,99' → 1299.99
'€ 89.00' → 89
Fetching Prices for All Tracked Products
Loop through every active product in the database and fetch its current price:
<?php
$pdo = get_db_connection();
if (!$pdo) {
exit("Database connection failed." . PHP_EOL);
}
// Get all active products
$stmt = $pdo->query("SELECT * FROM tracked_products WHERE active = 1");
$products = $stmt->fetchAll();
echo "Checking prices for " . count($products) . " products..." . PHP_EOL;
echo str_repeat('-', 50) . PHP_EOL;
foreach ($products as $product) {
echo "Fetching: {$product['name']}" . PHP_EOL;
$html = fetch_page($product['url']);
if (!$html) {
echo " Failed to fetch page - skipping." . PHP_EOL;
continue;
}
$price = extract_price($html, $product['price_selector']);
if ($price === false) {
echo " Could not extract price - skipping." . PHP_EOL;
continue;
}
echo " Current price: £" . number_format($price, 2) . PHP_EOL;
// Pause between requests to avoid triggering rate limits
sleep(2);
}
?>
Output:
Checking prices for 3 products...
--------------------------------------------------
Fetching: A Light in the Attic
Current price: £51.77
Fetching: Tipping the Velvet
Current price: £53.74
Fetching: Sharp Objects
Current price: £47.82
Finding the Right Price Selector for Any Site
Every site has a different HTML structure. To find the correct XPath for a product’s price element on any site:
- Open the product page in Chrome
- Right-click the price and select Inspect
- Look at the highlighted element – note its tag, class, or ID
- Right-click the element in DevTools → Copy → Copy XPath
- Test it in the Chrome console:
$x('your-xpath-here') - Store the working XPath in the
price_selectorcolumn for that product
Common price element patterns across different sites:
<?php
// Books.toscrape.com
$selector = '//*[contains(@class,"price_color")]';
// Generic span with price class
$selector = '//span[@class="price"]';
// Span with itemprop for structured data
$selector = '//span[@itemprop="price"]';
// Div with specific ID
$selector = '//div[@id="product-price"]';
// Meta tag with price (some sites use this)
$selector = '//meta[@itemprop="price"]/@content';
?>
Storing Price History in MySQL
Fetching the current price is useful once. Storing every price reading over time is what makes a tracker actually valuable – you can see when prices dropped, how often they change, and whether the current price is genuinely low or just fluctuating.
Saving a Price Reading
<?php
function save_price($pdo, $productId, $price, $currency = 'GBP') {
$sql = "INSERT INTO price_history (product_id, price, currency)
VALUES (:product_id, :price, :currency)";
try {
$stmt = $pdo->prepare($sql);
$stmt->execute([
':product_id' => $productId,
':price' => $price,
':currency' => $currency,
]);
return true;
} catch (PDOException $e) {
echo "Failed to save price: " . $e->getMessage() . PHP_EOL;
return false;
}
}
// Usage
$pdo = get_db_connection();
$saved = save_price($pdo, 1, 51.77);
if ($saved) {
echo "Price saved successfully." . PHP_EOL;
}
?>
Output:
Price saved successfully.
Getting the Previous Price for Comparison
<?php
function get_previous_price($pdo, $productId) {
// Get the most recent price reading before the current one
$sql = "SELECT price, recorded_at
FROM price_history
WHERE product_id = :product_id
ORDER BY recorded_at DESC
LIMIT 1 OFFSET 1";
$stmt = $pdo->prepare($sql);
$stmt->execute([':product_id' => $productId]);
return $stmt->fetch();
}
function get_latest_price($pdo, $productId) {
$sql = "SELECT price, recorded_at
FROM price_history
WHERE product_id = :product_id
ORDER BY recorded_at DESC
LIMIT 1";
$stmt = $pdo->prepare($sql);
$stmt->execute([':product_id' => $productId]);
return $stmt->fetch();
}
// Test it
$latest = get_latest_price($pdo, 1);
$previous = get_previous_price($pdo, 1);
if ($latest) {
echo "Latest price: £" . $latest['price'] . " at " . $latest['recorded_at'] . PHP_EOL;
}
if ($previous) {
echo "Previous price: £" . $previous['price'] . " at " . $previous['recorded_at'] . PHP_EOL;
}
?>
Output after two price checks:
Latest price: £51.77 at 2026-05-02 09:00:01
Previous price: £51.77 at 2026-05-01 09:00:01
Retrieving Full Price History for a Product
<?php
function get_price_history($pdo, $productId, $days = 30) {
$sql = "SELECT price, currency, recorded_at
FROM price_history
WHERE product_id = :product_id
AND recorded_at >= DATE_SUB(NOW(), INTERVAL :days DAY)
ORDER BY recorded_at ASC";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':product_id' => $productId,
':days' => $days,
]);
return $stmt->fetchAll();
}
// Display price history for product 1
$history = get_price_history($pdo, 1, 7); // last 7 days
echo "Price history (last 7 days):" . PHP_EOL;
echo str_repeat('-', 45) . PHP_EOL;
foreach ($history as $record) {
echo $record['recorded_at'] . " - £" . number_format($record['price'], 2) . PHP_EOL;
}
?>
Output after a week of daily checks:
Price history (last 7 days):
---------------------------------------------
2026-04-25 09:00:01 - £51.77
2026-04-26 09:00:01 - £51.77
2026-04-27 09:00:01 - £49.99
2026-04-28 09:00:01 - £49.99
2026-04-29 09:00:01 - £47.50
2026-04-30 09:00:01 - £47.50
2026-05-01 09:00:01 - £51.77
Getting Price Statistics
With price history in the database you can run useful queries – lowest price ever recorded, highest price, average over the past month:
<?php
function get_price_stats($pdo, $productId) {
$sql = "SELECT
MIN(price) AS lowest_ever,
MAX(price) AS highest_ever,
ROUND(AVG(price), 2) AS average_price,
COUNT(*) AS total_readings,
MIN(recorded_at) AS tracking_since
FROM price_history
WHERE product_id = :product_id";
$stmt = $pdo->prepare($sql);
$stmt->execute([':product_id' => $productId]);
return $stmt->fetch();
}
$stats = get_price_stats($pdo, 1);
if ($stats) {
echo "Price statistics for product #1:" . PHP_EOL;
echo str_repeat('-', 40) . PHP_EOL;
echo "Lowest ever: £" . number_format($stats['lowest_ever'], 2) . PHP_EOL;
echo "Highest ever: £" . number_format($stats['highest_ever'], 2) . PHP_EOL;
echo "Average price: £" . number_format($stats['average_price'],2) . PHP_EOL;
echo "Total readings: " . $stats['total_readings'] . PHP_EOL;
echo "Tracking since: " . $stats['tracking_since'] . PHP_EOL;
}
?>
Output:
Price statistics for product #1:
----------------------------------------
Lowest ever: £47.50
Highest ever: £51.77
Average price: £49.75
Total readings: 7
Tracking since: 2026-04-25 09:00:01
Avoiding Duplicate Readings
If the tracker runs multiple times in a day you’ll accumulate redundant readings where nothing changed. Optionally skip saving when the price is identical to the most recent reading:
<?php
function save_price_if_changed($pdo, $productId, $price, $currency = 'GBP') {
$latest = get_latest_price($pdo, $productId);
// Always save if no previous reading exists
if (!$latest) {
return save_price($pdo, $productId, $price, $currency);
}
// Skip if price hasn't changed
if ((float)$latest['price'] === (float)$price) {
echo " Price unchanged at £" . number_format($price, 2) . " - skipping save." . PHP_EOL;
return false;
}
// Price changed - save the new reading
return save_price($pdo, $productId, $price, $currency);
}
// Usage
$result = save_price_if_changed($pdo, 1, 51.77);
$result = save_price_if_changed($pdo, 1, 47.50);
?>
Output:
Price unchanged at £51.77 - skipping save.
The second call with a different price saves without output – meaning it recorded successfully. Use save_price_if_changed() when running the tracker multiple times daily. Use the plain save_price() when running once daily and you want a complete record of every check regardless of change.
Detecting Price Drops and Calculating Changes
Storing prices is only useful if you do something with the comparison. This section calculates exactly how much a price changed, what direction it moved, and whether it crossed the alert threshold you set for each product.
Calculating Price Change
<?php
function calculate_price_change($currentPrice, $previousPrice) {
if (!$previousPrice || $previousPrice == 0) {
return [
'difference' => 0,
'percentage' => 0,
'direction' => 'unknown',
'has_changed' => false,
];
}
$difference = $currentPrice - $previousPrice;
$percentage = round(($difference / $previousPrice) * 100, 2);
if ($difference < 0) {
$direction = 'dropped';
} elseif ($difference > 0) {
$direction = 'increased';
} else {
$direction = 'unchanged';
}
return [
'difference' => round(abs($difference), 2),
'percentage' => abs($percentage),
'direction' => $direction,
'has_changed' => $difference !== 0,
'raw_change' => round($difference, 2),
];
}
// Test it
$change = calculate_price_change(47.50, 51.77);
echo "Price direction: " . $change['direction'] . PHP_EOL;
echo "Change amount: £" . $change['difference'] . PHP_EOL;
echo "Change percent: " . $change['percentage'] . "%" . PHP_EOL;
echo "Has changed: " . ($change['has_changed'] ? 'Yes' : 'No') . PHP_EOL;
?>
Output on price drop:
Price direction: dropped
Change amount: £4.27
Change percent: 8.25%
Has changed: Yes
Output when price increased:
Price direction: increased
Change amount: £4.27
Change percent: 8.25%
Has changed: Yes
Output when unchanged:
Price direction: unchanged
Change amount: £0
Change percent: 0%
Has changed: No
Checking Against the Alert Threshold
Each product has an optional alert_threshold in the database. When the current price drops at or below that threshold, the tracker should flag it for an alert:
<?php
function should_send_alert($currentPrice, $alertThreshold, $previousPrice = null) {
// No threshold set - never alert
if ($alertThreshold === null) {
return false;
}
// Current price is above threshold - no alert
if ($currentPrice > $alertThreshold) {
return false;
}
// If we have a previous price, only alert if this is a new drop
// Avoids sending the same alert every day while price stays low
if ($previousPrice !== null && $previousPrice <= $alertThreshold) {
return false; // was already below threshold last check
}
return true; // price just crossed below threshold
}
// Test cases
$cases = [
['current' => 47.50, 'threshold' => 50.00, 'previous' => 51.77, 'expected' => 'ALERT'],
['current' => 47.50, 'threshold' => 50.00, 'previous' => 48.00, 'expected' => 'No alert (was already below)'],
['current' => 53.00, 'threshold' => 50.00, 'previous' => 51.77, 'expected' => 'No alert (above threshold)'],
['current' => 47.50, 'threshold' => null, 'previous' => 51.77, 'expected' => 'No alert (no threshold set)'],
];
foreach ($cases as $case) {
$alert = should_send_alert($case['current'], $case['threshold'], $case['previous']);
$result = $alert ? 'ALERT' : 'No alert';
echo "£{$case['current']} vs threshold £{$case['threshold']} - $result" . PHP_EOL;
}
?>
Output:
£47.50 vs threshold £50.00 - ALERT
£47.50 vs threshold £50.00 - No alert
£53.00 vs threshold £50.00 - No alert
£47.50 vs threshold - No alert
The second case is important – if the price was already below the threshold in the previous check, don’t alert again. Without this check you’d get the same alert email every single day the price stays low.
Generating a Full Price Report for All Products
<?php
function generate_price_report($pdo) {
$stmt = $pdo->query("SELECT * FROM tracked_products WHERE active = 1");
$products = $stmt->fetchAll();
$report = [];
foreach ($products as $product) {
// Get latest two readings
$sql = "SELECT price, recorded_at
FROM price_history
WHERE product_id = :id
ORDER BY recorded_at DESC
LIMIT 2";
$stmt = $pdo->prepare($sql);
$stmt->execute([':id' => $product['id']]);
$readings = $stmt->fetchAll();
if (empty($readings)) {
continue; // no data yet
}
$currentPrice = (float) $readings[0]['price'];
$previousPrice = isset($readings[1]) ? (float) $readings[1]['price'] : null;
$change = calculate_price_change($currentPrice, $previousPrice);
$needsAlert = should_send_alert(
$currentPrice,
$product['alert_threshold'],
$previousPrice
);
$report[] = [
'id' => $product['id'],
'name' => $product['name'],
'url' => $product['url'],
'current_price' => $currentPrice,
'previous_price'=> $previousPrice,
'change' => $change,
'threshold' => $product['alert_threshold'],
'needs_alert' => $needsAlert,
];
}
return $report;
}
// Display the report
$report = generate_price_report($pdo);
echo "Price Report - " . date('Y-m-d H:i:s') . PHP_EOL;
echo str_repeat('=', 55) . PHP_EOL;
foreach ($report as $item) {
$previousStr = $item['previous_price']
? "£" . number_format($item['previous_price'], 2)
: "No previous data";
$changeStr = $item['change']['has_changed']
? "{$item['change']['direction']} by £{$item['change']['difference']} ({$item['change']['percentage']}%)"
: "unchanged";
echo PHP_EOL;
echo "Product: {$item['name']}" . PHP_EOL;
echo "Current: £" . number_format($item['current_price'], 2) . PHP_EOL;
echo "Previous: $previousStr" . PHP_EOL;
echo "Change: $changeStr" . PHP_EOL;
if ($item['threshold']) {
echo "Threshold: £" . number_format($item['threshold'], 2) . PHP_EOL;
}
if ($item['needs_alert']) {
echo "*** ALERT: Price dropped below threshold! ***" . PHP_EOL;
}
echo str_repeat('-', 55) . PHP_EOL;
}
?>
Output:
Price Report - 2026-05-02 09:00:01
=======================================================
Product: A Light in the Attic
Current: £47.50
Previous: £51.77
Change: dropped by £4.27 (8.25%)
Threshold: £45.00
-------------------------------------------------------
Product: Tipping the Velvet
Current: £49.99
Previous: £53.74
Change: dropped by £3.75 (6.98%)
Threshold: £50.00
*** ALERT: Price dropped below threshold! ***
-------------------------------------------------------
Product: Sharp Objects
Current: £47.82
Previous: £47.82
Change: unchanged
-------------------------------------------------------
Tipping the Velvet dropped below £50.00 – the threshold set for that product – so it flags for an alert. A Light in the Attic dropped but is still above its £45.00 threshold so no alert fires. Sharp Objects has no threshold set so it just tracks silently.
Sending Email Alerts on Price Drops
The price report tells you what changed. Email alerts tell you without having to look. When a product crosses its threshold the tracker sends a formatted email automatically – product name, current price, previous price, how much it dropped, and a direct link to the product page.
Basic Email Alert
<?php
function send_price_alert($productName, $currentPrice, $previousPrice, $change, $productUrl, $alertEmail) {
$subject = "Price Drop Alert: $productName dropped to £" . number_format($currentPrice, 2);
$message = "Price Drop Detected
" . str_repeat('=', 40) . "
Product: $productName
Current Price: £" . number_format($currentPrice, 2) . "
Previous Price: £" . number_format($previousPrice, 2) . "
Drop Amount: £{$change['difference']} ({$change['percentage']}% off)
Threshold Met: Yes
View Product:
$productUrl
" . str_repeat('-', 40) . "
Tracked by PHP Price Tracker
" . date('Y-m-d H:i:s');
$headers = implode("\r\n", [
'From: Price Tracker <tracker@yoursite.com>',
'Reply-To: tracker@yoursite.com',
'Content-Type: text/plain; charset=UTF-8',
'X-Mailer: PHP/' . phpversion(),
]);
$sent = mail($alertEmail, $subject, $message, $headers);
if ($sent) {
echo " Alert email sent to $alertEmail" . PHP_EOL;
} else {
echo " Failed to send alert email." . PHP_EOL;
}
return $sent;
}
// Test it
$change = calculate_price_change(49.99, 53.74);
send_price_alert(
'Tipping the Velvet',
49.99,
53.74,
$change,
'https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html',
'your@email.com'
);
?>
Output:
Alert email sent to your@email.com
Email received:
Subject: Price Drop Alert: Tipping the Velvet dropped to £49.99
Price Drop Detected
========================================
Product: Tipping the Velvet
Current Price: £49.99
Previous Price: £53.74
Drop Amount: £3.75 (6.98% off)
Threshold Met: Yes
View Product:
https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html
----------------------------------------
Tracked by PHP Price Tracker
2026-05-02 09:00:01
HTML Email Alert
Plain text emails work but an HTML version is easier to read and looks more professional:
<?php
function send_html_price_alert($productName, $currentPrice, $previousPrice, $change, $productUrl, $alertEmail) {
$subject = "Price Drop: $productName - now £" . number_format($currentPrice, 2);
$dropAmount = "£{$change['difference']} ({$change['percentage']}% off)";
$date = date('Y-m-d H:i:s');
$htmlMessage = "<!DOCTYPE html>
<html>
<body style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;'>
<h2 style='color: #e74c3c;'>Price Drop Alert</h2>
<table style='width:100%; border-collapse: collapse;'>
<tr style='background:#f8f9fa;'>
<td style='padding:10px; border:1px solid #dee2e6;'><strong>Product</strong></td>
<td style='padding:10px; border:1px solid #dee2e6;'>$productName</td>
</tr>
<tr>
<td style='padding:10px; border:1px solid #dee2e6;'><strong>Current Price</strong></td>
<td style='padding:10px; border:1px solid #dee2e6; color:#27ae60; font-size:20px;'>
<strong>£" . number_format($currentPrice, 2) . "</strong>
</td>
</tr>
<tr style='background:#f8f9fa;'>
<td style='padding:10px; border:1px solid #dee2e6;'><strong>Previous Price</strong></td>
<td style='padding:10px; border:1px solid #dee2e6;'>
<s>£" . number_format($previousPrice, 2) . "</s>
</td>
</tr>
<tr>
<td style='padding:10px; border:1px solid #dee2e6;'><strong>You Save</strong></td>
<td style='padding:10px; border:1px solid #dee2e6; color:#e74c3c;'>
<strong>$dropAmount</strong>
</td>
</tr>
</table>
<p style='margin-top:20px;'>
<a href='$productUrl'
style='background:#3498db; color:white; padding:12px 24px;
text-decoration:none; border-radius:4px; display:inline-block;'>
View Product
</a>
</p>
<p style='color:#999; font-size:12px; margin-top:30px;'>
Alert sent: $date - PHP Price Tracker
</p>
</body>
</html>";
$headers = implode("\r\n", [
'From: Price Tracker <tracker@yoursite.com>',
'Reply-To: tracker@yoursite.com',
'MIME-Version: 1.0',
'Content-Type: text/html; charset=UTF-8',
]);
$sent = mail($alertEmail, $subject, $htmlMessage, $headers);
echo $sent
? " HTML alert sent to $alertEmail" . PHP_EOL
: " Failed to send HTML alert." . PHP_EOL;
return $sent;
}
?>
Logging Sent Alerts to Avoid Duplicates
Without tracking which alerts were sent, the tracker emails you every single day the price stays below threshold. Add an alerts log table:
CREATE TABLE alert_log (
id INT AUTO_INCREMENT PRIMARY KEY,
product_id INT NOT NULL,
price DECIMAL(10,2) NOT NULL,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES tracked_products(id) ON DELETE CASCADE
);
<?php
function alert_already_sent_today($pdo, $productId) {
$sql = "SELECT COUNT(*)
FROM alert_log
WHERE product_id = :product_id
AND DATE(sent_at) = CURDATE()";
$stmt = $pdo->prepare($sql);
$stmt->execute([':product_id' => $productId]);
return (int) $stmt->fetchColumn() > 0;
}
function log_alert($pdo, $productId, $price) {
$sql = "INSERT INTO alert_log (product_id, price) VALUES (:product_id, :price)";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':product_id' => $productId,
':price' => $price,
]);
}
// Usage inside the main tracker loop
if ($item['needs_alert']) {
if (alert_already_sent_today($pdo, $item['id'])) {
echo " Alert already sent today - skipping." . PHP_EOL;
} else {
$sent = send_html_price_alert(
$item['name'],
$item['current_price'],
$item['previous_price'],
$item['change'],
$item['url'],
'your@email.com'
);
if ($sent) {
log_alert($pdo, $item['id'], $item['current_price']);
}
}
}
?>
Using SMTP Instead of PHP mail()
PHP’s built-in mail() function depends on your server’s mail configuration and often ends up in spam folders. For reliable email delivery use PHPMailer with an SMTP service like Gmail, SendGrid, or Mailgun:
# Install PHPMailer via Composer
composer require phpmailer/phpmailer
<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
require __DIR__ . '/vendor/autoload.php';
function send_smtp_alert($productName, $currentPrice, $previousPrice, $change, $productUrl, $alertEmail) {
$mail = new PHPMailer(true);
try {
// SMTP configuration
$mail->isSMTP();
$mail->Host = 'smtp.gmail.com';
$mail->SMTPAuth = true;
$mail->Username = 'your@gmail.com';
$mail->Password = 'your-app-password'; // use Gmail App Password
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
// Recipients
$mail->setFrom('your@gmail.com', 'Price Tracker');
$mail->addAddress($alertEmail);
// Content
$mail->isHTML(true);
$mail->Subject = "Price Drop: $productName - now £" . number_format($currentPrice, 2);
$mail->Body = "<h2>Price dropped!</h2>
<p><strong>$productName</strong> is now
<strong>£" . number_format($currentPrice, 2) . "</strong>
(was £" . number_format($previousPrice, 2) . ").</p>
<p>You save: £{$change['difference']} ({$change['percentage']}%)</p>
<p><a href='$productUrl'>View Product</a></p>";
$mail->send();
echo " SMTP alert sent to $alertEmail" . PHP_EOL;
return true;
} catch (Exception $e) {
echo " SMTP error: {$mail->ErrorInfo}" . PHP_EOL;
return false;
}
}
?>
PHPMailer with Gmail SMTP delivers to inbox reliably. Use a Gmail App Password (not your account password) – generate one at myaccount.google.com → Security → App Passwords.
Tracking Multiple Products and Full Automation
Everything built so far – fetching, extracting, storing, comparing, and alerting – now gets combined into one complete script that runs through every tracked product automatically. Add a cron job at the end and the tracker runs itself daily without any manual intervention.
The Complete Price Tracker Script
Save this as price_tracker.php:
<?php
// ============================================
// PHP Price Tracker - Complete Script
// ============================================
set_time_limit(0);
error_reporting(E_ALL);
ini_set('log_errors', 1);
ini_set('error_log', __DIR__ . '/tracker_errors.log');
$logFile = __DIR__ . '/tracker.log';
$alertEmail = 'your@email.com';
$startTime = microtime(true);
// ---- Logging ----
function log_message($message) {
global $logFile;
$entry = '[' . date('Y-m-d H:i:s') . '] ' . $message . PHP_EOL;
file_put_contents($logFile, $entry, FILE_APPEND);
echo $entry;
}
// ---- Database ----
function get_db_connection() {
try {
$pdo = new PDO(
"mysql:host=localhost;dbname=price_tracker;charset=utf8mb4",
'your_username',
'your_password',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
return $pdo;
} catch (PDOException $e) {
log_message("DB connection failed: " . $e->getMessage());
return null;
}
}
// ---- Fetch ----
function fetch_page($url) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_ENCODING => '',
CURLOPT_LOW_SPEED_LIMIT => 500,
CURLOPT_LOW_SPEED_TIME => 10,
CURLOPT_HTTPHEADER => [
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language: en-US,en;q=0.5',
],
]);
$html = curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno || $httpCode !== 200) {
log_message("Fetch failed: $url (HTTP $httpCode)");
return false;
}
return $html;
}
// ---- Extract Price ----
function extract_price($html, $selector) {
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML($html);
libxml_clear_errors();
$xpath = new DOMXPath($dom);
$priceNode = $xpath->query($selector)->item(0);
if (!$priceNode) return false;
$rawPrice = trim($priceNode->textContent);
$cleanPrice = preg_replace('/[^0-9.]/', '', $rawPrice);
return is_numeric($cleanPrice) ? (float) $cleanPrice : false;
}
// ---- Price History ----
function get_latest_price($pdo, $productId) {
$stmt = $pdo->prepare(
"SELECT price FROM price_history
WHERE product_id = :id
ORDER BY recorded_at DESC LIMIT 1"
);
$stmt->execute([':id' => $productId]);
return $stmt->fetch();
}
function save_price($pdo, $productId, $price) {
$stmt = $pdo->prepare(
"INSERT INTO price_history (product_id, price) VALUES (:id, :price)"
);
$stmt->execute([':id' => $productId, ':price' => $price]);
}
// ---- Price Change ----
function calculate_change($current, $previous) {
if (!$previous || $previous == 0) return null;
$diff = $current - $previous;
$percentage = round((abs($diff) / $previous) * 100, 2);
return [
'direction' => $diff < 0 ? 'dropped' : ($diff > 0 ? 'increased' : 'unchanged'),
'difference' => round(abs($diff), 2),
'percentage' => $percentage,
'changed' => $diff !== 0,
];
}
// ---- Alert Check ----
function alert_sent_today($pdo, $productId) {
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM alert_log
WHERE product_id = :id AND DATE(sent_at) = CURDATE()"
);
$stmt->execute([':id' => $productId]);
return (int) $stmt->fetchColumn() > 0;
}
function log_alert($pdo, $productId, $price) {
$stmt = $pdo->prepare(
"INSERT INTO alert_log (product_id, price) VALUES (:id, :price)"
);
$stmt->execute([':id' => $productId, ':price' => $price]);
}
// ---- Send Alert ----
function send_alert($productName, $currentPrice, $previousPrice, $change, $url, $email) {
$subject = "Price Drop: $productName - now £" . number_format($currentPrice, 2);
$body = "Price Drop Detected
" . str_repeat('=', 40) . "
Product: $productName
Current Price: £" . number_format($currentPrice, 2) . "
Previous Price: £" . number_format($previousPrice, 2) . "
Drop Amount: £{$change['difference']} ({$change['percentage']}% off)
View Product: $url
Tracked by PHP Price Tracker - " . date('Y-m-d H:i:s');
$headers = "From: tracker@yoursite.com\r\nContent-Type: text/plain; charset=UTF-8";
return mail($email, $subject, $body, $headers);
}
// ============================================
// MAIN TRACKER LOOP
// ============================================
log_message("Price tracker started.");
$pdo = get_db_connection();
if (!$pdo) {
exit("Cannot run without database." . PHP_EOL);
}
// Load all active products
$stmt = $pdo->query("SELECT * FROM tracked_products WHERE active = 1");
$products = $stmt->fetchAll();
log_message("Checking " . count($products) . " products.");
$stats = [
'checked' => 0,
'saved' => 0,
'dropped' => 0,
'increased' => 0,
'unchanged' => 0,
'failed' => 0,
'alerts' => 0,
];
foreach ($products as $product) {
log_message("Checking: {$product['name']}");
$stats['checked']++;
// Fetch page
$html = fetch_page($product['url']);
if (!$html) {
log_message(" Failed to fetch - skipping.");
$stats['failed']++;
continue;
}
// Extract price
$currentPrice = extract_price($html, $product['price_selector']);
if ($currentPrice === false) {
log_message(" Could not extract price - skipping.");
$stats['failed']++;
continue;
}
log_message(" Current price: £" . number_format($currentPrice, 2));
// Get previous price
$latestRecord = get_latest_price($pdo, $product['id']);
$previousPrice = $latestRecord ? (float) $latestRecord['price'] : null;
// Save to history
save_price($pdo, $product['id'], $currentPrice);
$stats['saved']++;
// Calculate change
$change = calculate_change($currentPrice, $previousPrice);
if ($change) {
log_message(" Price {$change['direction']} by £{$change['difference']} ({$change['percentage']}%)");
$stats[$change['direction']]++;
} else {
log_message(" First reading - no comparison available.");
}
// Check alert threshold
$threshold = $product['alert_threshold'];
if ($threshold && $currentPrice <= $threshold) {
if ($previousPrice && $previousPrice > $threshold) {
// Price just crossed below threshold
if (!alert_sent_today($pdo, $product['id'])) {
$sent = send_alert(
$product['name'],
$currentPrice,
$previousPrice,
$change,
$product['url'],
$alertEmail
);
if ($sent) {
log_alert($pdo, $product['id'], $currentPrice);
log_message(" Alert sent!");
$stats['alerts']++;
}
} else {
log_message(" Alert already sent today - skipping.");
}
}
}
// Free memory
unset($html);
// Polite delay between products
sleep(2);
}
// ---- Summary ----
$duration = round(microtime(true) - $startTime, 2);
$summary = "
============================================
Tracker Complete: " . date('Y-m-d H:i:s') . "
Duration: {$duration}s
Checked: {$stats['checked']}
Saved: {$stats['saved']}
Dropped: {$stats['dropped']}
Increased: {$stats['increased']}
Unchanged: {$stats['unchanged']}
Failed: {$stats['failed']}
Alerts sent: {$stats['alerts']}
============================================";
log_message($summary);
?>
Output in tracker.log:
[2026-05-02 09:00:01] Price tracker started.
[2026-05-02 09:00:01] Checking 3 products.
[2026-05-02 09:00:01] Checking: A Light in the Attic
[2026-05-02 09:00:02] Current price: £47.50
[2026-05-02 09:00:02] Price dropped by £4.27 (8.25%)
[2026-05-02 09:00:04] Checking: Tipping the Velvet
[2026-05-02 09:00:05] Current price: £49.99
[2026-05-02 09:00:05] Price dropped by £3.75 (6.98%)
[2026-05-02 09:00:05] Alert sent!
[2026-05-02 09:00:07] Checking: Sharp Objects
[2026-05-02 09:00:08] Current price: £47.82
[2026-05-02 09:00:08] Price unchanged by £0 (0%)
============================================
Tracker Complete: 2026-05-02 09:00:08
Duration: 7.42s
Checked: 3
Saved: 3
Dropped: 2
Increased: 0
Unchanged: 1
Failed: 0
Alerts sent: 1
============================================
Automating With Cron
Run the tracker daily at 9am by adding this to your crontab with crontab -e:
0 9 * * * /usr/bin/php /var/www/html/price_tracker.php >> /var/www/html/tracker_cron.log 2>&1
Verify the PHP binary path first:
which php
Test the full command manually before scheduling:
/usr/bin/php /var/www/html/price_tracker.php
Once confirmed working, the tracker runs every morning at 9am, checks every product, saves the price, and emails you automatically when anything drops below your threshold. No manual checking required.
Adding New Products to Track
Add products to the database at any time without touching the tracker script:
<?php
$pdo = get_db_connection();
add_product(
$pdo,
'New Product Name',
'https://example.com/product-page',
'//span[@class="price"]', // XPath for this site's price element
29.99 // alert threshold - null for no alert
);
echo "Product added. It will be checked on the next tracker run." . PHP_EOL;
?>
Pausing and Removing Products
<?php
// Pause a product without deleting its history
$pdo->prepare("UPDATE tracked_products SET active = 0 WHERE id = :id")
->execute([':id' => 2]);
echo "Product paused." . PHP_EOL;
// Reactivate it
$pdo->prepare("UPDATE tracked_products SET active = 1 WHERE id = :id")
->execute([':id' => 2]);
echo "Product reactivated." . PHP_EOL;
// Delete a product and all its history
// CASCADE on the foreign key handles price_history deletion automatically
$pdo->prepare("DELETE FROM tracked_products WHERE id = :id")
->execute([':id' => 3]);
echo "Product and all price history deleted." . PHP_EOL;
?>
Frequently Asked Questions
Can I track Amazon prices with a PHP price tracker?
Technically yes, but Amazon actively blocks scrapers and changes its HTML structure frequently. Any XPath selector you write will break within days or weeks. Amazon also explicitly prohibits scraping in its terms of service. The practical alternative is the Amazon Product Advertising API – it provides official access to pricing data, stays stable, and keeps you within Amazon’s terms. For other retailers without APIs, the PHP price tracker in this guide works well as long as you add proper delays and rotate user agents.
How often should I run the price tracker?
Once daily is the right starting point for most use cases. Prices on most retail sites don’t change more than once a day, and running too frequently risks getting your IP blocked. If you need more frequent checks on a specific site – for flash sales or auction prices – increase the frequency gradually and monitor for blocking. Every 4-6 hours is the maximum for most sites without needing proxies.
What do I do when a price selector stops working?
Sites update their HTML structure occasionally. When extract_price() returns false for a product that was working before, open the product URL in Chrome, inspect the price element, and update the XPath in the tracked_products table:
<?php
$pdo->prepare(
"UPDATE tracked_products
SET price_selector = :selector
WHERE id = :id"
)->execute([
':selector' => '//new-xpath-here',
':id' => 1,
]);
echo "Selector updated." . PHP_EOL;
?>
No changes to the tracker script needed – it reads the selector from the database on every run.
How do I track prices on sites that require login?
Add cookie handling to the fetch function. Fetch the login page first, extract the CSRF token, POST your credentials, then use the session cookie on subsequent requests. The PHP cURL web scraping guide covers session handling with cookies in detail – the same approach applies here. Store the cookie file path in your tracker configuration and re-authenticate when the session expires.
Can I track multiple prices on the same page?
Yes. Add each price as a separate product in tracked_products with its own URL and selector. If the same page has two products you want to track – a regular price and a sale price for example – add two rows pointing to the same URL with different selectors. The tracker fetches the page once per product entry regardless.
Why are my alert emails going to spam?
PHP’s built-in mail() function sends email through your server’s local mail system without proper authentication – most email providers treat this as spam. Switch to PHPMailer with an SMTP service. Gmail, SendGrid, and Mailgun all work well – SendGrid has a free tier that handles 100 emails per day which is more than enough for a personal price tracker. Configure SPF and DKIM records on your domain for the best deliverability.
How do I view price history without querying the database directly?
Add a simple reporting script that reads from the database and outputs a formatted table:
<?php
$pdo = get_db_connection();
$productId = 1; // change this to any product ID
$days = 30;
$stmt = $pdo->prepare(
"SELECT
ph.price,
ph.recorded_at,
tp.name
FROM price_history ph
JOIN tracked_products tp ON tp.id = ph.product_id
WHERE ph.product_id = :id
AND ph.recorded_at >= DATE_SUB(NOW(), INTERVAL :days DAY)
ORDER BY ph.recorded_at DESC"
);
$stmt->execute([':id' => $productId, ':days' => $days]);
$history = $stmt->fetchAll();
if (empty($history)) {
echo "No price history found." . PHP_EOL;
exit;
}
echo "Price history for: " . $history[0]['name'] . PHP_EOL;
echo str_repeat('-', 45) . PHP_EOL;
$previousPrice = null;
foreach ($history as $record) {
$price = (float) $record['price'];
$change = '';
if ($previousPrice !== null) {
$diff = $price - $previousPrice;
if ($diff < 0) {
$change = " ↓ £" . number_format(abs($diff), 2);
} elseif ($diff > 0) {
$change = " ↑ £" . number_format($diff, 2);
}
}
echo $record['recorded_at'] . " £" . number_format($price, 2) . $change . PHP_EOL;
$previousPrice = $price;
}
?>
Output:
Price history for: A Light in the Attic
---------------------------------------------
2026-05-02 09:00:02 £47.50 ↓ £4.27
2026-05-01 09:00:01 £51.77
2026-04-30 09:00:01 £51.77
2026-04-29 09:00:01 £49.99 ↓ £1.78
2026-04-28 09:00:01 £51.77
Summary
You now have a complete PHP price tracker that runs automatically, stores full price history, and sends email alerts when prices drop. The system handles multiple products from different sites, avoids duplicate alerts, and logs everything so you always know what happened on each run.
The key components working together:
- MySQL database – tracked_products stores what to monitor, price_history stores every reading, alert_log prevents duplicate emails
- cURL fetch – proper headers prevent blocks, timeouts prevent hangs, delays prevent rate limiting
- DOMDocument + XPath – per-product selectors stored in the database mean each site’s price element is handled independently
- Price change detection – threshold checking with previous-price comparison prevents repeat alerts on sustained low prices
- Cron automation – daily runs without manual intervention, output redirected to log file for visibility
Extending this further: add a simple HTML dashboard that reads from the database and displays current prices and history charts for all tracked products. Add multiple alert recipients per product. Add a Telegram or Slack notification channel alongside email. The database structure and core tracker logic stay the same – everything else is an output layer on top.
For the scraping foundation this tracker builds on, the PHP cURL web scraping complete guide covers every request option, HTML parsing pattern, and error handling approach used here in full detail. For setting up the cron job on Linux or cPanel, the PHP cron job guide covers scheduling, debugging, and automation from start to finish.
Call to Action
Try building your own PHP price tracker today! Stay connected for more automation tutorials.
Also read our PHP web scraper guide to learn the basics.
Learn more about price tracking concepts from this guide.
Recommended Tools
- Reliable Hosting for PHP projects
- Proxy services for large scraping tasks
Note: This tutorial is for educational purposes. Always respect website terms before scraping.
