Vodiči

Laravel: Monitor Client Tech Stacks with AI and Get Notified of Changes

Laravel: Pratite tehnološke sklopove klijenata pomoću AI-ja i primajte obavijesti o promjenama

A client’s public website can change without a deployment reaching your repository. A redesign may introduce a new analytics provider, a hosting migration may replace the CDN, or an agency may quietly swap the CMS. These changes are visible from the outside, but they are easy to miss until they affect performance, security, or support.

This tutorial builds a production Laravel monitor that checks one important client website on a schedule, records a stable baseline, and emails a developer when the detected technology set or version information changes. The detector supplies confidence scores and evidence; Laravel supplies scheduling, queues, persistence, notifications, testing, and operational controls.

Get access to the detector

Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.

  1. Open the Website Technology Detector service page.
  2. Choose the available Free, Plus, or Pro plan and complete its activation.
  3. Open the official service documentation.
  4. 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. The implementation below uses the Bearer form because Laravel supports it directly and it avoids placing credentials in URLs. Regenerating the service token revokes the previously active token, so coordinate rotation with deployment and restart long-running queue workers afterward.

The exact request is POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Before writing application code, verify the token with one minimal request:

curl --request POST \
  '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://client.example"}'

Store the credential and monitor settings in .env, never in committed source code:

TECH_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
TECH_MONITOR_URL=https://client.example
[email protected]
TECH_MONITOR_MIN_CONFIDENCE=0.60

Add environment-backed configuration to config/services.php:

'technology_detector' => [
    'base_url' => 'https://ai.mihajlo.mk/api/website-technology-detector',
    'token' => env('TECH_DETECTOR_TOKEN'),
    'url' => env('TECH_MONITOR_URL'),
    'email' => env('TECH_MONITOR_EMAIL'),
    'minimum_confidence' => (float) env('TECH_MONITOR_MIN_CONFIDENCE', 0.60),
],

Choose a quiet, reliable architecture

The scheduled task dispatches a queue job. That job obtains a distributed lock, calls the detector, maps the untrusted response into a domain object, and compares a canonical fingerprint with the stored snapshot. The first successful check establishes a baseline and deliberately sends no email.

Only technology names and versions above the configured confidence threshold affect the fingerprint. Confidence and evidence remain available for diagnosis, but excluding them from comparison prevents notifications caused solely by small scoring or evidence changes.

The project adds these focused components:

  • app/Services/TechnologyDetector.php for the HTTP boundary
  • app/Domain/TechnologyReport.php for validation and normalization
  • app/Jobs/CheckClientStack.php for comparison and orchestration
  • app/Notifications/TechnologyStackChanged.php for email delivery
  • app/Models/TechnologySnapshot.php and one migration for state

Map the response at the boundary

Remote JSON must never flow directly into comparison logic. The following mapper requires a detections collection, validates individual records, clamps numeric confidence values, and treats versions, evidence, and redirect information as optional. If the documented response envelope changes, this is the only class that needs adapting.

<?php

namespace App\Domain;

use UnexpectedValueException;

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

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

        if (! is_array($items)) {
            throw new UnexpectedValueException(
                'Detector response is missing a detections array.'
            );
        }

        $detections = [];

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

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

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

            $versions = $item['versions'] ?? [];
            if (isset($item['version']) && is_string($item['version'])) {
                $versions = [$item['version']];
            }

            $versions = is_array($versions)
                ? array_values(array_map('strval', array_filter($versions, 'is_scalar')))
                : [];

            $evidence = is_array($item['evidence'] ?? null)
                ? array_values(array_map(
                    'strval',
                    array_filter($item['evidence'], 'is_scalar')
                ))
                : [];

            sort($versions);

            $detections[] = [
                'technology' => $name,
                'confidence' => max(0.0, min(1.0, (float) $confidence)),
                'versions' => array_values(array_unique($versions)),
                'evidence' => $evidence,
            ];
        }

        $redirects = $payload['redirects'] ?? $payload['redirect'] ?? [];

        return new self(
            $detections,
            is_array($redirects) ? $redirects : [],
        );
    }

    public function signature(float $minimumConfidence): array
    {
        $signature = array_map(
            fn (array $item) => [
                'technology' => $item['technology'],
                'versions' => $item['versions'],
            ],
            array_filter(
                $this->detections,
                fn (array $item) => $item['confidence'] >= $minimumConfidence
            )
        );

        usort(
            $signature,
            fn (array $a, array $b) =>
                [$a['technology'], $a['versions']]
                <=> [$b['technology'], $b['versions']]
        );

        return array_values($signature);
    }

    public function fingerprint(float $minimumConfidence): string
    {
        return hash(
            'sha256',
            json_encode($this->signature($minimumConfidence), JSON_THROW_ON_ERROR)
        );
    }
}

