Vodiči

Symfony: Detect Client Tech Stack Shifts and Get Notified Instantly

Symfony: Otkrivajte promjene u tehnološkom paketu klijenta i odmah primajte obavijesti

A client’s website can change underneath you without warning. A redesign may replace the CMS, a hosting migration may introduce a new CDN, or an optimization plugin may quietly add infrastructure you now need to support. Waiting until something breaks turns a detectable change into an avoidable incident.

This tutorial builds a production-oriented Symfony watcher that polls an important public website, records its detected technology stack, and emails a developer when that stack changes. It uses the Website Technology Detector API as the detection boundary, Symfony’s HTTP client for controlled requests, a small domain model for defensive parsing, and an atomic local snapshot for comparison.

The design deliberately stays modest. A scheduled command is easier to deploy and operate than a queue for one or several websites. “Instant” means the next polling interval; a five-minute schedule is often a sensible starting point, subject to the selected plan’s quota.

Get access and copy the service 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 the service-scoped token.

This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use a Bearer token so the credential remains out of the URL and its associated access logs. Regenerating the service token revokes the previously active token, so rotate the application configuration immediately after regeneration.

Confirm the endpoint before writing Symfony code

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 using a placeholder rather than placing the real token in shell history shared with anyone else:

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

The response contains confidence-scored detections, evidence, versions, and redirect information. Save a sample response privately while integrating, but never commit it if its evidence contains client-specific details.

Create the Symfony project configuration

This implementation targets PHP 8.3 or newer and an existing Symfony application with Console and dependency injection available. Install the first-party HTTP client, mailer, and lock components, plus the Symfony test pack:

composer require symfony/http-client symfony/mailer symfony/lock
composer require --dev symfony/test-pack
mkdir -p var/technology-watch

Keep local values in .env.local, which should not be committed. Production should inject the same variables through the hosting platform or secret manager:

WEBSITE_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
CLIENT_SITE_URL=https://client.example
[email protected]
[email protected]
MAILER_DSN=smtp://user:[email protected]:587

The project has four responsibilities: DetectorClient owns the remote API contract, DetectorResult validates and normalizes the response, SnapshotStore persists the last observation, and WatchTechnologyCommand coordinates comparison and notification.

Map the remote response at the boundary

Remote JSON must not leak through the application as an unvalidated array. The following model requires every detection to have a name, numeric confidence, evidence, and an array of versions. It also validates redirect entries instead of assuming every response is complete and correctly typed.

<?php
// src/Technology/DetectorResult.php
namespace App\Technology;

final readonly class Detection
{
    public function __construct(
        public string $name,
        public float $confidence,
        public array $versions,
        public array|string $evidence,
    ) {}
}

final readonly class DetectorResult
{
    /** @param list<Detection> $detections @param list<string> $redirects */
    public function __construct(
        public array $detections,
        public array $redirects,
    ) {}

    public static function fromPayload(array $payload): self
    {
        if (!isset($payload['detections']) || !is_array($payload['detections'])) {
            throw new \UnexpectedValueException('Missing detections array.');
        }

        $detections = [];
        foreach ($payload['detections'] as $row) {
            if (!is_array($row)
                || !is_string($row['name'] ?? null)
                || $row['name'] === ''
                || !is_numeric($row['confidence'] ?? null)
                || !is_array($row['versions'] ?? null)
                || (!is_array($row['evidence'] ?? null)
                    && !is_string($row['evidence'] ?? null))
            ) {
                throw new \UnexpectedValueException('Malformed detection.');
            }

            $versions = array_values(array_filter(
                $row['versions'],
                static fn (mixed $v): bool => is_string($v) && $v !== ''
            ));

            $detections[] = new Detection(
                $row['name'],
                (float) $row['confidence'],
                $versions,
                $row['evidence'],
            );
        }

        $redirects = $payload['redirects'] ?? [];
        if (!is_array($redirects)) {
            throw new \UnexpectedValueException('Malformed redirect information.');
        }

        $redirects = array_values(array_filter(
            $redirects,
            static fn (mixed $url): bool => is_string($url) && $url !== ''
        ));

        return new self($detections, $redirects);
    }

    public function fingerprint(): string
    {
        $stable = array_map(
            static fn (Detection $d): array => [
                'name' => $d->name,
                'versions' => $d->versions,
            ],
            $this->detections,
        );

        usort($stable, static fn (array $a, array $b): int =>
            [$a['name'], $a['versions']] <=> [$b['name'], $b['versions']]
        );

        return hash('sha256', json_encode(
            ['detections' => $stable, 'redirects' => $this->redirects],
            JSON_THROW_ON_ERROR
        ));
    }
}

