How to Build a Website Monitoring Script in PHP
Build a PHP website monitor with bounded requests, status and content checks, logging, retries, and cron scheduling.
A PHP website monitor makes a bounded HTTP request, applies an explicit health policy, records the result, and runs again from a scheduler such as cron. The example below checks HTTP status, response time, and optional expected text. It does not prove that JavaScript interactions, forms, or every backend dependency work.
What the monitor will do
- Read targets from a PHP configuration array.
- Use cURL with a finite timeout and deliberate redirect handling.
- Accept only the status codes your policy defines as healthy.
- Optionally search the returned HTML for expected text or a regular expression.
- Write one JSON record per check for later investigation.
- Exit non-zero when one or more checks fail, which helps cron and alerting tools detect a bad run.
A successful HTTP status can still hide a blank page, an error document, missing content, or a broken client-side application. Treat this script as an HTTP and content check, not a complete browser test.
Prerequisites
- PHP CLI with the cURL extension enabled in the same runtime used by cron.
- Permission to write the log directory.
- Network access from the monitoring host to each target.
Check the runtime before deploying:
php -v
php -m | grep -i curl
php -r 'var_export(extension_loaded("curl")); echo PHP_EOL;'
Complete PHP monitoring script
Save this as monitor.php. The configuration is intentionally explicit so you can review the health policy for every URL.
<?php
declare(strict_types=1);
$config = [
'connect_timeout' => 5,
'timeout' => 20,
'user_agent' => 'ExamplePhpMonitor/1.0',
'log_file' => __DIR__ . '/var/monitor.jsonl',
'targets' => [
[
'name' => 'Homepage',
'url' => 'https://example.com/',
'allowed_statuses' => [200],
'expected_text' => 'Example Domain',
],
[
'name' => 'Health endpoint',
'url' => 'https://example.com/health',
'allowed_statuses' => [200, 204],
],
],
];
function checkUrl(array $target, array $config): array
{
$started = microtime(true);
$ch = curl_init($target['url']);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_CONNECTTIMEOUT => $config['connect_timeout'],
CURLOPT_TIMEOUT => $config['timeout'],
CURLOPT_USERAGENT => $config['user_agent'],
CURLOPT_HEADER => false,
CURLOPT_ENCODING => '',
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$body = curl_exec($ch);
$curlError = curl_error($ch);
$curlErrno = curl_errno($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$effectiveUrl = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
$durationMs = (int) round((microtime(true) - $started) * 1000);
curl_close($ch);
$result = [
'time' => gmdate('c'),
'name' => $target['name'],
'url' => $target['url'],
'effective_url' => $effectiveUrl,
'status' => $status,
'duration_ms' => $durationMs,
'ok' => false,
'failure' => null,
];
if ($body === false) {
$result['failure'] = [
'type' => 'transport',
'errno' => $curlErrno,
'message' => $curlError,
];
return $result;
}
$allowed = $target['allowed_statuses'] ?? [200];
if (!in_array($status, $allowed, true)) {
$result['failure'] = [
'type' => 'status',
'allowed' => $allowed,
];
return $result;
}
if (isset($target['expected_text']) && !str_contains($body, $target['expected_text'])) {
$result['failure'] = [
'type' => 'content',
'expected' => $target['expected_text'],
];
return $result;
}
if (isset($target['expected_regex'])) {
$matched = @preg_match($target['expected_regex'], $body);
if ($matched !== 1) {
$result['failure'] = [
'type' => 'content_regex',
'pattern' => $target['expected_regex'],
];
return $result;
}
}
$result['ok'] = true;
return $result;
}
$logDir = dirname($config['log_file']);
if (!is_dir($logDir) && !mkdir($logDir, 0750, true) && !is_dir($logDir)) {
fwrite(STDERR, "Cannot create log directory: {$logDir}\n");
exit(2);
}
$runFailed = false;
foreach ($config['targets'] as $target) {
$result = checkUrl($target, $config);
$line = json_encode($result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
file_put_contents($config['log_file'], $line . PHP_EOL, FILE_APPEND | LOCK_EX);
echo ($result['ok'] ? 'OK ' : 'FAIL ') . $target['name'] . ' ' . $result['status'] . ' ' . $result['duration_ms'] . "ms\n";
if (!$result['ok']) {
$runFailed = true;
}
}
exit($runFailed ? 1 : 0);
Run it manually:
php monitor.php
tail -n 5 var/monitor.jsonl
Configure status and content rules
Status policy
Define success per endpoint. A homepage check might allow only 200. A health endpoint may intentionally return 204. Do not automatically treat every 2xx and 3xx response as equivalent.
'allowed_statuses' => [200, 204],
When redirects are followed, inspect the final response status and effective URL. The initial redirect status is not necessarily the status of the body you received.
Expected text and regular expressions
Use expected_text for a simple assertion, or expected_regex for a pattern:
'expected_regex' => '/<title>[^<]+<\/title>/i',
Keep patterns stable. Do not match timestamps, random IDs, or localized text unless that variability is intentional. Fetched HTML checks do not execute JavaScript, so they cannot verify a client-rendered interface.
Important cURL options
| Option | Purpose |
|---|---|
CURLOPT_CONNECTTIMEOUT |
Maximum seconds to establish a connection. |
CURLOPT_TIMEOUT |
Maximum time for the complete request. |
CURLOPT_FOLLOWLOCATION |
Follow redirects. |
CURLOPT_MAXREDIRS |
Bound redirect chains and loops. |
CURLOPT_USERAGENT |
Identify the monitor to the origin. |
CURLOPT_ENCODING |
Allow compressed responses and let cURL decode them. |
CURLOPT_SSL_VERIFYPEER, CURLOPT_SSL_VERIFYHOST |
Keep certificate verification enabled. |
Add headers or cookies only when the endpoint requires them:
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: text/html,application/xhtml+xml',
'Authorization: Bearer ' . $token,
]);
curl_setopt($ch, CURLOPT_COOKIE, 'session=example');
Alternative: PHP HTTP streams
PHP’s HTTP stream wrapper can handle small checks without cURL, provided the runtime permits URL access with allow_url_fopen. It exposes response headers and supports options for methods, headers, user agents, redirects, maximum redirects, timeouts, and reading content on error statuses.
<?php
$url = 'https://example.com/';
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 20,
'ignore_errors' => true,
'follow_location' => 1,
'max_redirects' => 5,
'user_agent' => 'ExamplePhpMonitor/1.0',
],
]);
$body = file_get_contents($url, false, $context);
if ($body === false) {
throw new RuntimeException('Request failed');
}
$finalStatus = null;
foreach (array_reverse($http_response_header ?? []) as $header) {
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $header, $m)) {
$finalStatus = (int) $m[1];
break;
}
}
echo json_encode([
'status' => $finalStatus,
'bytes' => strlen($body),
], JSON_PRETTY_PRINT) . PHP_EOL;
Streams are convenient, but status parsing after redirects needs care. cURL is usually easier when you need detailed timing, headers, retries, or more request controls.
Persisting history and making failures useful
The JSONL log stores one independent record per target and run. Keep at least the timestamp, target, final status, effective URL, duration, transport error, and failed rule. Rotate the file or move records to SQLite, PostgreSQL, or your log platform when volume grows.
SQLite option
$db = new PDO('sqlite:' . __DIR__ . '/var/monitor.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('CREATE TABLE IF NOT EXISTS checks (
id INTEGER PRIMARY KEY,
checked_at TEXT NOT NULL,
name TEXT NOT NULL,
url TEXT NOT NULL,
status INTEGER,
duration_ms INTEGER NOT NULL,
ok INTEGER NOT NULL,
failure_json TEXT
)');
$stmt = $db->prepare('INSERT INTO checks
(checked_at, name, url, status, duration_ms, ok, failure_json)
VALUES (:checked_at, :name, :url, :status, :duration_ms, :ok, :failure_json)');
$stmt->execute([
':checked_at' => $result['time'],
':name' => $result['name'],
':url' => $result['url'],
':status' => $result['status'],
':duration_ms' => $result['duration_ms'],
':ok' => $result['ok'] ? 1 : 0,
':failure_json' => json_encode($result['failure']),
]);
Retries, overlap, and failure policy
A transient network error should not always page someone. Add a small, bounded retry loop for transport failures, with a short delay between attempts. Avoid retrying every 404 or content mismatch because repeated requests can increase origin load and hide a real failure.
For many targets, the sum of individual timeouts can exceed your schedule interval. Set a run-level budget, keep the target list bounded, and prevent overlapping runs with a lock. A simple CLI lock can use flock:
$lock = fopen(__DIR__ . '/var/monitor.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
fwrite(STDERR, "Another run is active\n");
exit(3);
}
// Perform checks here.
flock($lock, LOCK_UN);
fclose($lock);
Schedule it with cron
Edit the crontab for the account that owns the PHP runtime:
crontab -e
*/15 * * * * cd /opt/site-monitor && /usr/bin/php monitor.php >> /opt/site-monitor/cron.log 2>&1
The fifteen-minute expression is an example. Choose an interval that fits your detection needs, target load, and total run time. Use absolute paths, confirm the PHP binary, and ensure cron has the same environment and permissions as your manual run.
Security and operational checklist
- Keep target URLs in a controlled configuration; do not accept arbitrary user URLs without SSRF protections.
- Do not disable TLS certificate verification to hide certificate errors.
- Keep authorization tokens and cookies out of source control and logs.
- Use a descriptive user agent and respect the target’s capacity.
- Limit redirects and request duration.
- Protect log files from public web access.
- Alert only after a defined number of consecutive failures if transient errors are common.
Performance, reliability, and cost
Each target normally requires one network request, so runtime grows with target count and latency. Sequential checks are simple and predictable; parallel cURL handles can reduce wall-clock time for larger lists, but they need stricter global limits and more careful error handling. Reuse connections where your client design supports it, keep response bodies bounded when you only need headers, and avoid downloading very large pages unnecessarily.
Use separate connect and total timeouts, record duration, and monitor the monitor itself. A down DNS resolver, exhausted file descriptors, expired CA bundle, or blocked egress can make every target fail at once.
The script has no service charge beyond the server and network resources you already operate. If you need browser rendering, visual capture, or a hosted scheduler, use a service designed for that workload.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Call to undefined function curl_init() |
cURL is not enabled in the CLI PHP runtime. | Install or enable the PHP cURL extension and verify with php -m. |
| Request times out | Slow origin, DNS, firewall, or timeout too low. | Check connectivity, keep finite timeouts, and inspect connect versus total duration. |
| Status is 0 | Transport failure occurred before an HTTP response. | Log curl_errno() and curl_error(); check DNS, TLS, proxy, and egress rules. |
| Content check fails on a visible page | The text is inserted by JavaScript or varies by locale/session. | Use a server-rendered marker or a browser-based test for client-side behavior. |
| Stream request returns false | allow_url_fopen is disabled or the wrapper could not connect. |
Enable it where appropriate, or use cURL and inspect the error. |
| Redirect gives the wrong status | The first response was a redirect. | Follow redirects deliberately and evaluate the final status and effective URL. |
| Cron works manually but not on schedule | Different PATH, PHP binary, working directory, user, or permissions. | Use absolute paths, log stderr, and run as the cron user. |
| Runs overlap | Total work exceeds the schedule interval. | Shorten the target list, lower timeouts, increase the interval, or add a lock. |
Or skip the browser setup
If your goal includes clean screenshots or rendered pages, ScreenshotNeo provides a website screenshot API and MCP server. The API accepts one GET request and returns PNG, JPEG, WebP, or PDF. It accepts consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be disabled. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and responses identify the result with X-Page-Verdict and X-Billed headers.
See the ScreenshotNeo API documentation for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo also offers an MCP server with take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. It supports full-page and element capture, device and viewport settings, waits, custom CSS and JavaScript, request blocking, headers, cookies, user agents, timezone, geolocation, caching, signed links, async jobs, bulk capture, and a usage API. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000.
Create a free ScreenshotNeo account and get 1,000 screenshots a month with no card.
FAQ
Does an HTTP 200 prove the website is healthy?
No. It proves that the server returned that status. Add a stable content assertion and use browser tests for JavaScript behavior, forms, and multi-step journeys.
Should I use cURL or streams?
Use cURL when you need detailed controls and diagnostics. Streams can be adequate for a small script when allow_url_fopen is available.
How often should cron run?
Choose an interval based on how quickly you need to detect incidents, the number of targets, and the time each run can take. Fifteen minutes is only an example.
Can the script monitor authenticated pages?
Yes, if you provide appropriate headers or cookies securely. Never put credentials in a public repository or write them into normal logs.
How do I monitor a JavaScript-rendered page?
A PHP HTTP request does not execute JavaScript. Use a browser automation system or a screenshot service when the assertion depends on client-side rendering.