The aliases for a technology label and singular or plural version and redirect data are intentionally confined to this defensive adapter. Compare them with the current sample in the official documentation when integrating, and adjust the adapter rather than weakening validation elsewhere.

Build a bounded HTTP client

Laravel’s built-in HTTP client provides the required JSON and authentication behavior. Connection and total-response timeouts stop workers from hanging. The service retries connection failures and server errors once with a short backoff, but never blindly retries authentication, validation, or other client errors.

<?php

namespace App\Services;

use App\Domain\TechnologyReport;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use RuntimeException;

final class TechnologyDetector
{
    public function detect(string $url): TechnologyReport
    {
        $this->validateUrl($url);

        for ($attempt = 1; $attempt <= 2; $attempt++) {
            try {
                $response = Http::baseUrl(
                    (string) config('services.technology_detector.base_url')
                )
                    ->withToken(
                        (string) config('services.technology_detector.token')
                    )
                    ->acceptJson()
                    ->asJson()
                    ->connectTimeout(3)
                    ->timeout(12)
                    ->post('/v1/detect-technologies', ['url' => $url]);
            } catch (ConnectionException $exception) {
                if ($attempt === 2) {
                    throw new DetectorTransientException(
                        'Detector connection failed.',
                        previous: $exception
                    );
                }

                usleep(250_000);
                continue;
            }

            if ($response->successful()) {
                return TechnologyReport::fromApi($response->json());
            }

            if ($response->status() === 429) {
                $retryAfter = (int) ($response->header('Retry-After') ?? 300);

                throw new DetectorRateLimitedException(
                    max(60, min(900, $retryAfter))
                );
            }

            if ($response->serverError() && $attempt === 1) {
                usleep(250_000);
                continue;
            }

            if ($response->serverError()) {
                throw new DetectorTransientException(
                    'Detector returned status '.$response->status().'.'
                );
            }

            throw new DetectorPermanentException(
                'Detector rejected the request with status '.$response->status().'.'
            );
        }

        throw new DetectorTransientException('Detector attempts exhausted.');
    }

    private function validateUrl(string $url): void
    {
        $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
        $host = parse_url($url, PHP_URL_HOST);

        if (! filter_var($url, FILTER_VALIDATE_URL)
            || ! in_array($scheme, ['http', 'https'], true)
            || ! is_string($host)
            || $host === '') {
            throw new DetectorPermanentException('Monitor URL is invalid.');
        }
    }
}

class DetectorPermanentException extends RuntimeException {}
class DetectorTransientException extends RuntimeException {}

final class DetectorRateLimitedException extends RuntimeException
{
    public function __construct(public readonly int $retryAfter)
    {
        parent::__construct('Detector rate limit reached.');
    }
}

The request body contains only the required url. Exception messages intentionally omit the token and response body. This prevents credentials or unexpected upstream content from leaking into logs.

Persist one canonical snapshot

Create a migration and model. A hash supplies a fixed-size unique key even when URLs are long.

<?php
// database/migrations/xxxx_xx_xx_create_technology_snapshots_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('technology_snapshots', function (Blueprint $table) {
            $table->id();
            $table->char('url_hash', 64)->unique();
            $table->text('url');
            $table->char('fingerprint', 64);
            $table->json('report');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('technology_snapshots');
    }
};

// app/Models/TechnologySnapshot.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class TechnologySnapshot extends Model
{
    protected $fillable = [
        'url_hash', 'url', 'fingerprint', 'report',
    ];

    protected function casts(): array
    {
        return ['report' => 'array'];
    }
}

Compare, update, and notify

The job uses an atomic cache lock so overlapping scheduler runs cannot produce duplicate alerts. Configure a shared cache such as Redis or the database cache when more than one application instance can execute jobs.

