Tutorials

Detect Client Tech Stack Changes: Real-time PHP 8.3 Alerts

Detect Client Tech Stack Changes: Real-time PHP 8.3 Alerts

A client’s public technology stack can change quietly: a CMS migration, a CDN replacement, a new analytics product, or a framework upgrade that affects your next deployment. Manually checking technology reports does not scale, but a small scheduled PHP program can establish a trusted baseline and alert you only when something meaningful changes.

This tutorial builds that program in Native PHP 8.3. It calls the Website Technology Detector API, validates the response at the application boundary, stores one snapshot per website, and produces cron-friendly notifications. Confidence and evidence remain available for investigation, while routine evidence fluctuations do not create noisy alerts.

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 its activation.
  4. Open the official service documentation.
  5. Find the Service token panel and copy its service-scoped token.

This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. The project uses a Bearer token so the credential stays out of URLs, proxy access logs, and browser history.

Regenerating the service token revokes the previously active token. Treat rotation as a deployment change: update the secret everywhere the monitor runs, verify the new token, and only then consider the rotation complete.

Confirm the endpoint before writing PHP

The exact request is POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies, with a JSON body containing url. Make one minimal request from a trusted terminal:

export WEBSITE_TECH_TOKEN='YOUR_SERVICE_TOKEN'

curl --fail-with-body \
  --connect-timeout 3 \
  --max-time 15 \
  -X POST \
  'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies' \
  -H "Authorization: Bearer ${WEBSITE_TECH_TOKEN}" \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  --data '{"url":"https://client.example"}'

Never commit that shell history or a real token. For local development, place placeholders in .env.local and exclude the file from Git:

# .env.local
WEBSITE_TECH_TOKEN=YOUR_SERVICE_TOKEN
MONITORED_URLS=https://client.example,https://shop.client.example
TECH_STATE_DIR=/var/lib/stack-monitor

# .gitignore
.env.local
.phpunit.cache/
vendor/

Native PHP does not load environment files automatically. During local verification, export this file through your shell with set -a; . ./.env.local; set +a. In production, inject the same variables through the scheduler, container secret mechanism, or service manager.

Choose a small, failure-aware architecture

A scheduled command is a better fit than a web route. Detection is background work, no user should wait for it, and cron already supplies scheduling and email delivery on ordinary Linux hosts.

The data path is intentionally short:

  1. A dedicated API client sends the authenticated request with bounded timeouts and retries.
  2. A mapper validates detections, confidence, evidence, versions, and redirect information.
  3. The command compares stable technology identities, versions, and redirects with the saved snapshot.
  4. An atomic state update becomes the next baseline.
  5. The command prints only changes or failures, allowing cron to notify the developer.

The first successful run creates a baseline without raising an alert. Confidence and evidence are saved for diagnosis, but they are excluded from change detection because small evidence variations can otherwise produce repetitive notifications.

Create the Native PHP project

The project needs PHP 8.3, the cURL and JSON extensions, Composer, and PHPUnit for automated tests.

{
  "name": "example/client-stack-monitor",
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "classmap": ["src/"]
  },
  "scripts": {
    "test": "phpunit tests"
  }
}
client-stack-monitor/
├── bin/
│   └── check-stack.php
├── src/
│   ├── Transport.php
│   ├── CurlTransport.php
│   ├── TechnologyDetectorClient.php
│   └── DetectionSnapshot.php
├── tests/
│   └── TechnologyDetectorClientTest.php
├── composer.json
└── .env.local

Run composer install after creating the files.

Build an injectable HTTP boundary

The transport abstraction keeps cURL details out of the domain and makes retries testable without network access.

<?php
// src/Transport.php
declare(strict_types=1);

namespace App;

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

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

final class TransportFailure extends \RuntimeException {}
final class ApiFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        string $message,
        public readonly ?int $status = null
    ) {
        parent::__construct($message);
    }
}
<?php
// src/CurlTransport.php
declare(strict_types=1);

