Laravel CRM: Enrich Leads with Website Tech Stack Summaries
A lead’s website often reveals more than a free-form “industry” field. A concise stack summary such as “WordPress 6.5, WooCommerce, Cloudflare” can help an agency route opportunities, prepare discovery calls, and identify likely maintenance work before anyone opens developer tools.
The useful version of this feature is not a synchronous API call buried inside a page request. Website analysis can be slow, rate-limited, or temporarily unavailable. A production CRM should enqueue the work, validate an uncertain external response at one boundary, preserve useful evidence, and expose a readable result without making the lead screen fragile.
This tutorial builds that complete flow in Laravel and PHP 8.3 or later.
Prerequisites and the finished architecture
Start with an existing Laravel CRM containing a Lead model and a website_url column. You also need PHP 8.3 or later, a configured database, a working Laravel queue, and a shared cache if the application runs on multiple servers.
The integration has five small responsibilities:
- The controller authorizes a scan and dispatches a background job.
- The job owns lifecycle state and queue retry behavior.
- A dedicated HTTP service calls the detector with bounded timeouts.
- A domain object validates detections, confidence, evidence, versions, and redirect information.
- The lead stores both a readable summary and structured evidence for later inspection.
This is deliberately modest architecture. The external boundary deserves isolation, but a small CRM does not need an event bus or a separate microservice for one enrichment operation.
Get access before writing integration code
- Create an account at https://ai.mihajlo.mk/register, or sign in at https://ai.mihajlo.mk/login.
- 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. Prefer the Bearer header: query parameters are more likely to appear in proxy, browser, and access logs.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment change: update the application secret, restart workers, verify a request, and only then consider the rotation complete.
Confirm the exact endpoint
The API call is POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. It accepts a JSON object containing url. Make one minimal request before integrating Laravel:
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://example.com"}'
Do not commit the token. Put it in the deployment environment or the project’s untracked .env file:
WEBSITE_TECH_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_TECH_ENDPOINT=https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies
QUEUE_CONNECTION=database
Expose those values through config/services.php. Application code should read configuration, never call env() directly:
<?php
return [
// Existing services...
'website_technology_detector' => [
'token' => env('WEBSITE_TECH_TOKEN'),
'endpoint' => env(
'WEBSITE_TECH_ENDPOINT',
'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies'
),
],
];
Add enrichment state to leads
Create the integration classes and a migration:
php artisan make:migration add_technology_enrichment_to_leads_table
php artisan make:job EnrichLeadTechnology
php artisan make:controller LeadTechnologyController
php artisan make:test LeadTechnologyEnrichmentTest
The migration keeps the human-facing summary separate from structured detector data. Status and error columns make asynchronous failures visible without overloading application logs.
<?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_status', 24)->default('not_scanned');
$table->text('technology_summary')->nullable();
$table->json('technology_report')->nullable();
$table->text('technology_error')->nullable();
$table->timestamp('technology_checked_at')->nullable();
$table->index('technology_status');
});
}
public function down(): void
{
Schema::table('leads', function (Blueprint $table): void {
$table->dropIndex(['technology_status']);
$table->dropColumn([
'technology_status',
'technology_summary',
'technology_report',
'technology_error',
'technology_checked_at',
]);
});
}
};
Add the new attributes to the model’s existing mass-assignment policy and casts:
protected $fillable = [
// Existing lead fields...
'technology_status',
'technology_summary',
'technology_report',
'technology_error',
'technology_checked_at',
];
protected function casts(): array
{
return [
'technology_report' => 'array',
'technology_checked_at' => 'immutable_datetime',
];
}
Validate the response at the boundary
External JSON is untrusted input even when the service is reliable. The detector returns confidence-scored technologies with evidence, versions, and redirect information, but the application must still reject malformed shapes and tolerate unusable individual entries.
Create app/Domain/Technology/DetectionReport.php:
<?php
namespace App\Domain\Technology;
use UnexpectedValueException;
final readonly class DetectionReport
{
public function __construct(
public array $detections,
public array $redirect,
) {}
public static function fromApi(array $payload): self
{
if (!isset($payload['detections']) || !is_array($payload['detections'])) {
throw new UnexpectedValueException('Missing detections array.');
}
$detections = [];
foreach ($payload['detections'] as $item) {
if (!is_array($item)) {
continue;
}
$name = $item['name'] ?? null;
$confidence = $item['confidence'] ?? null;
if (!is_string($name) || $name === '' || !is_numeric($confidence)) {
continue;
}
$versions = $item['versions'] ?? [];
$evidence = $item['evidence'] ?? [];
$detections[] = [
'name' => $name,
'confidence' => (float) $confidence,
'versions' => is_array($versions)
? array_values(array_filter($versions, 'is_string'))
: [],
'evidence' => is_array($evidence) ? $evidence : [],
];
}
$redirect = $payload['redirect'] ?? [];
return new self(
detections: $detections,
redirect: is_array($redirect) ? $redirect : [],
);
}
public function summary(): string
{
if ($this->detections === []) {
return 'No technologies identified.';
}
$items = $this->detections;
usort(
$items,
fn (array $a, array $b): int =>
$b['confidence'] <=> $a['confidence']
);
return implode(', ', array_map(function (array $item): string {
$version = $item['versions'][0] ?? null;
return $version ? "{$item['name']} {$version}" : $item['name'];
}, $items));
}
public function toArray(): array
{
return [
'detections' => $this->detections,
'redirect' => $this->redirect,
];
}
}
The application does not impose a confidence threshold because the supplied contract does not define whether scores use a zero-to-one or percentage scale. It sorts numerically, retains the score, and leaves threshold policy for a documented business decision.
Build a bounded Laravel HTTP client
Create app/Services/TechnologyDetector.php. The service permits one immediate retry for connection failures and server errors. It does not retry authentication, validation, or rate-limit responses inside the same worker attempt.
<?php
namespace App\Services;
use App\Domain\Technology\DetectionReport;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Throwable;
use UnexpectedValueException;
final class TechnologyDetectorException extends \RuntimeException
{
public function __construct(
string $message,
public readonly bool $retryable,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
final class TechnologyDetector
{
public function detect(string $url): DetectionReport
{
$parts = parse_url($url);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
if (
!filter_var($url, FILTER_VALIDATE_URL)
|| !in_array($scheme, ['http', 'https'], true)
|| empty($parts['host'])
|| isset($parts['user'])
|| isset($parts['pass'])
) {
throw new TechnologyDetectorException(
'The lead website URL is invalid.',
false
);
}
$token = (string) config('services.website_technology_detector.token');
$endpoint = (string) config('services.website_technology_detector.endpoint');
if ($token === '' || $endpoint === '') {
throw new TechnologyDetectorException(
'Detector configuration is missing.',
false
);
}
for ($attempt = 1; $attempt <= 2; $attempt++) {
try {
$response = Http::acceptJson()
->asJson()
->withToken($token)
->connectTimeout(3)
->timeout(12)
->post($endpoint, ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt === 1) {
usleep(250_000);
continue;
}
throw new TechnologyDetectorException(
'Detector connection failed.',
true,
$exception
);
}
if ($response->successful()) {
$payload = $response->json();
if (!is_array($payload)) {
throw new TechnologyDetectorException(
'Detector returned invalid JSON.',
false
);
}
try {
return DetectionReport::fromApi($payload);
} catch (UnexpectedValueException $exception) {
throw new TechnologyDetectorException(
'Detector response did not match its contract.',
false,
$exception
);
}
}
if ($response->status() === 429) {
throw new TechnologyDetectorException(
'Detector rate limit reached.',
true
);
}
if ($response->serverError()) {
if ($attempt === 1) {
usleep(250_000);
continue;
}
throw new TechnologyDetectorException(
'Detector server error.',
true
);
}
throw new TechnologyDetectorException(
"Detector rejected the request with HTTP {$response->status()}.",
false
);
}
throw new TechnologyDetectorException('Detector request failed.', true);
}
}
The response body and credential never enter exceptions or logs. A 401, 403, or other client error is permanent for that job attempt; blindly retrying it wastes quota and obscures configuration mistakes.
Run enrichment off the request path
Create app/Jobs/EnrichLeadTechnology.php:
<?php
namespace App\Jobs;
use App\Models\Lead;
use App\Services\TechnologyDetector;
use App\Services\TechnologyDetectorException;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;
final class EnrichLeadTechnology implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public int $tries = 3;
public int $timeout = 30;
public int $uniqueFor = 600;
public array $backoff = [60, 300];
public function __construct(public readonly int $leadId) {}
public function uniqueId(): string
{
return (string) $this->leadId;
}
public function handle(TechnologyDetector $detector): void
{
$lead = Lead::query()->findOrFail($this->leadId);
$lead->update([
'technology_status' => 'scanning',
'technology_error' => null,
]);
try {
$report = $detector->detect($lead->website_url);
} catch (TechnologyDetectorException $exception) {
if ($exception->retryable) {
Log::warning('Lead technology enrichment will retry.', [
'lead_id' => $lead->id,
'attempt' => $this->attempts(),
]);
throw $exception;
}
$lead->update([
'technology_status' => 'failed',
'technology_error' => $exception->getMessage(),
]);
return;
}
$lead->update([
'technology_status' => 'complete',
'technology_summary' => $report->summary(),
'technology_report' => $report->toArray(),
'technology_error' => null,
'technology_checked_at' => now(),
]);
}
public function failed(?Throwable $exception): void
{
Lead::query()->whereKey($this->leadId)->update([
'technology_status' => 'failed',
'technology_error' => 'Technology detection is temporarily unavailable.',
]);
}
}
Immediate HTTP retries and queue retries serve different failures. A short second attempt smooths over a dropped connection; delayed queue attempts handle longer outages and rate limits. The limits above cap a persistent server failure at six HTTP calls and a persistent 429 at three.
Authorize and dispatch scans
Create app/Http/Controllers/LeadTechnologyController.php:
<?php
namespace App\Http\Controllers;
use App\Jobs\EnrichLeadTechnology;
use App\Models\Lead;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
final class LeadTechnologyController extends Controller
{
public function store(Lead $lead): RedirectResponse
{
Gate::authorize('update', $lead);
$lead->update([
'technology_status' => 'queued',
'technology_error' => null,
]);
EnrichLeadTechnology::dispatch($lead->id)
->onQueue('integrations')
->afterCommit();
return back()->with('status', 'Technology scan queued.');
}
}
Register the protected route in routes/web.php:
use App\Http\Controllers\LeadTechnologyController;
use Illuminate\Support\Facades\Route;
Route::post(
'/leads/{lead}/technology-scan',
[LeadTechnologyController::class, 'store']
)->middleware(['auth', 'throttle:20,1'])
->name('leads.technology.scan');
The normal web middleware supplies CSRF protection. Authorization prevents one account from scanning another account’s leads, while throttling limits accidental button mashing. The unique job lock adds another safeguard; use a shared cache driver when several application nodes consume the queue.
Test success and permanent failure
Laravel’s Http::fake() keeps tests deterministic and proves that no real credential or network connection is required. Add these cases to tests/Feature/LeadTechnologyEnrichmentTest.php:
<?php
namespace Tests\Feature;
use App\Jobs\EnrichLeadTechnology;
use App\Models\Lead;
use App\Services\TechnologyDetector;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class LeadTechnologyEnrichmentTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config()->set(
'services.website_technology_detector.token',
'test-token'
);
config()->set(
'services.website_technology_detector.endpoint',
'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies'
);
}
public function test_it_saves_a_readable_summary_and_evidence(): void
{
Http::fake([
'https://ai.mihajlo.mk/*' => Http::response([
'detections' => [
[
'name' => 'WordPress',
'confidence' => 0.98,
'versions' => ['6.5'],
'evidence' => ['generator metadata'],
],
[
'name' => 'Cloudflare',
'confidence' => 0.91,
'versions' => [],
'evidence' => ['response headers'],
],
],
'redirect' => [],
], 200),
]);
$lead = Lead::factory()->create([
'website_url' => 'https://example.com',
]);
(new EnrichLeadTechnology($lead->id))
->handle(app(TechnologyDetector::class));
$lead->refresh();
$this->assertSame('complete', $lead->technology_status);
$this->assertSame(
'WordPress 6.5, Cloudflare',
$lead->technology_summary
);
$this->assertCount(
2,
$lead->technology_report['detections']
);
Http::assertSent(fn ($request): bool =>
$request->hasHeader('Authorization', 'Bearer test-token')
&& $request['url'] === 'https://example.com'
);
}
public function test_it_does_not_retry_an_authentication_failure(): void
{
Http::fake([
'https://ai.mihajlo.mk/*' => Http::response([], 401),
]);
$lead = Lead::factory()->create([
'website_url' => 'https://example.com',
]);
(new EnrichLeadTechnology($lead->id))
->handle(app(TechnologyDetector::class));
$this->assertSame(
'failed',
$lead->fresh()->technology_status
);
Http::assertSentCount(1);
}
}
Deploy, observe, and troubleshoot
Run the migration and tests, cache production configuration, and start a supervised worker:
php artisan migrate --force
php artisan test
php artisan config:cache
php artisan queue:work --queue=integrations --sleep=3 --tries=3 --timeout=40 --max-time=3600
Use systemd, Supervisor, or the process manager supplied by the hosting platform to restart the worker after failure and during deployment. Queue workers are long-lived: after changing the token or configuration, rebuild the configuration cache and restart them with php artisan queue:restart.
Monitor counts and age for queued, scanning, complete, and failed leads. Alert on a growing integration queue or a sustained increase in failures. Logs should include lead IDs, attempts, HTTP status categories, and durations, but never authorization headers, complete response bodies, or service tokens.
Common failure patterns
- Every request returns 401 or 403: confirm plan activation, the service-scoped token, and the Bearer header. A regenerated token immediately invalidates the former active token.
- Requests remain queued: verify that a worker consumes the
integrationsqueue and uses the same queue configuration as the web process. - Configuration changes have no effect: rebuild Laravel’s configuration cache and restart long-running workers.
- Responses fail mapping: compare the payload with the official documentation. Update only the boundary mapper; controllers, jobs, and stored domain data should remain stable.
- Rate limits recur: reduce scan frequency, avoid automatic rescans on every lead edit, and review the active plan rather than increasing immediate retries.
- Duplicate jobs appear across servers: configure a cache driver shared by every node so
ShouldBeUniqueuses one lock store.
Final verification checklist
- The token exists only in environment-backed configuration and secret storage.
- The application sends
POSTrequests to the exact detector endpoint with a JSONurl. - Only authorized CRM users can enqueue scans.
- HTTP connection and response timeouts are bounded.
- Authentication and validation failures are not blindly retried.
- Rate limits and temporary server failures move through bounded queue retries.
- Detections, confidence, versions, evidence, and redirect information are validated before storage.
- The lead screen can display
technology_summarywithout understanding the external payload. - Workers restart after deployments and secret rotation.
- Tests use
Http::fake()and contain no production credential.
The durable lesson is larger than technology detection: enrichment should improve a CRM without becoming a new point of failure. Keep the vendor response at the boundary, the queue lifecycle explicit, and the lead-facing result pleasantly simple. Then “WordPress 6.5, Cloudflare” becomes useful operational context rather than another unreliable API-shaped field.