Native PHP 8.3: Pratite tehnološke stackove klijenata uz upozorenja Website Detector API-ja
A client’s public website can change underneath you without a deployment appearing in your repository. A CMS upgrade may introduce a new server signature, an analytics tool may disappear, or a hosting migration may alter the redirect path and exposed framework version. Those changes are often harmless, but they can also explain broken integrations, performance regressions, or an unexpected expansion of the site’s attack surface.
This tutorial builds a small Native PHP 8.3 monitoring service around the Website Technology Detector API. It checks one or more important websites, converts the API response into a stable domain model, stores the last known state in SQLite, and emails a developer only when the meaningful public stack changes.
The design deliberately excludes evidence and confidence fluctuations from the comparison fingerprint. They remain available for diagnosis, while alerts focus on technology names, detected versions, and redirect destinations. That distinction prevents a monitoring tool from becoming a notification generator.
Get access before writing integration code
Begin by creating an account at https://ai.mihajlo.mk/register. If you already have one, sign in at https://ai.mihajlo.mk/login.
Open the service page at https://ai.mihajlo.mk/api/website-technology-detector, choose an available Free, Plus, or Pro plan, and complete its activation. Then visit the official documentation at https://ai.mihajlo.mk/api/website-technology-detector/documentation. Find the Service token panel and copy the service-scoped token shown there.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. The implementation below uses a Bearer token because headers are less likely than query strings to appear in proxy access logs and copied URLs.
Regenerating the service token revokes the previously active token. Treat regeneration as a credential rotation: update the deployed environment, run a verification check, and only then consider the rotation complete.
Confirm the exact request
The API call is POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. It receives a JSON object containing url. Make one minimal request before building the monitor:
export WEBSITE_DETECTOR_TOKEN='YOUR_SERVICE_TOKEN'
curl --fail-with-body \
--request POST \
--connect-timeout 5 \
--max-time 25 \
--header "Authorization: Bearer ${WEBSITE_DETECTOR_TOKEN}" \
--header "Accept: application/json" \
--header "Content-Type: application/json" \
--data '{"url":"https://client.example"}' \
https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies
Compare the returned document with the current response example in the official documentation. In particular, confirm the documented containers for confidence-scored technologies, versions, evidence, and redirect information before adapting the boundary mapper below.
For local development, store configuration in an uncommitted .env file:
WEBSITE_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
MONITOR_URL=https://client.example
MONITOR_MIN_CONFIDENCE=70
[email protected]
[email protected]
MONITOR_DB=/var/lib/website-stack-monitor/state.sqlite
Add .env, SQLite files, and temporary database files to .gitignore. Give the environment file owner-only permissions. Never place a real token in PHP source, test fixtures, command history, screenshots, or logs.
A compact architecture with explicit boundaries
This job does not need a web framework, message broker, or long-running worker. A scheduler invokes one CLI command. The command calls a dedicated API client, maps the response, compares it with an SQLite snapshot, and invokes a notifier.
- HTTP boundary: native cURL applies authentication, timeouts, response limits, and bounded retries.
- Domain boundary: a mapper validates uncertain JSON before the application trusts it.
- Persistence: SQLite gives atomic updates without introducing a database server.
- Notification: an interface keeps email separate from change detection and makes testing deterministic.
A practical project structure is:
website-stack-monitor/
├── bin/monitor.php
├── config/bootstrap.php
├── src/Http.php
├── src/Detector.php
├── src/Monitoring.php
├── tests/WebsiteDetectorClientTest.php
├── var/
├── .env
├── .gitignore
├── composer.json
└── phpunit.xml
The runtime requires PHP 8.3 with cURL, PDO, PDO SQLite, and JSON extensions, plus a configured mail transport if you use PHP’s mail(). PHPUnit 11 is the only development dependency:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"StackMonitor\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"StackMonitor\\Tests\\": "tests/"
}
}
}
Build a bounded native cURL client
First isolate transport mechanics. The interface lets tests provide queued responses without making network requests or sleeping.
<?php
// src/Http.php
declare(strict_types=1);
namespace StackMonitor;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
public array $headers = [],
) {}
}
interface Transport
{
public function post(string $url, array $headers, string $body): HttpResponse;
}
final class CurlTransport implements Transport
{
public function post(string $url, array $headers, string $body): HttpResponse
{
$receivedHeaders = [];
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 5_000,
CURLOPT_TIMEOUT_MS => 25_000,
CURLOPT_MAXREDIRS => 0,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HEADERFUNCTION => static function (
CurlHandle $handle,
string $line
) use (&$receivedHeaders): int {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$receivedHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
$bodyResult = curl_exec($handle);
if ($bodyResult === false) {
$message = curl_error($handle);
curl_close($handle);
throw new \RuntimeException("Detector transport failed: {$message}");
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
if (strlen($bodyResult) > 2_000_000) {
throw new \RuntimeException('Detector response exceeded 2 MB');
}
return new HttpResponse($status, $bodyResult, $receivedHeaders);
}
}
The service client retries only failures likely to be transient: HTTP 429, 500, 502, 503, and 504, plus transport exceptions. It does not retry malformed requests, failed authentication, or other client errors. Three attempts with capped backoff keep a scheduled run bounded.
<?php
// src/Detector.php
declare(strict_types=1);
namespace StackMonitor;
final class DetectorException extends \RuntimeException
{
public function __construct(
string $message,
public readonly ?int $status = null,
public readonly bool $retryable = false,
) {
parent::__construct($message);
}
}
final class WebsiteDetectorClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies';
public function __construct(
private readonly Transport $transport,
private readonly string $token,
private readonly \Closure $sleep,
) {
if ($token === '') {
throw new \InvalidArgumentException('Detector token is missing');
}
}
public function detect(string $url): array
{
$payload = json_encode(
['url' => $url],
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->post(self::ENDPOINT, [
'Authorization: Bearer ' . $this->token,
'Accept: application/json',
'Content-Type: application/json',
], $payload);
} catch (\RuntimeException $exception) {
if ($attempt === 3) {
throw new DetectorException(
'Detector remained unreachable after three attempts',
null,
true
);
}
($this->sleep)(1 << ($attempt - 1));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
try {
$decoded = json_decode(
$response->body,
true,
64,
JSON_THROW_ON_ERROR
);
} catch (\JsonException $exception) {
throw new DetectorException('Detector returned invalid JSON');
}
if (!is_array($decoded)) {
throw new DetectorException('Detector returned a non-object body');
}
return $decoded;
}
$retryable = $response->status === 429
|| in_array($response->status, [500, 502, 503, 504], true);
if (!$retryable || $attempt === 3) {
throw new DetectorException(
"Detector request failed with HTTP {$response->status}",
$response->status,
$retryable
);
}
$retryAfter = $response->headers['retry-after'] ?? null;
$delay = ctype_digit((string) $retryAfter)
? min(30, (int) $retryAfter)
: 1 << ($attempt - 1);
($this->sleep)($delay);
}
throw new \LogicException('Retry loop ended unexpectedly');
}
}
Do not log the response body indiscriminately. It may be large, and upstream error pages can contain operational details. A useful failure log records the target host, HTTP status, retryability, attempt count, and a correlation identifier generated by your job—not the token or complete request headers.
Map the response into a stable domain snapshot
The API supplies confidence-scored detections with evidence and version information, plus redirect information. External JSON must nevertheless be treated as untrusted input. The mapper below unwraps an optional object envelope, requires a list of technologies, validates every value, and rejects impossible confidence scores.
The field names should remain aligned with the current official response example. If that documented schema changes, update this one boundary rather than spreading array access throughout the application.
<?php
// Continue src/Detector.php
final readonly class Technology
{
public function __construct(
public string $name,
public ?string $version,
public float $confidence,
public array $evidence,
) {}
public function comparisonValue(): array
{
return ['name' => $this->name, 'version' => $this->version];
}
}
final readonly class StackSnapshot
{
public function __construct(
public string $requestedUrl,
public ?string $finalUrl,
public array $redirects,
public array $technologies,
) {}
public function comparisonDocument(): array
{
$technologies = array_map(
static fn (Technology $item): array => $item->comparisonValue(),
$this->technologies
);
usort(
$technologies,
static fn (array $a, array $b): int =>
[$a['name'], $a['version']] <=> [$b['name'], $b['version']]
);
return [
'final_url' => $this->finalUrl,
'redirects' => $this->redirects,
'technologies' => $technologies,
];
}
public function hash(): string
{
return hash('sha256', json_encode(
$this->comparisonDocument(),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
));
}
}
final class SnapshotMapper
{
public function map(
array $payload,
string $requestedUrl,
float $minimumConfidence
): StackSnapshot {
$body = isset($payload['data']) && is_array($payload['data'])
? $payload['data']
: $payload;
$rows = $body['technologies'] ?? null;
if (!is_array($rows) || !array_is_list($rows)) {
throw new \UnexpectedValueException(
'Response has no technologies list'
);
}
$technologies = [];
foreach ($rows as $row) {
if (!is_array($row)) {
throw new \UnexpectedValueException(
'Technology entry is not an object'
);
}
$name = $row['name'] ?? null;
$confidence = $row['confidence'] ?? null;
$version = $row['version'] ?? null;
$evidence = $row['evidence'] ?? [];
if (!is_string($name) || trim($name) === '') {
throw new \UnexpectedValueException(
'Technology name is missing'
);
}
if (!is_int($confidence) && !is_float($confidence)) {
throw new \UnexpectedValueException(
'Technology confidence is not numeric'
);
}
if ($confidence < 0 || $confidence > 100) {
throw new \UnexpectedValueException(
'Technology confidence is outside 0..100'
);
}
if ($version !== null && !is_string($version)) {
throw new \UnexpectedValueException(
'Technology version is invalid'
);
}
if (!is_array($evidence)) {
throw new \UnexpectedValueException(
'Technology evidence is invalid'
);
}
if ($confidence >= $minimumConfidence) {
$technologies[] = new Technology(
trim($name),
$version,
(float) $confidence,
array_values($evidence)
);
}
}
$finalUrl = $body['final_url'] ?? null;
$redirects = $body['redirects'] ?? [];
if ($finalUrl !== null && !is_string($finalUrl)) {
throw new \UnexpectedValueException('Final URL is invalid');
}
if (!is_array($redirects)) {
throw new \UnexpectedValueException('Redirect information is invalid');
}
return new StackSnapshot(
$requestedUrl,
$finalUrl,
array_values($redirects),
$technologies
);
}
}
A confidence threshold is a product decision, not an API truth. Start conservatively and inspect actual evidence. Lower thresholds reveal more tentative technologies but can increase churn. Raising the threshold can conceal a real change when public evidence is weak.
Persist snapshots and notify only after a real change
The first successful run establishes a baseline and sends no email. Later runs compare hashes. The new snapshot is committed only after notification succeeds; otherwise, a transient mail failure would silently consume the event.
<?php
// src/Monitoring.php
declare(strict_types=1);
namespace StackMonitor;
interface Notifier
{
public function changed(
StackSnapshot $before,
StackSnapshot $after
): void;
}
final class MailNotifier implements Notifier
{
public function __construct(
private readonly string $to,
private readonly string $from,
) {
foreach ([$to, $from] as $address) {
if (!filter_var($address, FILTER_VALIDATE_EMAIL)
|| str_contains($address, "\n")
|| str_contains($address, "\r")) {
throw new \InvalidArgumentException('Invalid alert address');
}
}
}
public function changed(StackSnapshot $before, StackSnapshot $after): void
{
$subject = 'Website technology stack changed';
$message = "Target: {$after->requestedUrl}\n\nBefore:\n"
. json_encode($before->comparisonDocument(), JSON_PRETTY_PRINT)
. "\n\nAfter:\n"
. json_encode($after->comparisonDocument(), JSON_PRETTY_PRINT);
if (!mail($this->to, $subject, $message, "From: {$this->from}")) {
throw new \RuntimeException('Mail transport rejected the alert');
}
}
}
final class SnapshotRepository
{
public function __construct(private readonly \PDO $pdo)
{
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$pdo->exec(
'CREATE TABLE IF NOT EXISTS snapshots (
target TEXT PRIMARY KEY,
snapshot_hash TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
checked_at TEXT NOT NULL
)'
);
}
public function find(string $target): ?StackSnapshot
{
$statement = $this->pdo->prepare(
'SELECT snapshot_json FROM snapshots WHERE target = :target'
);
$statement->execute(['target' => $target]);
$json = $statement->fetchColumn();
if ($json === false) {
return null;
}
$data = json_decode($json, true, 64, JSON_THROW_ON_ERROR);
$technologies = array_map(
static fn (array $row): Technology => new Technology(
$row['name'],
$row['version'],
(float) $row['confidence'],
$row['evidence']
),
$data['technologies']
);
return new StackSnapshot(
$data['requestedUrl'],
$data['finalUrl'],
$data['redirects'],
$technologies
);
}
public function save(StackSnapshot $snapshot): void
{
$statement = $this->pdo->prepare(
'INSERT INTO snapshots
(target, snapshot_hash, snapshot_json, checked_at)
VALUES (:target, :hash, :json, :checked)
ON CONFLICT(target) DO UPDATE SET
snapshot_hash = excluded.snapshot_hash,
snapshot_json = excluded.snapshot_json,
checked_at = excluded.checked_at'
);
$statement->execute([
'target' => $snapshot->requestedUrl,
'hash' => $snapshot->hash(),
'json' => json_encode($snapshot, JSON_THROW_ON_ERROR),
'checked' => gmdate(DATE_ATOM),
]);
}
}
The command should validate URLs before sending them. If targets can be edited by untrusted users, add an SSRF policy that resolves and rejects loopback, private, link-local, and reserved addresses. For this administrator-controlled monitor, restrict configuration to explicit HTTPS URLs.
<?php
// bin/monitor.php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use StackMonitor\CurlTransport;
use StackMonitor\MailNotifier;
use StackMonitor\SnapshotMapper;
use StackMonitor\SnapshotRepository;
use StackMonitor\WebsiteDetectorClient;
$config = parse_ini_file(dirname(__DIR__) . '/.env', false, INI_SCANNER_RAW);
if ($config === false) {
fwrite(STDERR, "Cannot read environment configuration\n");
exit(2);
}
$value = static function (string $key) use ($config): string {
$environment = getenv($key);
$result = $environment !== false ? $environment : ($config[$key] ?? '');
if (!is_string($result) || $result === '') {
throw new RuntimeException("Missing configuration: {$key}");
}
return $result;
};
try {
$target = $value('MONITOR_URL');
if (filter_var($target, FILTER_VALIDATE_URL) === false
|| parse_url($target, PHP_URL_SCHEME) !== 'https') {
throw new RuntimeException('MONITOR_URL must be an HTTPS URL');
}
$client = new WebsiteDetectorClient(
new CurlTransport(),
$value('WEBSITE_DETECTOR_TOKEN'),
static fn (int $seconds) => sleep($seconds)
);
$snapshot = (new SnapshotMapper())->map(
$client->detect($target),
$target,
(float) $value('MONITOR_MIN_CONFIDENCE')
);
$pdo = new PDO('sqlite:' . $value('MONITOR_DB'));
$repository = new SnapshotRepository($pdo);
$previous = $repository->find($target);
if ($previous !== null && $previous->hash() !== $snapshot->hash()) {
(new MailNotifier(
$value('ALERT_TO'),
$value('ALERT_FROM')
))->changed($previous, $snapshot);
}
$repository->save($snapshot);
fwrite(STDOUT, json_encode([
'event' => $previous === null ? 'baseline_created' : 'check_completed',
'target_host' => parse_url($target, PHP_URL_HOST),
'changed' => $previous !== null
&& $previous->hash() !== $snapshot->hash(),
'checked_at' => gmdate(DATE_ATOM),
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(0);
} catch (Throwable $exception) {
fwrite(STDERR, json_encode([
'event' => 'monitor_failed',
'error_type' => $exception::class,
'message' => $exception->getMessage(),
'failed_at' => gmdate(DATE_ATOM),
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(1);
}
Test retries and mapping without the network
A deterministic fake transport verifies behavior more reliably than a live API test. This test proves that a 429 response is retried, its bounded Retry-After delay is observed, and the successful payload is mapped.
<?php
// tests/WebsiteDetectorClientTest.php
declare(strict_types=1);
namespace StackMonitor\Tests;
use PHPUnit\Framework\TestCase;
use StackMonitor\HttpResponse;
use StackMonitor\SnapshotMapper;
use StackMonitor\Transport;
use StackMonitor\WebsiteDetectorClient;
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function post(string $url, array $headers, string $body): HttpResponse
{
$this->calls++;
return array_shift($this->responses);
}
}
final class WebsiteDetectorClientTest extends TestCase
{
public function testRateLimitIsRetriedAndResponseIsMapped(): void
{
$transport = new FakeTransport([
new HttpResponse(429, '{}', ['retry-after' => '2']),
new HttpResponse(200, json_encode([
'technologies' => [[
'name' => 'Example CMS',
'version' => '4.2',
'confidence' => 96,
'evidence' => ['public marker'],
]],
'final_url' => 'https://client.example/',
'redirects' => [],
], JSON_THROW_ON_ERROR)),
]);
$delays = [];
$client = new WebsiteDetectorClient(
$transport,
'test-token',
static function (int $seconds) use (&$delays): void {
$delays[] = $seconds;
}
);
$payload = $client->detect('https://client.example');
$snapshot = (new SnapshotMapper())->map(
$payload,
'https://client.example',
70
);
self::assertSame(2, $transport->calls);
self::assertSame([2], $delays);
self::assertSame('Example CMS', $snapshot->technologies[0]->name);
self::assertSame('4.2', $snapshot->technologies[0]->version);
}
}
Add companion tests for authentication failures with no retry, malformed JSON, absent detection lists, confidence filtering, first-run baseline behavior, changed hashes, and notification failure. Run the suite with vendor/bin/phpunit.
Deployment, observability, and predictable failures
Run the command from a scheduler under a dedicated operating-system account. The account needs read access to configuration, write access to the database directory, outbound HTTPS access, and access to a working local mail transfer agent. PHP’s mail() returning true means the message was accepted for delivery; it does not prove that the recipient received it.
A cron entry might run the check every six hours:
17 */6 * * * cd /opt/website-stack-monitor && /usr/bin/php bin/monitor.php >> /var/log/website-stack-monitor.log 2>&1
Avoid overlapping executions by using a scheduler-level lock such as flock, especially when monitoring several sites. Back up the small SQLite database, but do not place it in a web-accessible directory. Ship the JSON log lines to your normal logging system and alert on repeated monitor_failed events or a missing successful check.
Common failures are usually straightforward:
- HTTP 401 or 403: verify plan activation and the service-scoped token. A recently regenerated token makes the previous value invalid.
- HTTP 429: reduce schedule frequency, check plan capacity, and honor
Retry-After. Do not create an unbounded retry loop. - Validation errors: compare the response with the official documentation and update only the mapper.
- Repeated noisy alerts: raise the confidence threshold or confirm that redirect and version changes should remain fingerprint inputs.
- No email: inspect the local mail transport and delivery logs; do not assume API monitoring failed.
- Locked or unwritable SQLite database: verify directory ownership, use one scheduled process, and keep the file on suitable local storage.
Final verification checklist
- Confirm the token is environment-backed, excluded from version control, and absent from logs.
- Run the minimal API request and compare its shape with the official documentation.
- Run
composer install --no-dev --classmap-authoritativefor production and execute the PHPUnit suite in CI. - Execute the monitor once and confirm it creates a baseline without sending an alert.
- Use a fake repository snapshot in a staging test to verify the email path without falsifying the live website.
- Confirm that HTTP 401 is not retried and HTTP 429 or transient server failures stop after three attempts.
- Verify the scheduler’s working directory, lock, database permissions, mail transport, and failure alerting.
- Rotate the service token once in a controlled environment and confirm that the old token is revoked.
A useful stack monitor is intentionally skeptical: skeptical of remote JSON, unstable evidence, transient failures, unbounded retries, and successful-looking mail calls. With those boundaries in place, a modest PHP command becomes a dependable early-warning system. When an important client website changes in public, the developer responsible for it learns from a concise, evidence-backed alert—not from the next broken integration.