namespace App;

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

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

        $responseHeaders = [];

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => $connectTimeout,
            CURLOPT_TIMEOUT => $responseTimeout,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$responseHeaders): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $responseHeaders[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 TransportFailure($message ?: 'Network request failed.');
        }

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

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

Add bounded retries and structured failures

Transient network errors, rate limits, and selected server failures receive at most three attempts. Authentication and validation failures are returned immediately: retrying an invalid token or body wastes quota and delays diagnosis.

<?php
// src/TechnologyDetectorClient.php
declare(strict_types=1);

namespace App;

final class TechnologyDetectorClient
{
    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('WEBSITE_TECH_TOKEN is required.');
        }
    }

    public function detect(string $url): array
    {
        if (filter_var($url, FILTER_VALIDATE_URL) === false) {
            throw new \InvalidArgumentException('A valid absolute URL is required.');
        }

        $body = json_encode(['url' => $url], JSON_THROW_ON_ERROR);
        $retryable = [429, 500, 502, 503, 504];

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->post(
                    self::ENDPOINT,
                    [
                        'Authorization: Bearer ' . $this->token,
                        'Accept: application/json',
                        'Content-Type: application/json',
                    ],
                    $body,
                    3,
                    15
                );
            } catch (TransportFailure $error) {
                if ($attempt === 3) {
                    throw new ApiFailure('network', $error->getMessage());
                }
                ($this->sleep)(200_000 * $attempt);
                continue;
            }

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $decoded = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR);
                } catch (\JsonException $error) {
                    throw new ApiFailure('protocol', 'API returned invalid JSON.');
                }

                if (!is_array($decoded)) {
                    throw new ApiFailure('protocol', 'API response must be a JSON object.');
                }

                return $decoded;
            }

            if (in_array($response->status, $retryable, true) && $attempt < 3) {
                $retryAfter = $response->headers['retry-after'] ?? null;
                $seconds = ctype_digit((string) $retryAfter)
                    ? min(2, (int) $retryAfter)
                    : 0;

                ($this->sleep)(
                    $seconds > 0 ? $seconds * 1_000_000 : 200_000 * $attempt
                );
                continue;
            }

            $kind = match ($response->status) {
                400, 422 => 'validation',
                401, 403 => 'authentication',
                429 => 'rate_limit',
                default => $response->status >= 500 ? 'service' : 'http',
            };

            throw new ApiFailure(
                $kind,
                "Technology API failed with HTTP {$response->status}.",
                $response->status
            );
        }

        throw new ApiFailure('internal', 'Retry loop ended unexpectedly.');
    }
}

Map the response at the boundary

External JSON must not leak unchecked into comparison logic. The mapper below rejects missing identities and incorrectly typed confidence, evidence, versions, or redirect data. It preserves the useful diagnostic fields while producing a stable comparison view.

<?php
// src/DetectionSnapshot.php
declare(strict_types=1);

namespace App;

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

    public static function fromApi(array $payload): self
    {
        $items = $payload['detections'] ?? null;
        $redirects = $payload['redirects'] ?? [];

        if (!is_array($items) || !is_array($redirects)) {
            throw new ApiFailure(
                'protocol',
                'Response detections and redirects must be arrays.'
            );
        }

        $mapped = [];

        foreach ($items as $item) {
            if (!is_array($item)) {
                throw new ApiFailure('protocol', 'Each detection must be an object.');
            }

            $name = $item['name'] ?? null;
            $confidence = $item['confidence'] ?? null;
            $evidence = $item['evidence'] ?? [];
            $versions = $item['versions'] ?? [];

            if (!is_string($name) || trim($name) === '') {
                throw new ApiFailure('protocol', 'Detection name is missing.');
            }
            if ($confidence !== null && !is_numeric($confidence)) {
                throw new ApiFailure('protocol', 'Confidence must be numeric.');
            }
            if (!is_array($evidence) || !is_array($versions)) {
                throw new ApiFailure('protocol', 'Evidence and versions must be arrays.');
            }

            $versions = array_values(array_filter(
                $versions,
                static fn ($value): bool => is_string($value) && $value !== ''
            ));
            sort($versions, SORT_STRING);

            $mapped[strtolower(trim($name))] = [
                'name' => trim($name),
                'confidence' => $confidence === null ? null : (float) $confidence,
                'evidence' => $evidence,
                'versions' => $versions,
            ];
        }

        ksort($mapped, SORT_STRING);

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

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

    public function toArray(): array
    {
        return [
            'detections' => $this->detections,
            'redirects' => $this->redirects,
            'comparison' => $this->comparisonView(),
        ];
    }
}