The fingerprint intentionally excludes confidence and evidence. Those may become more precise without representing a real stack migration. Technology names, reported versions, and redirect paths are the change signals.

Build a bounded, retry-aware API client

The HTTP client permits three attempts for transport failures, rate limiting, and server failures. Authentication and validation failures are terminal: retrying the same token or payload only consumes time and quota.

<?php
// src/Technology/DetectorClient.php
namespace App\Technology;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class DetectorException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status = null,
        ?\Throwable $previous = null,
    ) {
        parent::__construct("Technology detection failed: {$kind}", 0, $previous);
    }
}

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

    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $detectorToken,
    ) {}

    public function detect(string $url): DetectorResult
    {
        for ($attempt = 1; $attempt <= 3; ++$attempt) {
            try {
                $response = $this->http->request('POST', self::ENDPOINT, [
                    'auth_bearer' => $this->detectorToken,
                    'json' => ['url' => $url],
                    'timeout' => 10.0,
                    'max_duration' => 20.0,
                ]);

                $status = $response->getStatusCode();

                if ($status >= 200 && $status < 300) {
                    return DetectorResult::fromPayload($response->toArray(false));
                }

                if (in_array($status, [400, 401, 403, 422], true)) {
                    $kind = in_array($status, [401, 403], true)
                        ? 'authentication'
                        : 'request_rejected';
                    throw new DetectorException($kind, $status);
                }

                if (($status === 429 || $status >= 500) && $attempt < 3) {
                    $headers = $response->getHeaders(false);
                    $retryAfter = $headers['retry-after'][0] ?? null;
                    $seconds = ctype_digit((string) $retryAfter)
                        ? min(5, max(1, (int) $retryAfter))
                        : 1 << ($attempt - 1);

                    $this->logger->warning('Detector request will be retried.', [
                        'status' => $status,
                        'attempt' => $attempt,
                        'delay_seconds' => $seconds,
                    ]);
                    usleep($seconds * 1_000_000);
                    continue;
                }

                throw new DetectorException(
                    $status === 429 ? 'rate_limited' : 'remote_error',
                    $status
                );
            } catch (DecodingExceptionInterface|\UnexpectedValueException $e) {
                throw new DetectorException('invalid_response', null, $e);
            } catch (TransportExceptionInterface $e) {
                if ($attempt === 3) {
                    throw new DetectorException('transport', null, $e);
                }

                usleep((1 << ($attempt - 1)) * 1_000_000);
            }
        }

        throw new DetectorException('exhausted');
    }
}

The idle timeout bounds stalled connection or response activity, while max_duration caps the complete request. The rate-limit delay honors a simple numeric Retry-After value but caps it at five seconds, keeping a scheduled process bounded.

Persist an atomic baseline

A database would be unnecessary for a single watcher. Store one JSON file per URL and replace it atomically. The web server or scheduler user needs write access to var/technology-watch.

<?php
// src/Technology/SnapshotStore.php
namespace App\Technology;

