Tutorials

Native PHP 8.3: Monitor Client Sites for Tech Stack Changes with Website Detector API

Native PHP 8.3: Monitor Client Sites for Tech Stack Changes with Website Detector API

A client site can change underneath you without a deployment: a CDN migration alters headers, a redesign replaces the CMS, or a marketing plugin quietly introduces a new dependency. These changes are public, operationally relevant, and easy to miss until something breaks.

This tutorial builds a production-oriented Native PHP 8.3 monitor around the Website Technology Detector API. It checks a controlled list of client sites, converts the API response into a stable domain snapshot, stores the last successful result, and emails a developer when technologies are added, removed, or report a different version.

The design deliberately separates detection, comparison, persistence, and notification. That keeps transient API failures from looking like stack changes and makes every boundary independently testable.

Get access and copy a service-scoped token

  1. Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
  2. Open the Website Technology Detector service page.
  3. Choose an available Free, Plus, or Pro plan and complete activation. Pick a plan whose request allowance fits your number of sites and monitoring frequency.
  4. Open the official service documentation.
  5. 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 logs and analytics systems.

Regenerating the service token revokes the previously active token. Treat regeneration as a credential rotation: update the deployment environment, restart the monitor, and verify one request before considering the rotation complete.

Confirm the endpoint before writing the monitor

The exact call is POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Its JSON request body contains url.

Make one minimal request with a non-sensitive test target:

curl --fail-with-body \
  --request POST \
  --url https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies \
  --header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
  --header "Accept: application/json" \
  --header "Content-Type: application/json" \
  --data '{"url":"https://example.com"}'

Do not paste the real token into shell history on a shared machine. For the project, create a deployment environment file excluded from version control:

WEBSITE_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
CLIENT_SITES=https://example.com,https://www.example.org
[email protected]
STATE_FILE=/var/lib/site-stack-monitor/state.json

Set the file mode to 0600. The target URLs are configuration, not arbitrary web input: only list sites you are responsible for monitoring.

Architecture and project structure

A synchronous scheduled command is enough here. A queue would add infrastructure without improving a small hourly monitor. The command performs four operations:

  1. Ask the detector for each configured URL.
  2. Map the response into validated detections and redirect metadata.
  3. Compare only technology names and versions with the previous successful snapshot.
  4. Persist the snapshot and notify on a meaningful difference.

Confidence and evidence remain in stored snapshots for diagnosis, but they are excluded from the change signature. Otherwise harmless confidence fluctuations could generate noisy alerts.

Create this structure:

site-stack-monitor/
├── bin/
│   └── check.php
├── src/
│   └── Monitor.php
├── tests/
│   └── DetectorClientTest.php
├── composer.json
└── phpunit.xml

Use Composer only for autoloading and PHPUnit:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "files": ["src/Monitor.php"]
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  }
}

Install dependencies with composer install, then run composer dump-autoload after adding the source file.

Build a bounded HTTP and domain boundary

The API returns confidence-scored detections with evidence and versions, plus redirect information. The mapper below validates those concepts while tolerating nesting and the two natural collection labels, detections and technologies. Unknown fields stay outside the comparison model instead of leaking throughout the application.

<?php
declare(strict_types=1);

namespace SiteMonitor;

use Closure;
use JsonException;
use RuntimeException;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body
    ) {}
}

interface Transport
{
    public function post(string $url, array $headers, string $body): HttpResponse;
}

final class TransportFailure extends RuntimeException {}

final class ApiFailure extends RuntimeException
{
    public function __construct(public readonly ?int $status, string $message)
    {
        parent::__construct($message);
    }
}

final class CurlTransport implements Transport
{
    public function post(string $url, array $headers, string $body): HttpResponse
    {
        $receivedHeaders = [];
        $handle = curl_init($url);

        if ($handle === false) {
            throw new TransportFailure('Unable to initialize cURL');
        }

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 25,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
                use (&$receivedHeaders): int {
                $length = strlen($line);
                $parts = explode(':', $line, 2);

                if (count($parts) === 2) {
                    $receivedHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }

                return $length;
            },
        ]);

        $bodyResult = curl_exec($handle);

        if ($bodyResult === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new TransportFailure($message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResponse($status, $receivedHeaders, $bodyResult);
    }
}