<?php
// app/Jobs/CheckClientStack.php

namespace App\Jobs;

use App\Models\TechnologySnapshot;
use App\Notifications\TechnologyStackChanged;
use App\Services\DetectorPermanentException;
use App\Services\DetectorRateLimitedException;
use App\Services\TechnologyDetector;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Throwable;

final class CheckClientStack implements ShouldQueue
{
    use Queueable;

    public int $tries = 4;
    public array $backoff = [60, 300, 900];

    public function __construct(public readonly string $url)
    {
        $this->onQueue('monitoring');
    }

    public function handle(TechnologyDetector $detector): void
    {
        $lock = Cache::lock('tech-scan:'.hash('sha256', $this->url), 300);

        if (! $lock->get()) {
            return;
        }

        try {
            $report = $detector->detect($this->url);
            $minimum = (float) config(
                'services.technology_detector.minimum_confidence'
            );
            $signature = $report->signature($minimum);
            $fingerprint = $report->fingerprint($minimum);
            $urlHash = hash('sha256', $this->url);

            $snapshot = TechnologySnapshot::firstOrNew([
                'url_hash' => $urlHash,
            ]);

            if (! $snapshot->exists) {
                $snapshot->fill([
                    'url' => $this->url,
                    'fingerprint' => $fingerprint,
                    'report' => $signature,
                ])->save();

                Log::info('Technology monitor baseline created.', [
                    'target' => $urlHash,
                ]);

                return;
            }

            if (hash_equals($snapshot->fingerprint, $fingerprint)) {
                $snapshot->touch();
                return;
            }

            $old = array_map('json_encode', $snapshot->report);
            $new = array_map('json_encode', $signature);
            $added = array_map('json_decode', array_values(array_diff($new, $old)));
            $removed = array_map('json_decode', array_values(array_diff($old, $new)));

            $snapshot->update([
                'fingerprint' => $fingerprint,
                'report' => $signature,
            ]);

            Notification::route(
                'mail',
                (string) config('services.technology_detector.email')
            )->notify(new TechnologyStackChanged(
                $this->url,
                $added,
                $removed
            ));

            Log::notice('Technology stack change detected.', [
                'target' => $urlHash,
                'added_count' => count($added),
                'removed_count' => count($removed),
            ]);
        } catch (DetectorRateLimitedException $exception) {
            $this->release($exception->retryAfter);
        } catch (DetectorPermanentException $exception) {
            Log::error('Technology monitor request rejected.', [
                'target' => hash('sha256', $this->url),
                'message' => $exception->getMessage(),
            ]);
        } finally {
            $lock->release();
        }
    }

    public function failed(?Throwable $exception): void
    {
        Log::error('Technology monitor exhausted queue attempts.', [
            'target' => hash('sha256', $this->url),
            'exception' => $exception?->getMessage(),
        ]);
    }
}

The permanent-error branch prevents repeated calls for invalid URLs, rejected credentials, and validation failures. Rate limits honor a bounded Retry-After value by releasing the job. Connection and server failures escape as transient exceptions, allowing the queue’s spaced retry policy to take over.

Create the mail notification:

<?php
// app/Notifications/TechnologyStackChanged.php

namespace App\Notifications;

use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

final class TechnologyStackChanged extends Notification
{
    public function __construct(
        private readonly string $url,
        private readonly array $added,
        private readonly array $removed,
    ) {}

    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        $mail = (new MailMessage)
            ->subject('Client website technology stack changed')
            ->line('A public technology change was detected for '.$this->url.'.');

        foreach ($this->added as $item) {
            $mail->line('Added: '.$this->label($item));
        }

        foreach ($this->removed as $item) {
            $mail->line('Removed: '.$this->label($item));
        }

        return $mail->line(
            'Review the website and confirm whether the change was expected.'
        );
    }

    private function label(object $item): string
    {
        $versions = $item->versions ?? [];

        return $item->technology
            .($versions === [] ? '' : ' '.implode(', ', $versions));
    }
}

Schedule the monitor

Add the schedule to routes/console.php. The queue keeps the external request out of the scheduler process, while the lock inside the job provides the final concurrency guard.

<?php

use App\Jobs\CheckClientStack;
use Illuminate\Support\Facades\Schedule;