final readonly class SnapshotStore
{
    public function __construct(private string $directory) {}

    public function load(string $url): ?array
    {
        $path = $this->path($url);
        if (!is_file($path)) {
            return null;
        }

        $data = json_decode(
            (string) file_get_contents($path),
            true,
            512,
            JSON_THROW_ON_ERROR
        );

        if (!is_array($data) || !is_string($data['fingerprint'] ?? null)) {
            throw new \RuntimeException('Stored snapshot is invalid.');
        }

        return $data;
    }

    public function save(string $url, DetectorResult $result): void
    {
        if (!is_dir($this->directory)
            && !mkdir($this->directory, 0770, true)
            && !is_dir($this->directory)
        ) {
            throw new \RuntimeException('Cannot create snapshot directory.');
        }

        $path = $this->path($url);
        $temporary = $path.'.tmp';

        $payload = json_encode([
            'url' => $url,
            'observed_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
            'fingerprint' => $result->fingerprint(),
            'detections' => array_map(
                static fn (Detection $d): array => [
                    'name' => $d->name,
                    'confidence' => $d->confidence,
                    'versions' => $d->versions,
                    'evidence' => $d->evidence,
                ],
                $result->detections
            ),
            'redirects' => $result->redirects,
        ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);

        if (file_put_contents($temporary, $payload, LOCK_EX) === false
            || !rename($temporary, $path)
        ) {
            throw new \RuntimeException('Cannot write snapshot.');
        }
    }

    private function path(string $url): string
    {
        return $this->directory.'/'.hash('sha256', $url).'.json';
    }
}

Coordinate detection and notification

The first successful run establishes a baseline without raising a false alarm. Later changes are emailed before the snapshot is updated. If mail delivery fails, the old state remains and the next run tries again. This gives at-least-once notification semantics, so a crash after sending but before saving can produce a duplicate message.

<?php
// src/Command/WatchTechnologyCommand.php
namespace App\Command;

use App\Technology\DetectorClient;
use App\Technology\DetectorException;
use App\Technology\SnapshotStore;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

#[AsCommand(
    name: 'app:watch-technology',
    description: 'Detect and report changes to a client website technology stack.'
)]
final class WatchTechnologyCommand extends Command
{
    public function __construct(
        private readonly DetectorClient $detector,
        private readonly SnapshotStore $store,
        private readonly LockFactory $locks,
        private readonly MailerInterface $mailer,
        private readonly string $clientSiteUrl,
        private readonly string $alertFrom,
        private readonly string $alertTo,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $lock = $this->locks->createLock(
            'technology-watch-'.hash('sha256', $this->clientSiteUrl),
            60
        );

        if (!$lock->acquire()) {
            $output->writeln('Another detection is already running.');
            return Command::SUCCESS;
        }

        try {
            $current = $this->detector->detect($this->clientSiteUrl);
            $previous = $this->store->load($this->clientSiteUrl);

            if ($previous === null) {
                $this->store->save($this->clientSiteUrl, $current);
                $output->writeln('Baseline created; no alert sent.');
                return Command::SUCCESS;
            }

            if (hash_equals($previous['fingerprint'], $current->fingerprint())) {
                $this->store->save($this->clientSiteUrl, $current);
                $output->writeln('No technology change detected.');
                return Command::SUCCESS;
            }

            $summary = array_map(
                static fn ($d): string => sprintf(
                    '%s [%s], confidence %s',
                    $d->name,
                    implode(', ', $d->versions) ?: 'version unavailable',
                    $d->confidence
                ),
                $current->detections
            );

            $this->mailer->send(
                (new Email())
                    ->from($this->alertFrom)
                    ->to($this->alertTo)
                    ->subject('Client website technology stack changed')
                    ->text(
                        "A public stack change was detected for "
                        .$this->clientSiteUrl.".\n\n"
                        .implode("\n", $summary)
                        ."\n\nRedirects:\n"
                        .implode("\n", $current->redirects)
                    )
            );

            $this->store->save($this->clientSiteUrl, $current);
            $output->writeln('Change detected and notification sent.');
            return Command::SUCCESS;
        } catch (DetectorException $e) {
            $output->writeln(sprintf(
                'Detection failed: %s%s',
                $e->kind,
                $e->status === null ? '' : " (HTTP {$e->status})"
            ));
            return Command::FAILURE;
        } finally {
            $lock->release();
        }
    }
}

Wire environment values into services

# config/services.yaml
services:
  _defaults:
    autowire: true
    autoconfigure: true
    bind:
      $detectorToken: '%env(string:WEBSITE_DETECTOR_TOKEN)%'
      $clientSiteUrl: '%env(string:CLIENT_SITE_URL)%'
      $alertFrom: '%env(string:TECH_ALERT_FROM)%'
      $alertTo: '%env(string:TECH_ALERT_TO)%'

  App\:
    resource: '../src/'

  App\Technology\SnapshotStore:
    arguments:
      $directory: '%kernel.project_dir%/var/technology-watch'