final readonly class DetectionSnapshot
{
    public function __construct(
        public array $detections,
        public array $redirectMetadata
    ) {}

    public static function fromPayload(array $payload): self
    {
        $collections = [];
        self::findCollections($payload, $collections);

        if ($collections === []) {
            throw new ApiFailure(null, 'Response has no detection collection');
        }

        $mapped = [];

        foreach ($collections as $collection) {
            foreach ($collection as $item) {
                if (!is_array($item)) {
                    continue;
                }

                $name = $item['name'] ?? $item['technology'] ?? null;
                $confidence = $item['confidence'] ?? null;

                if (!is_string($name) || trim($name) === '' || !is_numeric($confidence)) {
                    continue;
                }

                $evidence = $item['evidence'] ?? [];
                $evidence = is_string($evidence) ? [$evidence] : $evidence;

                if (!is_array($evidence)) {
                    $evidence = [];
                }

                $version = $item['version'] ?? null;
                $key = strtolower(trim($name));

                $mapped[$key] = [
                    'name' => trim($name),
                    'version' => is_scalar($version) ? (string) $version : null,
                    'confidence' => (float) $confidence,
                    'evidence' => array_values(array_filter(
                        $evidence,
                        static fn ($value): bool => is_string($value)
                    )),
                ];
            }
        }

        ksort($mapped);
        $redirects = [];
        self::findRedirectMetadata($payload, '', $redirects);

        return new self(array_values($mapped), $redirects);
    }

    public function stackSignature(): array
    {
        return array_map(
            static fn (array $item): array => [
                'name' => $item['name'],
                'version' => $item['version'],
            ],
            $this->detections
        );
    }

    private static function findCollections(array $node, array &$found): void
    {
        foreach ($node as $key => $value) {
            if (
                is_string($key)
                && in_array(strtolower($key), ['detections', 'technologies'], true)
                && is_array($value)
            ) {
                $found[] = $value;
            }

            if (is_array($value)) {
                self::findCollections($value, $found);
            }
        }
    }

    private static function findRedirectMetadata(
        array $node,
        string $path,
        array &$found
    ): void {
        foreach ($node as $key => $value) {
            $next = $path === '' ? (string) $key : $path . '.' . $key;

            if (is_string($key) && preg_match('/redirect|final[_-]?url/i', $key)) {
                $found[$next] = $value;
            } elseif (is_array($value)) {
                self::findRedirectMetadata($value, $next, $found);
            }
        }
    }
}

final class DetectorClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies';

    private Closure $sleep;

    public function __construct(
        private readonly Transport $transport,
        private readonly string $token,
        ?Closure $sleep = null
    ) {
        $this->sleep = $sleep ?? static fn (int $milliseconds) =>
            usleep($milliseconds * 1000);
    }

    public function detect(string $url): DetectionSnapshot
    {
        $parts = parse_url($url);

        if (
            $parts === false
            || !isset($parts['scheme'], $parts['host'])
            || !in_array(strtolower($parts['scheme']), ['http', 'https'], true)
        ) {
            throw new ApiFailure(null, 'Configured target is not an HTTP(S) URL');
        }

        $requestBody = json_encode(['url' => $url], JSON_THROW_ON_ERROR);

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->post(self::ENDPOINT, [
                    'Authorization: Bearer ' . $this->token,
                    'Accept: application/json',
                    'Content-Type: application/json',
                ], $requestBody);
            } catch (TransportFailure $failure) {
                if ($attempt === 3) {
                    throw new ApiFailure(null, 'Transport failed after three attempts');
                }

                ($this->sleep)(250 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $decoded = json_decode(
                        $response->body,
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );
                } catch (JsonException) {
                    throw new ApiFailure($response->status, 'Invalid JSON response');
                }

                if (!is_array($decoded)) {
                    throw new ApiFailure($response->status, 'Unexpected JSON root');
                }

                return DetectionSnapshot::fromPayload($decoded);
            }

            $retryable = $response->status === 429
                || in_array($response->status, [500, 502, 503, 504], true);

            if (!$retryable || $attempt === 3) {
                throw new ApiFailure(
                    $response->status,
                    'Detector returned HTTP ' . $response->status
                );
            }

            $retryAfter = $response->headers['retry-after'] ?? null;
            $delay = is_string($retryAfter) && ctype_digit($retryAfter)
                ? min(10_000, (int) $retryAfter * 1000)
                : 250 * (2 ** ($attempt - 1));

            ($this->sleep)($delay);
        }

        throw new ApiFailure(null, 'Detector request did not complete');
    }
}

The client retries network failures, HTTP 429, and selected temporary server errors. It never blindly retries authentication, permission, or validation failures. A numeric Retry-After value is honored but capped at ten seconds so one run cannot stall indefinitely.

Compare snapshots, persist state, and notify

The command uses an exclusive run lock, preventing overlapping scheduler invocations. It writes state through a temporary file and atomic rename. A failed site retains its previous successful state, so an outage cannot masquerade as an empty stack.

<?php
declare(strict_types=1);

