Laravel: Detect Client Website Tech Stack Shifts with AI and Stay Ahead
A client’s website can change underneath you without a deployment from your team. A redesign may replace the CMS, a hosting migration may introduce a new CDN, or an agency handoff may quietly remove analytics, security, or framework components you rely on.
This tutorial builds a Laravel monitor that periodically calls a deterministic Website Technology Detector API, stores a normalized snapshot, and emails a developer when that public technology stack changes. The first successful inspection establishes a baseline; later inspections alert only when the resulting fingerprint differs.
The design deliberately favors a scheduled command over controllers and queues. For a modest collection of important client sites, sequential checks are easier to operate, cannot arrive out of order, and need fewer moving parts. Bounded HTTP calls keep the scheduler predictable.
Get API access before writing integration code
Register through the registration page, or use the sign-in page if you already have an account. Open the Website Technology Detector service page, choose an available Free, Plus, or Pro plan, and complete its activation.
Next, open the official service documentation. Find the Service token panel and copy its service-scoped token. Regenerating this token revokes the previously active token, so treat rotation as a coordinated deployment rather than an incidental dashboard action.
This service is not tokenless. Every request must authenticate with a Bearer token, an X-API-Token header, or a token query parameter. The implementation below uses the Bearer form because it keeps credentials out of URLs and access logs.
The exact operation is POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Test the credential with a public site you are authorized to monitor:
curl -sS --fail-with-body \
--connect-timeout 5 \
--max-time 20 \
-X POST \
"https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies" \
-H "Authorization: Bearer YOUR_SERVICE_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
--data '{"url":"https://client.example"}'
The response contains confidence-scored detections and associated evidence, versions, and redirect information. Those details should be treated as service data, not reconstructed from guesses in application code.
After the request succeeds, place the credential in the Laravel project’s uncommitted .env file:
TECH_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
[email protected]
MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=YOUR_MAIL_USERNAME
MAIL_PASSWORD=YOUR_MAIL_PASSWORD
MAIL_ENCRYPTION=tls
[email protected]
MAIL_FROM_NAME="Stack Watch"
Prerequisites and project shape
You need PHP 8.3 or newer, Composer, a Laravel application, a supported database, and a working Laravel mail transport. Create a fresh application if necessary, then generate the main framework artifacts:
composer create-project laravel/laravel stack-watch
cd stack-watch
php artisan make:model MonitoredSite -m
php artisan make:command DetectSiteStacks
php artisan make:notification TechnologyStackChanged
php artisan make:test DetectSiteStacksTest
The resulting feature has four boundaries: a database model owns monitoring state, a service class owns the remote HTTP contract, a domain object canonicalizes successful responses, and a scheduled command coordinates comparison and notification. No public route accepts arbitrary URLs.
Add the service and alert configuration:
<?php
// Add inside the returned array in config/services.php
'technology_detector' => [
'base_url' => 'https://ai.mihajlo.mk/api/website-technology-detector',
'token' => env('TECH_DETECTOR_TOKEN'),
],
<?php
// config/monitoring.php
return [
'developer_email' => env('STACK_ALERT_EMAIL'),
];
Persist the last known stack
The database keeps the complete normalized response rather than projecting undocumented inner response fields. That preserves confidence, evidence, version, and redirect data while allowing the application boundary to reject malformed JSON. It also makes future investigations possible without placing potentially bulky evidence in logs or email.
Define the migration created for monitored_sites:
<?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('monitored_sites', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('url')->unique();
$table->string('last_fingerprint', 64)->nullable();
$table->json('last_report')->nullable();
$table->timestamp('last_checked_at')->nullable();
$table->timestamp('last_error_at')->nullable();
$table->string('last_error')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('monitored_sites');
}
};
Configure the model in app/Models/MonitoredSite.php:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class MonitoredSite extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'last_report' => 'array',
'last_checked_at' => 'datetime',
'last_error_at' => 'datetime',
];
}
}
Canonicalize the domain response
JSON object key order has no semantic meaning, but naïvely hashing the response text would treat reordered keys as a change. The domain mapper recursively sorts associative keys and then hashes the normalized document. List order remains intact because redirect sequences and other ordered evidence may be meaningful.
Create app/Domain/Technology/DetectionReport.php:
<?php
namespace App\Domain\Technology;
use JsonException;
use UnexpectedValueException;
final readonly class DetectionReport
{
private function __construct(public array $document)
{
}
public static function fromPayload(mixed $payload): self
{
if (! is_array($payload) || $payload === []) {
throw new UnexpectedValueException(
'Detector returned an invalid JSON document.'
);
}
$normalized = self::normalize($payload);
try {
json_encode(
$normalized,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
);
} catch (JsonException $exception) {
throw new UnexpectedValueException(
'Detector response could not be normalized.',
previous: $exception
);
}
return new self($normalized);
}
public function fingerprint(): string
{
return hash('sha256', json_encode(
$this->document,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
));
}
private static function normalize(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_map(self::normalize(...), $value);
}
ksort($value, SORT_STRING);
foreach ($value as $key => $item) {
$value[$key] = self::normalize($item);
}
return $value;
}
}
This boundary intentionally avoids asserting field names that are not part of the supplied contract. If the official documentation later identifies volatile metadata that changes on every call, exclude that specific field here only after confirming its semantics.
Build a bounded, retry-aware API client
Create app/Exceptions/DetectorException.php to give callers structured failure states without exposing response bodies or credentials:
<?php
namespace App\Exceptions;
use RuntimeException;
use Throwable;
final class DetectorException extends RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly ?int $status = null,
public readonly ?int $retryAfter = null,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Now create app/Services/WebsiteTechnologyDetector.php. Connection failures, server failures, and quota responses receive bounded retries. Authentication and validation failures do not: repeating an invalid token or URL merely consumes time and may worsen rate pressure.
<?php
namespace App\Services;
use App\Domain\Technology\DetectionReport;
use App\Exceptions\DetectorException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;
final class WebsiteTechnologyDetector
{
public function detect(string $url): DetectionReport
{
$parts = parse_url($url);
if (
$parts === false
|| ! isset($parts['host'], $parts['scheme'])
|| ! in_array($parts['scheme'], ['http', 'https'], true)
|| isset($parts['user'])
|| isset($parts['pass'])
) {
throw new DetectorException(
'validation',
'The monitored URL must be a public HTTP or HTTPS URL.'
);
}
$token = config('services.technology_detector.token');
if (! is_string($token) || $token === '') {
throw new DetectorException(
'configuration',
'Technology detector token is not configured.'
);
}
try {
$response = Http::baseUrl(
config('services.technology_detector.base_url')
)
->withToken($token)
->acceptJson()
->asJson()
->connectTimeout(5)
->timeout(20)
->retry(
3,
function (int $attempt, Throwable $exception): int {
if ($exception instanceof RequestException) {
$header = $exception->response
->header('Retry-After');
if (is_string($header) && ctype_digit($header)) {
return min(5000, ((int) $header) * 1000);
}
}
return min(2000, 250 * (2 ** ($attempt - 1)));
},
function (Throwable $exception): bool {
if ($exception instanceof ConnectionException) {
return true;
}
if (! $exception instanceof RequestException) {
return false;
}
$status = $exception->response->status();
return $status === 429 || $status >= 500;
},
throw: false,
)
->post('/v1/detect-technologies', ['url' => $url]);
} catch (ConnectionException $exception) {
throw new DetectorException(
'network',
'Technology detector connection failed.',
previous: $exception
);
}
$status = $response->status();
if (in_array($status, [401, 403], true)) {
throw new DetectorException(
'authentication',
'Technology detector rejected the service token.',
$status
);
}
if (in_array($status, [400, 422], true)) {
throw new DetectorException(
'validation',
'Technology detector rejected the URL.',
$status
);
}
if ($status === 429) {
$header = $response->header('Retry-After');
throw new DetectorException(
'rate_limit',
'Technology detector quota or rate limit was reached.',
$status,
is_string($header) && ctype_digit($header)
? (int) $header
: null
);
}
if ($response->serverError()) {
throw new DetectorException(
'remote',
'Technology detector is temporarily unavailable.',
$status
);
}
if (! $response->successful()) {
throw new DetectorException(
'remote',
'Technology detector returned an unexpected status.',
$status
);
}
return DetectionReport::fromPayload($response->json());
}
}
The retry is appropriate because detection is observational: it does not modify the client website. The maximum response timeout, connection timeout, retry count, and capped backoff prevent one unhealthy dependency from occupying the scheduler indefinitely.
Notify only after a meaningful comparison
Create app/Notifications/TechnologyStackChanged.php:
<?php
namespace App\Notifications;
use App\Models\MonitoredSite;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
final class TechnologyStackChanged extends Notification
{
public function __construct(
private readonly MonitoredSite $site,
private readonly string $oldFingerprint,
private readonly string $newFingerprint,
) {
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("Technology stack changed: {$this->site->name}")
->line("A public technology change was detected.")
->line("Site: {$this->site->url}")
->line("Previous fingerprint: {$this->oldFingerprint}")
->line("Current fingerprint: {$this->newFingerprint}")
->line('Review the stored reports and confirm whether the change was expected.');
}
}
The scheduled command performs the comparison. It sends the notification before saving the new snapshot. If mail delivery throws an exception, the old fingerprint remains in place and the next run tries again. A crash after successful delivery but before saving can produce a duplicate, which is preferable to silently losing the alert.
Replace app/Console/Commands/DetectSiteStacks.php with:
<?php
namespace App\Console\Commands;
use App\Exceptions\DetectorException;
use App\Models\MonitoredSite;
use App\Notifications\TechnologyStackChanged;
use App\Services\WebsiteTechnologyDetector;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Throwable;
final class DetectSiteStacks extends Command
{
protected $signature = 'sites:detect-stack {--site=* : Limit checks to site IDs}';
protected $description = 'Detect and compare monitored website technology stacks';
public function handle(WebsiteTechnologyDetector $detector): int
{
$email = config('monitoring.developer_email');
if (! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error('STACK_ALERT_EMAIL is not configured correctly.');
return self::FAILURE;
}
$ids = array_values(array_filter(
$this->option('site'),
fn (mixed $id): bool => ctype_digit((string) $id)
));
$failed = false;
MonitoredSite::query()
->when($ids !== [], fn ($query) => $query->whereIn('id', $ids))
->orderBy('id')
->each(function (MonitoredSite $site) use (
$detector,
$email,
&$failed
): void {
try {
$report = $detector->detect($site->url);
$fingerprint = $report->fingerprint();
$changed = $site->last_fingerprint !== null
&& ! hash_equals(
$site->last_fingerprint,
$fingerprint
);
if ($changed) {
Notification::route('mail', $email)->notify(
new TechnologyStackChanged(
$site,
$site->last_fingerprint,
$fingerprint
)
);
}
$site->forceFill([
'last_fingerprint' => $fingerprint,
'last_report' => $report->document,
'last_checked_at' => now(),
'last_error_at' => null,
'last_error' => null,
])->save();
Log::info('Technology stack check completed.', [
'site_id' => $site->id,
'changed' => $changed,
]);
} catch (DetectorException $exception) {
$failed = true;
$site->forceFill([
'last_error_at' => now(),
'last_error' => $exception->kind,
])->save();
Log::warning('Technology stack check failed.', [
'site_id' => $site->id,
'kind' => $exception->kind,
'status' => $exception->status,
'retry_after' => $exception->retryAfter,
]);
} catch (Throwable $exception) {
$failed = true;
Log::error('Technology stack monitor failed unexpectedly.', [
'site_id' => $site->id,
'exception' => $exception::class,
]);
}
});
return $failed ? self::FAILURE : self::SUCCESS;
}
}
Schedule and initialize the monitor
Add the schedule to routes/console.php:
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('sites:detect-stack')
->hourly()
->withoutOverlapping(30)
->onOneServer();
onOneServer() requires all application instances to share a compatible central cache. On a single-server deployment it may be omitted. withoutOverlapping() protects against a slow run colliding with the next scheduled invocation.
Run the migration and add a deliberately curated client URL:
php artisan migrate
php artisan tinker
App\Models\MonitoredSite::create([
'name' => 'Important Client',
'url' => 'https://client.example',
]);
Do not expose this insertion as an unauthenticated controller. Monitor only public sites that the business is entitled to inspect, and reject user credentials embedded in URLs.
Test the change path without calling the service
Laravel’s HTTP and notification fakes make the test deterministic. The fixture below treats detection details as opaque service data while proving that version and evidence changes alter the fingerprint.
<?php
namespace Tests\Feature;
use App\Domain\Technology\DetectionReport;
use App\Models\MonitoredSite;
use App\Notifications\TechnologyStackChanged;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
final class DetectSiteStacksTest extends TestCase
{
use RefreshDatabase;
public function test_it_notifies_when_the_report_changes(): void
{
config([
'services.technology_detector.token' => 'test-token',
'monitoring.developer_email' => '[email protected]',
]);
$old = [
'detections' => [[
'confidence' => 0.95,
'evidence' => ['public-signal-a'],
'versions' => ['1'],
]],
];
$new = [
'detections' => [[
'confidence' => 0.95,
'evidence' => ['public-signal-a'],
'versions' => ['2'],
]],
];
$site = MonitoredSite::create([
'name' => 'Important Client',
'url' => 'https://client.example',
'last_report' => $old,
'last_fingerprint' => DetectionReport::fromPayload($old)
->fingerprint(),
]);
Http::fake([
'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies'
=> Http::response($new, 200),
]);
Notification::fake();
$this->artisan('sites:detect-stack')
->assertSuccessful();
Notification::assertSentOnDemand(
TechnologyStackChanged::class
);
$site->refresh();
$this->assertSame(
DetectionReport::fromPayload($new)->fingerprint(),
$site->last_fingerprint
);
Http::assertSentCount(1);
}
}
Add companion tests for the baseline case, unchanged response, HTTP 401, HTTP 422, exhausted 429 retries, server failures, invalid JSON, and connection exceptions. The baseline test should assert that the report is stored without sending a notification.
Deploy with security and observability intact
Deploy the code, inject TECH_DETECTOR_TOKEN and mail credentials through the platform’s secret manager, migrate, and rebuild Laravel’s configuration cache:
php artisan migrate --force
php artisan config:cache
php artisan schedule:list
php artisan sites:detect-stack --site=1
Configure one system cron entry to invoke Laravel’s scheduler:
* * * * * cd /var/www/stack-watch && php artisan schedule:run >> /dev/null 2>&1
Never log the token, authorization headers, or entire upstream response. The structured logs already expose the site identifier, failure category, HTTP status, retry guidance, and whether a change occurred. Alert operationally on repeated authentication, rate_limit, or remote failures, as well as sites whose last_checked_at has become stale.
When rotating the service token, update every running instance before regenerating it if your deployment process permits that ordering. Because regeneration revokes the old active token, mixed deployments otherwise produce temporary 401 responses.
Common failures to diagnose
- Every call returns 401 or 403: verify that the active service-scoped token reached the runtime environment, then rebuild the configuration cache.
- The command returns 429: retries are already bounded. Reduce monitoring frequency, divide checks across time, or review the activated plan instead of adding an unbounded retry loop.
- No email arrives: inspect Laravel mail configuration and logs, then send a controlled test through the configured transport.
- Every run reports a change: compare two stored reports and consult the official response documentation. Remove only a confirmed volatile metadata field in the canonicalizer.
- The schedule runs twice: confirm that only the intended cron entries exist and that multi-instance deployments share the cache required by
onOneServer().
Final verification checklist
- The service plan is active and the token is stored only in environment-backed configuration.
- The minimal POST request succeeds with the required
urlJSON body. - The first scheduled run stores a baseline without notifying anyone.
- An unchanged fake response produces no notification.
- A changed version, evidence item, confidence value, detection, or redirect detail produces an email and a new fingerprint.
- Authentication and validation responses are not blindly retried.
- Connection, rate-limit, and server failures have bounded retries and structured logs.
- The scheduler runs on deployment, overlap protection works, and stale checks are observable.
A useful monitor does more than announce that “something changed.” It establishes a defensible baseline, retains the evidence behind each result, distinguishes service failure from stack movement, and makes missed alerts less likely than duplicate ones. With those properties in place, a quiet client-side migration becomes an actionable engineering signal instead of an unpleasant surprise.