Schedule::job(
    new CheckClientStack(
        (string) config('services.technology_detector.url')
    )
)
    ->hourly()
    ->onOneServer()
    ->withoutOverlapping(30);

onOneServer() requires all application instances to use the same supported central cache. Validate that the URL and notification email are non-empty during deployment rather than allowing an empty configuration to reach the queue.

Test changes without calling the service

Http::fake() makes the integration deterministic. The main test proves that the first response creates a quiet baseline and the second produces an on-demand notification.

<?php

namespace Tests\Feature;

use App\Jobs\CheckClientStack;
use App\Notifications\TechnologyStackChanged;
use App\Services\TechnologyDetector;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

final class CheckClientStackTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_notifies_only_after_the_baseline_changes(): void
    {
        Notification::fake();

        Http::fakeSequence()
            ->push(['detections' => [[
                'technology' => 'Example CMS',
                'confidence' => 0.98,
                'versions' => ['1.0'],
                'evidence' => ['public marker'],
            ]]], 200)
            ->push(['detections' => [[
                'technology' => 'Example CMS',
                'confidence' => 0.99,
                'versions' => ['2.0'],
                'evidence' => ['public marker'],
            ]]], 200);

        $detector = app(TechnologyDetector::class);
        $job = new CheckClientStack('https://client.example');

        $job->handle($detector);
        Notification::assertNothingSent();

        $job->handle($detector);
        Notification::assertSentOnDemand(TechnologyStackChanged::class);

        Http::assertSentCount(2);
    }

    public function test_authentication_failure_is_not_retried(): void
    {
        Http::fake([
            '*' => Http::response(['message' => 'Unauthorized'], 401),
        ]);

        $this->expectException(
            \App\Services\DetectorPermanentException::class
        );

        try {
            app(TechnologyDetector::class)
                ->detect('https://client.example');
        } finally {
            Http::assertSentCount(1);
        }
    }
}

The fixtures exercise the boundary shape rather than reproducing undocumented fields. Add tests for malformed JSON structure, low-confidence detections, redirects, connection exceptions, server errors, and rate limits before expanding the monitor to many sites.

Deploy and operate it safely

Run the migration, cache production configuration, and start a dedicated queue worker:

php artisan migrate --force
php artisan config:cache
php artisan queue:work --queue=monitoring --sleep=3 --tries=4 --max-time=3600

Run php artisan schedule:run every minute through cron or the platform scheduler. Configure Laravel mail delivery, use a durable queue, and monitor failed jobs. After changing or regenerating the token, rebuild the configuration cache and restart workers with php artisan queue:restart.

Restrict who can edit the monitored URL. Although this design reads it from trusted deployment configuration, turning it into arbitrary user input would require explicit URL policy and authorization. Keep tokens out of exception context, notification text, test fixtures, database rows, and logs.

Common production failures

  • Every request returns 401 or 403: confirm that the service-scoped token is active and that a regenerated token has been deployed to every worker.
  • Jobs run but no email arrives: verify Laravel’s mail transport and TECH_MONITOR_EMAIL, then restart workers after configuration changes.
  • Duplicate notifications appear: ensure all nodes share the same atomic cache and database, and confirm that only one scheduler topology is active.
  • Changes are noisy: raise the confidence threshold and continue excluding evidence and confidence values from the fingerprint.
  • No changes are ever reported: inspect the normalized snapshot, verify the response mapper against the official documentation, and confirm that the worker consumes the monitoring queue.

Final verification checklist

  • The exact endpoint succeeds with the deployed service token.
  • The first queued check creates a baseline without notifying anyone.
  • A controlled fake response change sends one email with added and removed technologies.
  • Authentication and validation failures make only one HTTP attempt.
  • Server failures retry with bounded backoff, while rate limits delay the job.
  • Tokens never appear in source control, logs, stored reports, or messages.
  • The scheduler, queue worker, shared cache, mail transport, and failed-job monitoring are running.

A useful technology monitor should be intentionally uneventful. Most checks should end with a matching fingerprint and a quiet log trail. When a meaningful public stack change finally appears, the developer receives a concise signal backed by normalized versions, confidence-aware filtering, and retained evidence—not another noisy alert that everyone learns to ignore.

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.