use SiteMonitor\ApiFailure;
use SiteMonitor\CurlTransport;
use SiteMonitor\DetectorClient;

require dirname(__DIR__) . '/vendor/autoload.php';

function requiredEnv(string $name): string
{
    $value = getenv($name);

    if ($value === false || trim($value) === '') {
        throw new RuntimeException("Missing environment variable: {$name}");
    }

    return trim($value);
}

function logEvent(string $level, string $event, array $context = []): void
{
    fwrite(STDERR, json_encode([
        'time' => gmdate(DATE_ATOM),
        'level' => $level,
        'event' => $event,
    ] + $context, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL);
}

$token = requiredEnv('WEBSITE_DETECTOR_TOKEN');
$email = requiredEnv('ALERT_EMAIL');
$stateFile = requiredEnv('STATE_FILE');
$sites = array_values(array_filter(array_map(
    'trim',
    explode(',', requiredEnv('CLIENT_SITES'))
)));

if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    throw new RuntimeException('ALERT_EMAIL is invalid');
}

$lock = fopen($stateFile . '.run.lock', 'c');

if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
    logEvent('warning', 'run_already_active');
    exit(75);
}

$state = [];

if (is_file($stateFile)) {
    $decoded = json_decode(
        file_get_contents($stateFile),
        true,
        512,
        JSON_THROW_ON_ERROR
    );
    $state = is_array($decoded) ? $decoded : [];
}

$nextState = $state;
$client = new DetectorClient(new CurlTransport(), $token);
$exitCode = 0;

foreach ($sites as $site) {
    $key = hash('sha256', $site);

    try {
        $snapshot = $client->detect($site);
        $signature = $snapshot->stackSignature();
        $previous = $state[$key]['signature'] ?? null;

        if (is_array($previous) && $previous !== $signature) {
            $message = "Public technology stack changed for {$site}\n\n"
                . "Previous:\n"
                . json_encode($previous, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)
                . "\n\nCurrent:\n"
                . json_encode($signature, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);

            if (!mail($email, 'Client website technology change', $message)) {
                throw new RuntimeException('Local mail transport rejected the alert');
            }

            logEvent('info', 'change_alert_sent', ['site' => $site]);
        } elseif ($previous === null) {
            logEvent('info', 'baseline_created', ['site' => $site]);
        } else {
            logEvent('info', 'stack_unchanged', ['site' => $site]);
        }

        $nextState[$key] = [
            'url' => $site,
            'observed_at' => gmdate(DATE_ATOM),
            'signature' => $signature,
            'detections' => $snapshot->detections,
            'redirect_metadata' => $snapshot->redirectMetadata,
        ];
    } catch (ApiFailure | RuntimeException $failure) {
        $exitCode = 1;
        logEvent('error', 'site_check_failed', [
            'site' => $site,
            'status' => $failure instanceof ApiFailure ? $failure->status : null,
            'message' => $failure->getMessage(),
        ]);
    }
}

$temporary = $stateFile . '.' . getmypid() . '.tmp';
file_put_contents(
    $temporary,
    json_encode($nextState, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
    LOCK_EX
);
chmod($temporary, 0600);

if (!rename($temporary, $stateFile)) {
    throw new RuntimeException('Unable to replace state file');
}

flock($lock, LOCK_UN);
fclose($lock);
exit($exitCode);

The first successful run creates a baseline without sending an alert. That is important: initial discovery is not a change. PHP’s mail() requires a correctly configured local mail transfer agent. If your host does not provide one, replace that single notification boundary with your established mailer while keeping the detector and comparison logic unchanged.

Test without making real API calls

A fake transport makes retries and failure paths deterministic. It also verifies that authentication and request serialization remain correct.

<?php
declare(strict_types=1);

namespace Tests;

use PHPUnit\Framework\TestCase;
use SiteMonitor\ApiFailure;
use SiteMonitor\DetectorClient;
use SiteMonitor\HttpResponse;
use SiteMonitor\Transport;

final class FakeTransport implements Transport
{
    public array $requests = [];

    public function __construct(private array $responses) {}

    public function post(string $url, array $headers, string $body): HttpResponse
    {
        $this->requests[] = compact('url', 'headers', 'body');
        return array_shift($this->responses);
    }
}

final class DetectorClientTest extends TestCase
{
    public function testMapsDetectionAndSendsExpectedRequest(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(200, [], json_encode([
                'data' => [
                    'detections' => [[
                        'name' => 'Example CMS',
                        'version' => '2',
                        'confidence' => 0.95,
                        'evidence' => ['public marker'],
                    ]],
                ],
                'redirects' => ['final_url' => 'https://example.com/'],
            ], JSON_THROW_ON_ERROR)),
        ]);

        $result = (new DetectorClient($fake, 'test-token'))->detect(
            'https://example.com'
        );

        self::assertSame('Example CMS', $result->detections[0]['name']);
        self::assertSame(
            ['name' => 'Example CMS', 'version' => '2'],
            $result->stackSignature()[0]
        );
        self::assertContains(
            'Authorization: Bearer test-token',
            $fake->requests[0]['headers']
        );
        self::assertSame(
            ['url' => 'https://example.com'],
            json_decode($fake->requests[0]['body'], true)
        );
    }

    public function testRetriesRateLimitUsingRetryAfter(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(429, ['retry-after' => '1'], ''),
            new HttpResponse(200, [], '{"detections":[]}'),
        ]);
        $delays = [];

        $client = new DetectorClient(
            $fake,
            'test-token',
            static function (int $milliseconds) use (&$delays): void {
                $delays[] = $milliseconds;
            }
        );

        $client->detect('https://example.com');

        self::assertCount(2, $fake->requests);
        self::assertSame([1000], $delays);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(401, [], '{"message":"unauthorized"}'),
        ]);

        try {
            (new DetectorClient($fake, 'bad-token'))->detect(
                'https://example.com'
            );
            self::fail('Expected ApiFailure');
        } catch (ApiFailure $failure) {
            self::assertSame(401, $failure->status);
            self::assertCount(1, $fake->requests);
        }
    }
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
  <testsuites>
    <testsuite name="site-stack-monitor">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
