Laravel CRM: Obogatite potencijalne klijente tehnološkim skupovima web-stranica otkrivenima umjetnom inteligencijom
A lead’s website often reveals more than a discovery form does. A detected CMS, commerce platform, analytics suite, or front-end framework can help an agency prepare a relevant first conversation. The useful outcome is not a raw API response, however. It is a concise summary that appears inside the CRM, backed by evidence and refreshed without slowing down the person viewing the lead.
This tutorial builds that feature in Laravel and PHP 8.3. A queued job calls the Website Technology Detector API, validates its response at the application boundary, stores the normalized report, and adds a readable summary such as WordPress 6.6, WooCommerce 9.1, Cloudflare to each lead.
Prerequisites and design
Start with a Laravel application containing a Lead model and a leads table with a nullable website_url column. You also need PHP 8.3 or later, a configured Laravel queue, and permission to run migrations and workers in the target environment.
The integration uses a queue because remote detection can take longer than an ordinary CRM update. The controller records the request and returns immediately; a job performs the scan and updates the lead afterward. This also gives transient network and rate-limit failures a controlled retry path.
The relevant project structure will be:
app/
Data/TechnologyReport.php
Exceptions/TechnologyDetectorException.php
Jobs/DetectLeadTechnologies.php
Services/WebsiteTechnologyDetector.php
Http/Controllers/LeadTechnologyScanController.php
config/services.php
database/migrations/..._add_technology_fields_to_leads_table.php
tests/Feature/DetectLeadTechnologiesTest.php
Get API access before writing integration code
- Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login 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.
- Open the official service documentation.
- 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 the Bearer form so the credential remains out of URLs, access logs, and browser history.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment change: update every environment that uses the old value, rebuild Laravel’s configuration cache, and restart its queue workers.
Confirm the endpoint with a minimal request
The exact operation is POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Its JSON body contains one field, url:
curl --request POST \
--url https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies \
--header "Accept: application/json" \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Content-Type: application/json" \
--data '{"url":"https://example.com"}'
Use a public site you are authorized to process. Never paste a production token into shell history on a shared machine.
Now place the credential in the application environment, not in source control:
WEBSITE_TECHNOLOGY_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_TECHNOLOGY_DETECTOR_URL=https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies
Add an environment-backed entry to config/services.php:
'website_technology_detector' => [
'token' => env('WEBSITE_TECHNOLOGY_DETECTOR_TOKEN'),
'url' => env(
'WEBSITE_TECHNOLOGY_DETECTOR_URL',
'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies',
),
],
Store both the summary and the evidence
A summary is convenient for the CRM interface, but it should not replace the underlying result. Retaining a normalized JSON report preserves confidence scores, evidence, versions, and redirect information for auditing or later presentation.
<?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::table('leads', function (Blueprint $table): void {
$table->string('technology_scan_status', 20)
->default('never')->index();
$table->text('technology_summary')->nullable();
$table->json('technology_report')->nullable();
$table->text('technology_scan_error')->nullable();
$table->timestamp('technology_scanned_at')->nullable();
});
}
public function down(): void
{
Schema::table('leads', function (Blueprint $table): void {
$table->dropColumn([
'technology_scan_status',
'technology_summary',
'technology_report',
'technology_scan_error',
'technology_scanned_at',
]);
});
}
};
Add technology_report as an array cast and technology_scanned_at as a datetime cast on Lead. The job below assigns attributes directly, so these fields do not need to be mass assignable.
Normalize the response at one boundary
Remote JSON must not leak throughout the domain model. The mapper below accepts only usable detection entries, preserves the promised evidence and version collections, and rejects malformed top-level data instead of silently storing a false “no technologies” result.
<?php
// app/Data/TechnologyReport.php
namespace App\Data;
use UnexpectedValueException;
final readonly class TechnologyReport
{
public function __construct(
public array $detections,
public array $redirects,
) {}
public static function fromApi(array $payload): self
{
if (!isset($payload['detections']) || !is_array($payload['detections'])) {
throw new UnexpectedValueException(
'Detector response is missing a detections array.'
);
}
$detections = [];
foreach ($payload['detections'] as $item) {
if (!is_array($item)) {
continue;
}
$name = $item['name'] ?? null;
if (!is_string($name) || trim($name) === '') {
continue;
}
$confidence = $item['confidence'] ?? null;
$versions = $item['versions'] ?? [];
$evidence = $item['evidence'] ?? [];
$detections[] = [
'name' => trim($name),
'confidence' => is_numeric($confidence)
? (float) $confidence
: null,
'versions' => is_array($versions)
? array_values(array_filter(
$versions,
static fn ($value) => is_string($value) && $value !== ''
))
: [],
'evidence' => is_array($evidence) ? $evidence : [],
];
}
$redirects = $payload['redirects'] ?? [];
if (!is_array($redirects)) {
throw new UnexpectedValueException(
'Detector response contains invalid redirect information.'
);
}
usort(
$detections,
static fn (array $a, array $b): int =>
($b['confidence'] ?? -1) <=> ($a['confidence'] ?? -1)
);
return new self($detections, $redirects);
}
public function summary(int $limit = 8): string
{
$items = array_map(
static function (array $item): string {
$version = $item['versions'][0] ?? null;
return $version
? "{$item['name']} {$version}"
: $item['name'];
},
array_slice($this->detections, 0, $limit),
);
return $items === [] ? 'No technologies detected' : implode(', ', $items);
}
public function toArray(): array
{
return [
'detections' => $this->detections,
'redirects' => $this->redirects,
];
}
}
The application deliberately does not turn an unspecified confidence scale into a percentage. It stores the numeric value as returned and uses it only for ordering.
Build a bounded, failure-aware API client
Laravel’s HTTP client provides JSON encoding, timeouts, retry support, and a deterministic fake for tests. The client retries connection errors, HTTP 429 responses, and server failures. Validation and authentication failures are returned immediately because repeating the same request will not repair them.
<?php
// app/Exceptions/TechnologyDetectorException.php
namespace App\Exceptions;
use RuntimeException;
final class TechnologyDetectorException extends RuntimeException
{
public function __construct(
string $message,
public readonly ?int $status = null,
public readonly ?int $retryAfter = null,
) {
parent::__construct($message);
}
public function isTransient(): bool
{
return $this->status === null
|| $this->status === 429
|| $this->status >= 500;
}
}
<?php
// app/Services/WebsiteTechnologyDetector.php
namespace App\Services;
use App\Data\TechnologyReport;
use App\Exceptions\TechnologyDetectorException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;
use UnexpectedValueException;
final class WebsiteTechnologyDetector
{
public function detect(string $url): TechnologyReport
{
$token = config('services.website_technology_detector.token');
$endpoint = config('services.website_technology_detector.url');
if (!is_string($token) || $token === '') {
throw new TechnologyDetectorException(
'Website detector token is not configured.',
401,
);
}
try {
$response = Http::acceptJson()
->asJson()
->withToken($token)
->connectTimeout(3)
->timeout(12)
->retry(
[250, 750],
static 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($endpoint, ['url' => $url]);
} catch (ConnectionException $exception) {
throw new TechnologyDetectorException(
'Could not connect to the technology detector.'
);
}
if (!$response->successful()) {
$retryAfter = $response->header('Retry-After');
$retryAfter = is_string($retryAfter) && ctype_digit($retryAfter)
? min((int) $retryAfter, 900)
: null;
throw new TechnologyDetectorException(
'Technology detector returned HTTP '.$response->status().'.',
$response->status(),
$retryAfter,
);
}
$payload = $response->json();
if (!is_array($payload)) {
throw new TechnologyDetectorException(
'Technology detector returned invalid JSON.'
);
}
try {
return TechnologyReport::fromApi($payload);
} catch (UnexpectedValueException $exception) {
throw new TechnologyDetectorException($exception->getMessage());
}
}
}
The error messages intentionally omit response bodies, authorization headers, and tokens. If the provider returns sensitive diagnostic material, it therefore cannot accidentally enter normal application logs.
Run detection safely in the queue
The job receives both the lead ID and the URL that initiated the scan. It refuses to write an obsolete result if somebody changes the lead’s website while the request is running.
<?php
// app/Jobs/DetectLeadTechnologies.php
namespace App\Jobs;
use App\Exceptions\TechnologyDetectorException;
use App\Models\Lead;
use App\Services\WebsiteTechnologyDetector;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;
final class DetectLeadTechnologies implements ShouldQueue
{
use Queueable;
public int $tries = 4;
public int $timeout = 60;
public array $backoff = [60, 300, 900];
public function __construct(
public readonly int $leadId,
public readonly string $requestedUrl,
) {}
public function handle(WebsiteTechnologyDetector $detector): void
{
$lead = Lead::find($this->leadId);
if (!$lead || $lead->website_url !== $this->requestedUrl) {
return;
}
$lead->forceFill([
'technology_scan_status' => 'processing',
'technology_scan_error' => null,
])->save();
try {
$report = $detector->detect($this->requestedUrl);
} catch (TechnologyDetectorException $exception) {
Log::warning('Lead technology detection failed.', [
'lead_id' => $this->leadId,
'status' => $exception->status,
'transient' => $exception->isTransient(),
]);
if ($exception->status === 429 && $exception->retryAfter) {
$this->release($exception->retryAfter);
return;
}
if ($exception->isTransient()) {
throw $exception;
}
$lead->forceFill([
'technology_scan_status' => 'failed',
'technology_scan_error' => $exception->getMessage(),
])->save();
return;
}
if ($lead->fresh()->website_url !== $this->requestedUrl) {
return;
}
$lead->forceFill([
'technology_scan_status' => 'completed',
'technology_summary' => $report->summary(),
'technology_report' => $report->toArray(),
'technology_scan_error' => null,
'technology_scanned_at' => now(),
])->save();
}
public function failed(?Throwable $exception): void
{
Lead::whereKey($this->leadId)
->where('website_url', $this->requestedUrl)
->update([
'technology_scan_status' => 'failed',
'technology_scan_error' =>
'Detection failed after bounded retries.',
]);
}
}
Expose an authorized CRM action
The controller validates that the lead has an HTTP or HTTPS URL, relies on the lead policy for authorization, and dispatches only after the database transaction commits.
<?php
// app/Http/Controllers/LeadTechnologyScanController.php
namespace App\Http\Controllers;
use App\Jobs\DetectLeadTechnologies;
use App\Models\Lead;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
final class LeadTechnologyScanController extends Controller
{
public function __invoke(Lead $lead): JsonResponse
{
$this->authorize('update', $lead);
validator(
['website_url' => $lead->website_url],
['website_url' => ['required', 'url:http,https']]
)->validate();
DB::transaction(function () use ($lead): void {
$lead->forceFill([
'technology_scan_status' => 'queued',
'technology_scan_error' => null,
])->save();
DetectLeadTechnologies::dispatch(
$lead->getKey(),
$lead->website_url,
)->afterCommit();
});
return response()->json(['status' => 'queued'], 202);
}
}
<?php
// routes/web.php
use App\Http\Controllers\LeadTechnologyScanController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->group(function (): void {
Route::post(
'/leads/{lead}/technology-scan',
LeadTechnologyScanController::class,
)->name('leads.technology-scan');
});
A production interface can poll the existing lead endpoint or refresh after receiving the 202 Accepted response. Avoid making the scan route public: unrestricted submissions would consume quota and could turn the application into a general-purpose URL scanner.
Test without calling the external service
Http::fake() keeps the suite deterministic while still verifying authentication, request shape, mapping, and persistence.
<?php
// tests/Feature/DetectLeadTechnologiesTest.php
namespace Tests\Feature;
use App\Jobs\DetectLeadTechnologies;
use App\Models\Lead;
use App\Services\WebsiteTechnologyDetector;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class DetectLeadTechnologiesTest extends TestCase
{
use RefreshDatabase;
public function test_it_enriches_a_lead_from_a_normalized_response(): void
{
config()->set(
'services.website_technology_detector.token',
'test-token'
);
Http::fake([
'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies'
=> Http::response([
'detections' => [
[
'name' => 'WordPress',
'confidence' => 98,
'versions' => ['6.6'],
'evidence' => ['generator metadata'],
],
[
'name' => 'Cloudflare',
'confidence' => 90,
'versions' => [],
'evidence' => ['response header'],
],
],
'redirects' => [
['from' => 'http://example.test',
'to' => 'https://example.test'],
],
], 200),
]);
$lead = Lead::factory()->create([
'website_url' => 'https://example.test',
]);
$job = new DetectLeadTechnologies(
$lead->id,
$lead->website_url,
);
$job->handle(app(WebsiteTechnologyDetector::class));
$lead->refresh();
$this->assertSame('completed', $lead->technology_scan_status);
$this->assertSame(
'WordPress 6.6, Cloudflare',
$lead->technology_summary,
);
$this->assertCount(
2,
$lead->technology_report['detections'],
);
Http::assertSent(fn ($request): bool =>
$request->method() === 'POST'
&& $request['url'] === 'https://example.test'
&& $request->hasHeader(
'Authorization',
'Bearer test-token',
)
);
}
}
Add focused tests for HTTP 401, HTTP 429 with Retry-After, a connection exception, malformed successful JSON, an empty detections array, and a website URL changed during processing. Also test the controller policy and confirm unauthorized users cannot enqueue work.
Security and operational boundaries
Only submit public HTTP or HTTPS websites. If users can freely edit website_url, reject private, loopback, link-local, and internal DNS destinations before dispatching. The remote service performs the fetch, but a public CRM should still prevent its scanning feature from becoming an unrestricted proxy for arbitrary targets.
Keep the token in a secret manager or deployment environment. Do not expose it through client-side JavaScript, exception pages, fixtures, screenshots, or diagnostic dumps. Laravel’s encrypted environment file support may help with deployment workflows, but the decryption key must remain separate.
For observability, graph completed and failed jobs, queue latency, response status classes, and rate-limit events. Logs should identify the lead and failure category, not the token or full response body. An alert on sustained authentication failures is particularly valuable because regeneration immediately invalidates the former token.
Deploy and verify
Run the migration, rebuild cached configuration, and restart long-lived workers so they receive the current token:
php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --queue=default --tries=4 --timeout=90
In production, supervise the worker with the platform’s process manager rather than keeping the final command in an interactive terminal. Ensure the worker timeout exceeds the job’s 60-second timeout, and keep the job timeout above the worst bounded sequence of HTTP attempts.
Common failure patterns
- Every request returns 401 or 403: verify the service-scoped token, plan activation, Bearer header, and configuration cache. A regenerated token makes the previous value unusable.
- Jobs remain queued: confirm that a worker is running against the same queue connection and queue name used by the web process.
- Repeated 429 responses: reduce scan frequency, respect
Retry-After, and check the active plan’s limits. Do not add an unbounded retry loop. - A successful response becomes a failed scan: compare the payload with the current official documentation and update the single mapper boundary. Do not scatter alternate field guesses across controllers and models.
- The summary belongs to an old domain: ensure both pre-write URL checks remain in the job and consider cancelling queued scans when a lead’s website changes.
Final verification checklist
- The token exists only in environment-backed configuration.
- The test request uses the exact POST endpoint and sends JSON containing
url. - The CRM action returns
202without waiting for detection. - The worker stores a readable summary plus normalized detections, evidence, versions, confidence values, and redirect information.
- Authentication and validation failures are not blindly retried.
- Connections, responses, retries, backoff, and queue execution are bounded.
- Logs reveal enough to diagnose a lead scan without revealing credentials or payload bodies.
- Automated tests use
Http::fake()and never spend service quota.
The best enrichment feature is not the one that stores the most JSON. It is the one that quietly turns external evidence into a dependable decision aid. With the remote boundary isolated, retries constrained, stale results rejected, and the readable summary kept beside its evidence, a small agency CRM gains useful technical context without inheriting a fragile integration.