Keep this mapper synchronized with the response schema shown in the official documentation. If the service changes incompatibly, a protocol failure is safer than silently recording an empty stack and reporting that every technology disappeared.

Compare snapshots and emit notifications

The command validates configuration, serializes concurrent runs with a file lock, writes state through an atomic rename, and logs operational events through syslog. It prints only actionable changes and failures.

<?php
// bin/check-stack.php
declare(strict_types=1);

use App\CurlTransport;
use App\DetectionSnapshot;
use App\TechnologyDetectorClient;

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

openlog('stack-monitor', LOG_PID, LOG_USER);

$token = getenv('WEBSITE_TECH_TOKEN') ?: '';
$stateDir = getenv('TECH_STATE_DIR') ?: '';
$urls = array_values(array_filter(array_map(
    'trim',
    explode(',', getenv('MONITORED_URLS') ?: '')
)));

if ($token === '' || $stateDir === '' || $urls === []) {
    fwrite(STDOUT, "Stack monitor configuration is incomplete.\n");
    exit(2);
}

if (!is_dir($stateDir) || !is_writable($stateDir)) {
    fwrite(STDOUT, "State directory is missing or not writable.\n");
    exit(2);
}

$client = new TechnologyDetectorClient(
    new CurlTransport(),
    $token,
    static fn (int $microseconds) => usleep($microseconds)
);

$exit = 0;