Test the API boundary without network access

MockHttpClient makes the external interaction deterministic. Test both a valid mapping and rejection of malformed data; command tests can separately mock the client, store, and mailer to cover baseline, unchanged, changed, and mail-failure paths.

<?php
// tests/Technology/DetectorClientTest.php
namespace App\Tests\Technology;

use App\Technology\DetectorClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class DetectorClientTest extends TestCase
{
    public function testMapsSuccessfulDetection(): void
    {
        $response = new MockResponse(json_encode([
            'detections' => [[
                'name' => 'Symfony',
                'confidence' => 0.98,
                'versions' => ['7'],
                'evidence' => ['public-signal'],
            ]],
            'redirects' => [],
        ], JSON_THROW_ON_ERROR), ['http_code' => 200]);

        $client = new DetectorClient(
            new MockHttpClient([$response]),
            new NullLogger(),
            'test-token'
        );

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

        self::assertSame('Symfony', $result->detections[0]->name);
        self::assertSame(['7'], $result->detections[0]->versions);
        self::assertSame([], $result->redirects);
    }

    public function testRejectsMalformedResponse(): void
    {
        $response = new MockResponse(
            '{"detections":[{"name":"Symfony"}]}',
            ['http_code' => 200]
        );

        $client = new DetectorClient(
            new MockHttpClient([$response]),
            new NullLogger(),
            'test-token'
        );

        $this->expectException(\App\Technology\DetectorException::class);
        $client->detect('https://client.example');
    }
}
php bin/phpunit
php bin/console app:watch-technology -vv

Deploy and operate the watcher

Run the command under the same Unix identity on every invocation so it can read and replace its snapshot. With multiple application instances, local files and a local lock are insufficient; either pin the scheduled task to one instance or replace both with shared storage and a shared Symfony Lock store.

A five-minute cron entry is straightforward:

*/5 * * * * cd /srv/site-watch && APP_ENV=prod php bin/console app:watch-technology --no-interaction >> var/log/technology-watch-cron.log 2>&1

Monitor nonzero exits and repeated rate_limited, transport, or invalid_response failures. Logs should contain the URL, status, attempt, and failure category, but never the token or full authorization headers. Choose a polling interval that respects the activated plan’s quota.

Restrict CLIENT_SITE_URL to a reviewed public client URL. Do not turn this command into an unrestricted user-supplied URL proxy. Protect environment variables, keep snapshot files outside the public document root, and ensure mail credentials are managed like the detector token.

Common failure modes

  • HTTP 401 or 403: verify the service token and whether someone regenerated it, thereby revoking the previous token.
  • HTTP 400 or 422: check that the JSON contains a valid public url; changing retries will not repair the request.
  • HTTP 429: reduce polling frequency or review the active plan. The client retries briefly, then returns a structured failure.
  • Invalid response: preserve the existing baseline and investigate before changing the mapper. Never overwrite known-good state with unvalidated data.
  • No email: run the command interactively, verify MAILER_DSN, and test mail delivery independently. A failed send intentionally leaves the previous snapshot untouched.
  • Repeated alerts: confirm that the snapshot directory is persistent and writable across deployments and container restarts.

Final verification checklist

  1. The service plan is active and the current service-scoped token is available only through environment configuration.
  2. The minimal POST request succeeds against the exact detection endpoint.
  3. php bin/phpunit passes without making a real network request.
  4. The first command run creates a baseline and sends no alert.
  5. An unchanged response updates observation data without sending mail.
  6. A changed technology, version, or redirect fingerprint sends mail before committing the new snapshot.
  7. Authentication, validation, quota, transport, malformed-response, and mail failures preserve the previous baseline.
  8. The scheduler reports nonzero exits, avoids overlapping runs, and operates within plan limits.

A useful watcher is not merely an API call on a timer. Its value comes from treating the remote response as untrusted, distinguishing retryable failures from permanent ones, and preserving state until notification succeeds. With those boundaries in place, a quiet public technology change becomes a timely, actionable signal instead of tomorrow’s debugging surprise.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.