</phpunit>

Run vendor/bin/phpunit. The fixtures contain no real credentials and the fake never opens a network connection.

Deploy with a systemd timer

Create the state directory ahead of time, assign it to a dedicated unprivileged account, and ensure the environment file is readable only by that account. A systemd service provides reliable environment loading and sends structured logs to the journal.

# /etc/systemd/system/site-stack-monitor.service
[Unit]
Description=Monitor public client technology stacks

[Service]
Type=oneshot
User=site-monitor
Group=site-monitor
WorkingDirectory=/opt/site-stack-monitor
EnvironmentFile=/etc/site-stack-monitor.env
ExecStart=/usr/bin/php /opt/site-stack-monitor/bin/check.php
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/site-stack-monitor

# /etc/systemd/system/site-stack-monitor.timer
[Unit]
Description=Run the site stack monitor hourly

[Timer]
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

After installing the units, run systemctl daemon-reload, systemctl enable --now site-stack-monitor.timer, and one manual systemctl start site-stack-monitor.service. Inspect results with journalctl -u site-stack-monitor.service.

Security, observability, and common failures

Never log the token, Authorization header, or raw response body. The structured events above record the site, outcome, and HTTP status without exposing credentials or excessive evidence. Monitor nonzero service exits separately; stack-change email should not double as outage monitoring.

  • HTTP 401 or 403: verify the environment file and service activation. If the token was regenerated, the old value is immediately obsolete.
  • HTTP 429: reduce scheduling frequency, check plan capacity, and retain bounded backoff. Do not start parallel retries.
  • HTTP 400: validate the configured URL and JSON body. Retrying the same invalid request wastes quota.
  • Timeouts or 5xx responses: the client retries three bounded attempts, then preserves the last successful snapshot.
  • No email: confirm the local MTA, PHP sendmail_path, recipient address, and service-account permissions.
  • Repeated false alerts: inspect normalized names and versions. Keep confidence and evidence out of the signature unless your operational policy explicitly considers them changes.
  • State-write errors: verify that the state directory exists and is writable while the environment and state files remain inaccessible to other users.

Final verification checklist

  • The service plan is active and the current service-scoped token is environment-backed.
  • The minimal POST request succeeds against the exact detector endpoint.
  • PHP 8.3, cURL, JSON, Composer dependencies, and a working mail transport are installed.
  • All PHPUnit tests pass without network access.
  • The first run creates a baseline and sends no change alert.
  • A controlled fixture or temporary state edit proves that an added, removed, or version-changed technology triggers one email.
  • HTTP 401 is not retried, while HTTP 429 and temporary server failures use bounded retries.
  • Concurrent runs are rejected, failed checks preserve prior state, and logs contain no secret or raw response body.
  • The timer runs as an unprivileged account and the token and state files use restrictive permissions.

The valuable part of this monitor is not merely calling a detector. It is deciding what constitutes a meaningful change, preserving evidence without creating alert noise, and ensuring failures remain failures rather than becoming false business signals. With that boundary in place, a quiet hourly PHP process becomes an early-warning system for the public technology choices your client sites expose to the world.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.