foreach ($urls as $url) {
    $id = hash('sha256', $url);
    $stateFile = $stateDir . '/' . $id . '.json';
    $lock = fopen($stateDir . '/' . $id . '.lock', 'c');

    if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
        syslog(LOG_WARNING, "Skipped concurrent check url_hash={$id}");
        continue;
    }

    try {
        $snapshot = DetectionSnapshot::fromApi($client->detect($url));
        $current = $snapshot->toArray();
        $previous = null;

        if (is_file($stateFile)) {
            $previous = json_decode(
                (string) file_get_contents($stateFile),
                true,
                512,
                JSON_THROW_ON_ERROR
            );
        }

        $temporary = $stateFile . '.' . getmypid() . '.tmp';
        file_put_contents(
            $temporary,
            json_encode($current, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
            LOCK_EX
        );
        rename($temporary, $stateFile);

        if ($previous === null) {
            syslog(LOG_INFO, "Baseline created url_hash={$id}");
        } elseif (($previous['comparison'] ?? null) !== $current['comparison']) {
            echo "Public technology stack changed: {$url}\n";
            echo json_encode([
                'before' => $previous['comparison'] ?? null,
                'after' => $current['comparison'],
            ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), "\n";
            $exit = max($exit, 10);
        } else {
            syslog(LOG_INFO, "No stack change url_hash={$id}");
        }
    } catch (Throwable $error) {
        syslog(LOG_ERR, "Check failed url_hash={$id} type=" . $error::class);
        echo "Technology check failed for {$url}: {$error->getMessage()}\n";
        $exit = 20;
    } finally {
        flock($lock, LOCK_UN);
        fclose($lock);
    }
}

exit($exit);

The command updates the snapshot after a successful comparison, so one change produces one notification rather than recurring alerts. A failed request never overwrites the last trusted state.

Test without calling the live service

A deterministic fake transport verifies the most important failure path: a rate-limited response is retried, while the token and request body are still handled by the real client code.

<?php
// tests/TechnologyDetectorClientTest.php
declare(strict_types=1);

use App\HttpResponse;
use App\TechnologyDetectorClient;
use App\Transport;
use PHPUnit\Framework\TestCase;

final class TechnologyDetectorClientTest extends TestCase
{
    public function testRetriesRateLimitAndReturnsDecodedPayload(): void
    {
        $transport = new class implements Transport {
            public int $calls = 0;

            public function post(
                string $url,
                array $headers,
                string $body,
                int $connectTimeout,
                int $responseTimeout
            ): HttpResponse {
                $this->calls++;

                if ($this->calls === 1) {
                    return new HttpResponse(429, '{}', ['retry-after' => '1']);
                }

                self::assertSame(['url' => 'https://client.example'], json_decode($body, true));
                self::assertContains('Authorization: Bearer test-token', $headers);
                self::assertSame(3, $connectTimeout);
                self::assertSame(15, $responseTimeout);

                return new HttpResponse(200, json_encode([
                    'detections' => [[
                        'name' => 'Example CMS',
                        'confidence' => 95,
                        'evidence' => ['header'],
                        'versions' => ['8.3'],
                    ]],
                    'redirects' => [],
                ], JSON_THROW_ON_ERROR));
            }
        };

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

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

        self::assertSame(2, $transport->calls);
        self::assertSame([1_000_000], $delays);
        self::assertSame('Example CMS', $result['detections'][0]['name']);
    }
}

Run composer test. Add companion tests for immediate 401 failure, exhausted 503 retries, invalid JSON, malformed detections, the first-run baseline, and a changed version. These cases protect the behavior most likely to produce missed or misleading alerts.

Deploy securely and schedule the monitor

Create the state directory outside the web root and restrict it to the operating-system account running the job. Do not log the token, authorization headers, complete API responses, or response bodies from failures. The command logs a URL hash to syslog because even a public client hostname may be sensitive operational information.

A cron entry can load a root-readable environment file and email command output:

[email protected]
17 * * * * set -a; . /etc/client-stack-monitor.env; set +a; cd /opt/client-stack-monitor && /usr/bin/php bin/check-stack.php

Choose a frequency that fits the activated plan’s quota. Exit code 0 means success or baseline creation, 10 means a detected change, and 20 means at least one check failed. Cron email provides the notification, while those exit codes also integrate cleanly with a scheduler or monitoring system.

Common production failures

  • 401 or 403: verify the injected token and whether it was regenerated. The client deliberately does not retry authentication failures.
  • 400 or 422: inspect the configured URL and request contract. Validation failures are not transient.
  • 429: reduce scheduling frequency or review the active plan. Retries are bounded and respect a short numeric Retry-After.
  • Repeated 5xx or timeouts: keep the last baseline, alert on failure, and let the next scheduled run try again.
  • Unexpected protocol failure: compare the official response documentation with DetectionSnapshot::fromApi(); never weaken validation merely to suppress the error.
  • No cron email: confirm that the host has a working mail transport, or route nonzero exits and standard output through the monitoring facility already used by the server.

Final verification checklist

  • The service plan is active and the service-scoped token is injected from the environment.
  • The minimal POST request succeeds against the exact detection endpoint.
  • composer test passes without network access.
  • The first command run creates state but sends no change alert.
  • A controlled snapshot modification produces exit code 10 and visible before-and-after data.
  • An invalid token produces one authentication failure without retries or state loss.
  • The state directory and environment file are readable only by the deployment account.
  • Syslog receives URL-hashed success and failure events, without credentials.
  • The scheduler frequency stays within the selected plan’s limits.

A dependable stack monitor is less about sending frequent requests than preserving trustworthy state. By validating external data, distinguishing permanent failures from transient ones, suppressing evidence noise, and refusing to overwrite a good baseline after an error, this small PHP service turns a public website change into a useful engineering signal. The next time a client quietly replaces a framework, CMS, or redirect path, the discovery arrives as an actionable alert instead of an unpleasant surprise.

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.