Laravel: Turn Website URLs into Pre-filled CRM Leads with AI Company Data Extraction
A salesperson should not have to copy a company name, general email address, telephone number, and employee details from a website into a CRM one field at a time. A better workflow asks for the website once, retrieves structured company data, and presents it as an editable lead draft.
This tutorial builds that workflow in Laravel with a deliberately narrow architecture: a synchronous enrichment endpoint, a defensive application-boundary mapper, and a small CRM form that never saves third-party data without human review. The integration uses Laravel’s built-in HTTP client, bounded timeouts, selective retries, structured errors, and deterministic tests.
Get access to the Website to Company data service
First, register for an account, or sign in to an existing account. Open the Website to Company data service page, choose the available Free, Plus, or Pro plan, and complete its activation.
Next, open the official service documentation. Find the Service token panel and copy the service-scoped token. Regenerating this token revokes the previously active token, so token rotation must update every deployed environment that uses it.
This service is not anonymous: every request requires the service token in the token query parameter. Some APIs need no credential, but that is not the case here.
Confirm the exact API contract
The integration sends an HTTP GET request to https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. It supplies two query parameters: token for authentication and website for the public company website.
Before writing Laravel code, make one minimal request with a placeholder token:
curl --get 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract' \
--data-urlencode 'token=YOUR_SERVICE_TOKEN' \
--data-urlencode 'website=https://example.com'
Use only placeholders in documentation and shell history. Do not paste a real token into source control, screenshots, logs, test fixtures, or support messages.
Store the real credential in the project’s uncommitted .env file:
WEBSITE_COMPANY_DATA_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_COMPANY_DATA_URL=https://ai.mihajlo.mk/api/website-to-company-data/v1/extract
Add an inert placeholder, never the real value, to .env.example. Then expose both settings through config/services.php:
'website_company_data' => [
'token' => env('WEBSITE_COMPANY_DATA_TOKEN'),
'url' => env(
'WEBSITE_COMPANY_DATA_URL',
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract'
),
],
Choose an architecture that matches the interaction
This is an interactive prefill operation: the salesperson enters a website and expects suggestions immediately. A queue would add polling, state management, and another failure boundary without improving this short request path. A synchronous controller is therefore appropriate, provided its network time is bounded.
The feature has four responsibilities:
- A form request normalizes and validates the submitted website.
- A dedicated client owns authentication, timeout, retry, and upstream-error behavior.
- A domain DTO accepts only the documented
company,contact,email,phone, andpeoplefields. - A controller returns a lead draft that the browser can place into editable fields.
The API result does not directly update a database. Website content can be incomplete, outdated, or ambiguous, so the salesperson reviews the draft before the CRM’s normal save operation.
Create the Laravel feature
The example targets PHP 8.3 or newer and a current Laravel application. No third-party HTTP package is required.
composer create-project laravel/laravel crm-prefill
cd crm-prefill
php artisan make:request PrefillLeadRequest
php artisan make:controller LeadPrefillController
php artisan make:class Data/CompanyData
php artisan make:class Exceptions/CompanyDataException
php artisan make:class Services/WebsiteCompanyDataClient
Validate and normalize the website
Salespeople commonly enter example.com rather than a complete URL. The request below adds HTTPS when the scheme is absent, accepts only HTTP or HTTPS, rejects embedded credentials, and blocks literal private or reserved IP addresses. DNS-based network policy remains the upstream service’s responsibility.
<?php
// app/Http/Requests/PrefillLeadRequest.php
namespace App\Http\Requests;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
final class PrefillLeadRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$website = trim((string) $this->input('website'));
if ($website !== '' && ! preg_match('~^https?://~i', $website)) {
$website = 'https://'.$website;
}
$this->merge(['website' => $website]);
}
public function rules(): array
{
return [
'website' => [
'required',
'string',
'max:2048',
'url:http,https',
function (string $attribute, mixed $value, Closure $fail): void {
$parts = parse_url((string) $value);
if (! is_array($parts) || ! isset($parts['host'])) {
return;
}
if (isset($parts['user']) || isset($parts['pass'])) {
$fail('The website must not contain credentials.');
return;
}
$host = strtolower($parts['host']);
if ($host === 'localhost') {
$fail('The website must be publicly reachable.');
return;
}
$isIp = filter_var($host, FILTER_VALIDATE_IP) !== false;
$isPublicIp = filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
if ($isIp && ! $isPublicIp) {
$fail('The website must use a public address.');
}
},
],
];
}
}
Map the response at the application boundary
The service contract names five returned values, but an integration should not trust their runtime types. The mapper accepts strings for the four lead fields and an array for people. Missing, empty, or unexpected values become safe defaults instead of leaking uncertain data through the application.
<?php
// app/Data/CompanyData.php
namespace App\Data;
final readonly class CompanyData
{
public function __construct(
public ?string $company,
public ?string $contact,
public ?string $email,
public ?string $phone,
public array $people,
) {}
public static function fromPayload(array $payload): self
{
$people = $payload['people'] ?? [];
return new self(
company: self::text($payload['company'] ?? null),
contact: self::text($payload['contact'] ?? null),
email: self::text($payload['email'] ?? null),
phone: self::text($payload['phone'] ?? null),
people: is_array($people) ? $people : [],
);
}
public function toArray(): array
{
return [
'company' => $this->company,
'contact' => $this->contact,
'email' => $this->email,
'phone' => $this->phone,
'people' => $this->people,
];
}
private static function text(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}
}
If the official response contract later defines nested structures for a field, update this single mapper rather than guessing at arbitrary object keys throughout controllers and views.
Build a bounded, selective HTTP client
The client allows two attempts for this idempotent GET request. It retries connection failures and selected transient server responses. A rate-limit response is retried only when a numeric Retry-After header asks for no more than one second. Authentication and validation failures are never retried.
<?php
// app/Exceptions/CompanyDataException.php
namespace App\Exceptions;
use RuntimeException;
final class CompanyDataException extends RuntimeException
{
public function __construct(
public readonly string $category,
public readonly int $httpStatus,
string $message,
) {
parent::__construct($message);
}
}
<?php
// app/Services/WebsiteCompanyDataClient.php
namespace App\Services;
use App\Data\CompanyData;
use App\Exceptions\CompanyDataException;
use Exception;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
final class WebsiteCompanyDataClient
{
public function extract(string $website): CompanyData
{
$token = config('services.website_company_data.token');
$endpoint = config('services.website_company_data.url');
if (! is_string($token) || $token === '') {
throw new CompanyDataException(
'configuration',
503,
'Company enrichment is not configured.'
);
}
$started = microtime(true);
try {
$response = Http::acceptJson()
->connectTimeout(2)
->timeout(8)
->retry(
2,
fn (int $attempt, Exception $e): int =>
$this->retryDelay($attempt, $e),
fn (Exception $e): bool => $this->isRetryable($e),
throw: false,
)
->get((string) $endpoint, [
'token' => $token,
'website' => $website,
]);
} catch (ConnectionException) {
throw new CompanyDataException(
'network',
503,
'Company enrichment is temporarily unavailable.'
);
}
Log::info('website_company_data.completed', [
'host' => parse_url($website, PHP_URL_HOST),
'status' => $response->status(),
'latency_ms' => (int) ((microtime(true) - $started) * 1000),
]);
if (in_array($response->status(), [401, 403], true)) {
throw new CompanyDataException(
'authentication',
502,
'Company enrichment credentials were rejected.'
);
}
if ($response->status() === 429) {
throw new CompanyDataException(
'rate_limited',
429,
'Company enrichment is busy. Please try again shortly.'
);
}
if ($response->status() >= 500) {
throw new CompanyDataException(
'upstream',
503,
'Company enrichment is temporarily unavailable.'
);
}
if (! $response->successful()) {
throw new CompanyDataException(
'upstream_request',
502,
'The website could not be enriched.'
);
}
$payload = $response->json();
if (! is_array($payload)) {
throw new CompanyDataException(
'invalid_response',
502,
'Company enrichment returned an invalid response.'
);
}
return CompanyData::fromPayload($payload);
}
private function isRetryable(Exception $exception): bool
{
if ($exception instanceof ConnectionException) {
return true;
}
if (! $exception instanceof RequestException) {
return false;
}
$status = $exception->response->status();
if ($status === 429) {
$retryAfter = $exception->response->header('Retry-After');
return is_string($retryAfter)
&& ctype_digit($retryAfter)
&& (int) $retryAfter <= 1;
}
return in_array($status, [500, 502, 503, 504], true);
}
private function retryDelay(int $attempt, Exception $exception): int
{
if ($exception instanceof RequestException
&& $exception->response->status() === 429) {
return min(
1000,
((int) $exception->response->header('Retry-After')) * 1000
);
}
return 250 * $attempt;
}
}
Expose the prefill endpoint
The controller translates a sanitized integration failure into a stable application response. It logs only the failure category, status, and hostname—not the token, full query string, upstream body, or submitted page path.
<?php
// app/Http/Controllers/LeadPrefillController.php
namespace App\Http\Controllers;
use App\Exceptions\CompanyDataException;
use App\Http\Requests\PrefillLeadRequest;
use App\Services\WebsiteCompanyDataClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
final class LeadPrefillController extends Controller
{
public function __invoke(
PrefillLeadRequest $request,
WebsiteCompanyDataClient $client,
): JsonResponse {
$website = $request->validated('website');
try {
return response()->json([
'data' => $client->extract($website)->toArray(),
]);
} catch (CompanyDataException $exception) {
Log::warning('website_company_data.failed', [
'category' => $exception->category,
'status' => $exception->httpStatus,
'host' => parse_url($website, PHP_URL_HOST),
]);
return response()->json([
'error' => [
'code' => $exception->category,
'message' => $exception->getMessage(),
],
], $exception->httpStatus);
}
}
}
<?php
// routes/web.php
use App\Http\Controllers\LeadPrefillController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->group(function (): void {
Route::view('/crm/leads/create', 'leads.create')
->name('leads.create');
Route::post('/crm/leads/prefill', LeadPrefillController::class)
->middleware('throttle:20,1')
->name('leads.prefill');
});
Connect it to the lead form
Place the following fragment inside the CRM’s existing authenticated lead form. The prefill button calls Laravel, copies accepted scalar values into editable inputs, and displays the people collection for review. The CRM’s existing save action remains authoritative.
<input id="website" name="website" placeholder="example.com">
<button id="prefill" type="button">Prefill company data</button>
<input id="company" name="company">
<input id="contact" name="contact">
<input id="email" name="email" type="email">
<input id="phone" name="phone">
<pre id="people"></pre>
<p id="prefill-status"></p>
<script>
document.getElementById('prefill').addEventListener('click', async () => {
const status = document.getElementById('prefill-status');
status.textContent = 'Looking up company data…';
try {
const response = await fetch('/crm/leads/prefill', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({
website: document.getElementById('website').value
})
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error?.message ?? 'Prefill failed.');
}
for (const field of ['company', 'contact', 'email', 'phone']) {
document.getElementById(field).value = payload.data[field] ?? '';
}
document.getElementById('people').textContent =
JSON.stringify(payload.data.people, null, 2);
status.textContent = 'Company data is ready for review.';
} catch (error) {
status.textContent = error.message;
}
});
</script>
Test success, validation, and authentication failure
Http::fake() keeps the suite deterministic and prevents tests from consuming plan quota. These tests verify the exact method, endpoint, query authentication, response mapping, local validation, and the rule that authentication failures are not retried.
<?php
// tests/Feature/LeadPrefillTest.php
namespace Tests\Feature;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class LeadPrefillTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware();
config([
'services.website_company_data.token' => 'YOUR_SERVICE_TOKEN',
'services.website_company_data.url' =>
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
]);
}
public function test_it_returns_a_lead_draft(): void
{
Http::fake([
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract*' =>
Http::response([
'company' => 'Example Company',
'contact' => 'General enquiries',
'email' => '[email protected]',
'phone' => '+1 555 0100',
'people' => [],
]),
]);
$this->postJson('/crm/leads/prefill', [
'website' => 'example.com',
])->assertOk()->assertJsonPath(
'data.company',
'Example Company'
);
Http::assertSent(fn (Request $request): bool =>
$request->method() === 'GET'
&& $request['token'] === 'YOUR_SERVICE_TOKEN'
&& $request['website'] === 'https://example.com'
);
}
public function test_it_rejects_an_invalid_scheme_without_an_api_call(): void
{
$this->postJson('/crm/leads/prefill', [
'website' => 'ftp://example.com',
])->assertUnprocessable();
Http::assertNothingSent();
}
public function test_it_does_not_retry_rejected_credentials(): void
{
Http::fake([
'*' => Http::response([], 401),
]);
$this->postJson('/crm/leads/prefill', [
'website' => 'https://example.com',
])->assertStatus(502)
->assertJsonPath('error.code', 'authentication');
Http::assertSentCount(1);
}
}
Security, observability, and deployment
Because authentication is mandated as a query parameter, infrastructure deserves special attention. Keep HTTPS enabled end to end and configure reverse proxies, tracing systems, exception reporters, and application-performance tools to redact query strings. Laravel’s code never logs the endpoint or token, but infrastructure outside Laravel may capture complete URLs by default.
Protect the route with authentication, authorization appropriate to CRM users, CSRF protection, and application-level throttling. Treat all extracted data as untrusted input when rendering it; Blade’s escaped output and DOM textContent are safer than raw HTML. Do not silently persist people data or use it to trigger outreach without the review and compliance rules relevant to the application.
Monitor counts and latency for successful requests, validation failures, rate limits, upstream failures, and authentication failures. A sudden authentication spike usually indicates an expired or regenerated token; rising latency or 5xx responses indicate an upstream dependency problem. Alerts should aggregate categories rather than attach sensitive request details.
Set the production token before caching configuration, then deploy with the normal Laravel checks:
php artisan test
php artisan config:cache
php artisan route:cache
After rotating the service token, update the secret in every environment and rebuild Laravel’s configuration cache. A worker restart is unnecessary for this synchronous design, but long-running application servers should be reloaded according to the deployment platform’s normal procedure.
Common failure modes
- Every request returns an authentication error: confirm that the token came from this service’s Service token panel, then refresh cached configuration. A regenerated token immediately replaces the prior active token.
- Valid-looking input returns 422: inspect the normalized URL and confirm it uses HTTP or HTTPS, contains no embedded credentials, and does not point to a literal private address.
- The response succeeds but fields are empty: inspect the upstream contract and mapper. The boundary intentionally rejects unexpected types instead of guessing at undocumented shapes.
- Requests intermittently return 429: do not increase retries. Respect the plan’s capacity, keep the UI message actionable, and retry later rather than turning one limited request into a burst.
- Production sees changes that local development does not: clear and rebuild Laravel’s configuration cache after changing environment-backed settings.
Final verification checklist
- The salesperson can enter a bare domain or complete public HTTP/HTTPS URL.
- The server sends GET requests only to the exact configured extraction endpoint.
- Both
tokenandwebsiteare query parameters. - Company, contact, email, phone, and people data cross one defensive mapper.
- Timeouts and retry counts are bounded, and 401, 403, and validation failures are not retried.
- No credential or full authenticated URL appears in code, logs, tests, or monitoring.
- The returned lead remains editable and is reviewed before it is saved.
The most valuable part of this feature is not the HTTP call. It is the boundary around it: one website goes in, a predictable lead draft comes out, transient failures remain contained, and a person keeps the final decision. That is what turns convenient enrichment into a dependable CRM workflow.