Laravel: Auto-Prefill CRM Leads from Company Websites with AI Data Extraction
A salesperson should not have to copy a company name, email address, phone number, and contact details from a website into a CRM one field at a time. A better workflow asks for one input—the company website—then enriches the lead in the background while keeping every returned value reviewable.
This tutorial builds that workflow in Laravel on PHP 8.3. The application creates a lead immediately, queues enrichment, calls the Website to Company data service, validates its response at the application boundary, and stores the returned company, contact, email, phone, and people data without assuming undocumented field shapes.
Get access and copy the service token
Complete access setup before writing integration code:
- Create an account on the registration page, or use the sign-in page if you already have one.
- Open the Website to Company data service page.
- Choose an available Free, Plus, or Pro plan and complete its activation. Review the current plan page for applicable quotas rather than encoding plan assumptions in your application.
- Open the official service documentation.
- Find the Service token panel and copy the service-scoped token.
This service requires a token. Regenerating it revokes the previously active token, so treat regeneration as a credential rotation: update every deployed environment promptly and restart workers that use cached configuration.
Confirm the exact API contract
The integration uses GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Authentication is the token={serviceToken} query parameter, while the company site is supplied through the website query parameter.
Run a minimal request from a trusted terminal. Keeping the values in separate encoded parameters avoids malformed URLs:
curl --get \
--data-urlencode "token=YOUR_SERVICE_TOKEN" \
--data-urlencode "website=https://example.com" \
"https://ai.mihajlo.mk/api/website-to-company-data/v1/extract"
Do not paste a real token into shell history on a shared machine. For routine diagnostics, load it from a protected environment variable instead.
Put the credential in Laravel configuration
Add the secret to the project’s local .env file, never to source control:
COMPANY_DATA_TOKEN=YOUR_SERVICE_TOKEN
COMPANY_DATA_URL=https://ai.mihajlo.mk/api/website-to-company-data/v1/extract
QUEUE_CONNECTION=database
Expose those values through config/services.php so configuration caching and tests behave predictably:
'company_data' => [
'token' => env('COMPANY_DATA_TOKEN'),
'url' => env(
'COMPANY_DATA_URL',
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract'
),
],
Architecture: respond quickly, enrich safely
A browser request should not wait through an external timeout or retry sequence. The controller therefore validates the website, creates a pending lead, and dispatches a queue job. The job owns the slow work, while a dedicated client owns the remote protocol and response validation.
The resulting flow is deliberately small:
LeadControlleraccepts the website and returns the new lead.EnrichLeadruns outside the request lifecycle.CompanyDataClienthandles authentication, timeouts, retries, and status classification.CompanyProfileconverts untrusted JSON into a stable domain value.Leadrecords pending, complete, or failed enrichment state.
Background execution adds a queue worker, but it prevents an intermittent provider response from tying up PHP request workers. It also gives the CRM an honest state to display instead of pretending enrichment is instantaneous.
Create the lead storage
Start with an existing Laravel application running PHP 8.3, Composer, a supported database, and a configured queue backend. Generate the application pieces with Artisan:
php artisan make:model Lead -m
php artisan make:controller LeadController
php artisan make:job EnrichLead
php artisan make:test LeadEnrichmentTest
Use JSON columns for external values whose internal structure is not part of the supplied contract. That preserves useful structured data without inventing fields such as a company name or job title.
<?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('leads', function (Blueprint $table): void {
$table->id();
$table->string('website', 2048);
$table->json('company_data')->nullable();
$table->json('contact_data')->nullable();
$table->json('email_data')->nullable();
$table->json('phone_data')->nullable();
$table->json('people_data')->nullable();
$table->string('enrichment_status')->default('pending');
$table->string('enrichment_error')->nullable();
$table->timestamp('enriched_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('leads');
}
};
Make the corresponding model fields assignable and cast the JSON columns:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class Lead extends Model
{
protected $fillable = [
'website',
'company_data',
'contact_data',
'email_data',
'phone_data',
'people_data',
'enrichment_status',
'enrichment_error',
'enriched_at',
];
protected function casts(): array
{
return [
'company_data' => 'json',
'contact_data' => 'json',
'email_data' => 'json',
'phone_data' => 'json',
'people_data' => 'json',
'enriched_at' => 'immutable_datetime',
];
}
}
Build a defensive API boundary
The documented application-level fields are company, contact, email, phone, and people. Their deeper shapes should not be guessed. The DTO below accepts structured arrays, strings, or null and rejects booleans, numbers, and objects that would otherwise leak ambiguous data into the CRM.
<?php
namespace App\Services\CompanyData;
use UnexpectedValueException;
final readonly class CompanyProfile
{
public function __construct(
public array|string|null $company,
public array|string|null $contact,
public array|string|null $email,
public array|string|null $phone,
public array|string|null $people,
) {}
public static function fromPayload(array $payload): self
{
return new self(
self::value($payload['company'] ?? null, 'company'),
self::value($payload['contact'] ?? null, 'contact'),
self::value($payload['email'] ?? null, 'email'),
self::value($payload['phone'] ?? null, 'phone'),
self::value($payload['people'] ?? null, 'people'),
);
}
private static function value(mixed $value, string $field): array|string|null
{
if ($value === null || is_array($value) || is_string($value)) {
return $value;
}
throw new UnexpectedValueException(
"Unexpected type for response field: {$field}"
);
}
}
The HTTP client uses bounded connection and response timeouts. It retries connection failures, rate limiting, and selected transient server failures. Authentication and validation failures are not retried because another identical request cannot repair them.
<?php
namespace App\Services\CompanyData;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use UnexpectedValueException;
final class CompanyDataException extends RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly ?int $status = null,
) {
parent::__construct("Company enrichment failed: {$kind}");
}
}
final class CompanyDataClient
{
public function extract(string $website): CompanyProfile
{
$url = (string) config('services.company_data.url');
$token = (string) config('services.company_data.token');
if ($token === '') {
throw new CompanyDataException('configuration');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::acceptJson()
->connectTimeout(3)
->timeout(12)
->get($url, [
'token' => $token,
'website' => $website,
]);
} catch (ConnectionException) {
if ($attempt === 3) {
throw new CompanyDataException('network');
}
usleep($attempt === 1 ? 200_000 : 800_000);
continue;
}
$status = $response->status();
if ($response->successful()) {
$payload = $response->json();
if (! is_array($payload)) {
throw new CompanyDataException('malformed_response', $status);
}
try {
return CompanyProfile::fromPayload($payload);
} catch (UnexpectedValueException) {
throw new CompanyDataException('malformed_response', $status);
}
}
if (in_array($status, [429, 500, 502, 503, 504], true)
&& $attempt < 3) {
$retryAfter = $response->header('Retry-After');
$milliseconds = is_numeric($retryAfter)
? min(5000, max(0, (int) $retryAfter * 1000))
: ($attempt === 1 ? 200 : 800);
usleep($milliseconds * 1000);
continue;
}
$kind = match (true) {
in_array($status, [401, 403], true) => 'authentication',
in_array($status, [400, 422], true) => 'invalid_request',
$status === 429 => 'rate_limited',
$status >= 500 => 'upstream',
default => 'unexpected_status',
};
throw new CompanyDataException($kind, $status);
}
throw new CompanyDataException('network');
}
}
The response body is intentionally absent from exceptions and logs. Providers can return sensitive details or operational metadata, and the query URL contains a credential.
Queue enrichment and expose the CRM endpoint
The job permits one queue attempt because the client already performs bounded retries. Multiplying client retries by worker retries can create a traffic surge during an outage.
<?php
namespace App\Jobs;
use App\Models\Lead;
use App\Services\CompanyData\CompanyDataClient;
use App\Services\CompanyData\CompanyDataException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
final class EnrichLead implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 60;
public function __construct(public readonly int $leadId) {}
public function handle(CompanyDataClient $client): void
{
$lead = Lead::find($this->leadId);
if (! $lead || $lead->enrichment_status !== 'pending') {
return;
}
try {
$profile = $client->extract($lead->website);
$lead->update([
'company_data' => $profile->company,
'contact_data' => $profile->contact,
'email_data' => $profile->email,
'phone_data' => $profile->phone,
'people_data' => $profile->people,
'enrichment_status' => 'complete',
'enrichment_error' => null,
'enriched_at' => now(),
]);
} catch (CompanyDataException $exception) {
$lead->update([
'enrichment_status' => 'failed',
'enrichment_error' => $exception->kind,
]);
Log::warning('Lead enrichment failed', [
'lead_id' => $lead->id,
'failure_kind' => $exception->kind,
'http_status' => $exception->status,
]);
}
}
}
The controller accepts only HTTP or HTTPS URLs. Place the route behind the CRM’s normal authentication and rate limiting so arbitrary visitors cannot spend the service quota.
<?php
namespace App\Http\Controllers;
use App\Jobs\EnrichLead;
use App\Models\Lead;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class LeadController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'website' => ['required', 'url:http,https', 'max:2048'],
]);
$lead = Lead::create([
'website' => $validated['website'],
'enrichment_status' => 'pending',
]);
EnrichLead::dispatch($lead->id);
return response()->json(['lead' => $lead], 202);
}
}
// routes/web.php
use App\Http\Controllers\LeadController;
use Illuminate\Support\Facades\Route;
Route::post('/leads', [LeadController::class, 'store'])
->middleware(['auth', 'throttle:20,1']);
A CRM screen can submit {"website":"https://example.com"}, render the returned pending lead immediately, and poll or refresh its existing lead endpoint until the status becomes complete or failed.
Test success and failure paths without network calls
Laravel’s Http::fake() makes the external boundary deterministic. Test both mapping and the important promise that permanent authentication failures are not retried.
<?php
namespace Tests\Feature;
use App\Services\CompanyData\CompanyDataClient;
use App\Services\CompanyData\CompanyDataException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class LeadEnrichmentTest extends TestCase
{
public function test_it_maps_company_data_and_sends_required_query_fields(): void
{
config()->set('services.company_data.token', 'test-token');
config()->set(
'services.company_data.url',
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract'
);
Http::fake([
'ai.mihajlo.mk/*' => Http::response([
'company' => ['display' => 'Example Company'],
'contact' => ['department' => 'Sales'],
'email' => '[email protected]',
'phone' => '+1 555 0100',
'people' => [['display' => 'A Person']],
], 200),
]);
$profile = app(CompanyDataClient::class)
->extract('https://example.com');
$this->assertSame('[email protected]', $profile->email);
$this->assertSame('+1 555 0100', $profile->phone);
Http::assertSent(fn (Request $request): bool =>
$request->method() === 'GET'
&& $request['token'] === 'test-token'
&& $request['website'] === 'https://example.com'
);
}
public function test_it_does_not_retry_authentication_failures(): void
{
config()->set('services.company_data.token', 'bad-token');
config()->set(
'services.company_data.url',
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract'
);
Http::fake([
'ai.mihajlo.mk/*' => Http::response([], 401),
]);
try {
app(CompanyDataClient::class)->extract('https://example.com');
$this->fail('Expected CompanyDataException');
} catch (CompanyDataException $exception) {
$this->assertSame('authentication', $exception->kind);
}
Http::assertSentCount(1);
}
}
Security, observability, and deployment
Authorize lead creation with the same policy used elsewhere in the CRM. Consider rejecting URLs containing credentials and restricting enrichment to public company websites according to your product rules. Escape returned strings when rendering them; enrichment data is untrusted input, even when it originated on a public site.
Never log the full outgoing URL because its query string contains the token. Log the lead identifier, sanitized failure category, HTTP status, attempt count, and duration. Dashboard counts for rate_limited, authentication, network, and malformed_response make incidents distinguishable without exposing payloads.
Deploy the secret through the hosting platform’s encrypted environment configuration. Then migrate, cache configuration, and restart long-running workers:
php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --queue=default --tries=1 --timeout=60
Run the worker under a process supervisor rather than an interactive shell. Ensure the queue connection’s retry_after exceeds the 60-second worker timeout so the same job is not delivered concurrently. If the database queue tables are not already present, create their migrations using the Artisan command supplied by your installed Laravel version, then migrate them before starting workers.
Common failures worth designing for
- Every lead reports authentication failure: verify the environment contains the active service-scoped token, clear stale configuration, and remember that regeneration revoked the old token.
- Leads remain pending: check that a queue worker is running against the same queue connection and environment as the web application.
- Rate limiting appears during imports: reduce application concurrency and submission rate. Do not turn an HTTP 429 into unlimited retries.
- The API succeeds but mapping fails: inspect a securely captured, redacted response and update the boundary deliberately. Do not scatter response-shape assumptions through controllers and views.
- Duplicate enrichment occurs: keep the status guard, configure queue visibility correctly, and make updates idempotent.
Final verification checklist
- The registration, plan activation, documentation, and service-token steps are complete.
- The real token exists only in protected environment configuration.
- The client calls the exact GET endpoint with
tokenandwebsitequery parameters. - Company, contact, email, phone, and people values cross one validated application boundary.
- Timeouts and retries are bounded, while authentication and validation failures are not retried.
- The authenticated CRM route creates a pending lead from only a website.
- A supervised worker changes that lead to complete or a structured failed state.
- Tests pass using
Http::fake(), with no live service dependency. - Logs contain diagnostic context but no token, query URL, or returned personal data.
The best enrichment workflow is not the one that hides uncertainty. It is the one that turns one small user action into useful structured data while making latency, failure, security, and human review explicit.