Tutorials

Laravel CRM Automation: Pre-fill Leads Instantly from Company Websites

Laravel CRM Automation: Pre-fill Leads Instantly from Company Websites

A salesperson should not have to copy a company name, phone number, and contact details from five browser tabs before creating one lead. The company website is already a useful identifier; the CRM should turn it into a draft that the salesperson can review and save.

This tutorial builds that workflow in Laravel and PHP 8.3. A salesperson enters a public website, the application calls the Website to Company data service, maps the response at a strict application boundary, and fills the lead form without overwriting anything the salesperson has already typed.

The implementation is synchronous because prefill is an interactive action. A queue would add polling, stale drafts, and more failure states without improving this short request-and-response workflow. We will still add bounded timeouts, selective retries, structured errors, tests, throttling, and production-safe logging.

Get access and copy the service token

  1. Create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.
  2. Open the Website to Company data service page.
  3. Choose the available Free, Plus, or Pro plan and complete its activation.
  4. Open the official service documentation.
  5. Find the Service token panel and copy the service-scoped token shown there.

This service requires a token. Regenerating it revokes the previously active token, so coordinate rotation with deployment: update the production secret first, deploy or refresh cached configuration, and then verify a request. Never place the token in source control, fixtures, screenshots, exception messages, or application logs.

Confirm the HTTP contract

The exact request is GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Authentication uses the token={serviceToken} query parameter, and the target site is supplied through the website query parameter.

Test the credential from a trusted shell before writing integration code:

curl --get 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract' \
  --data-urlencode 'website=https://example.com' \
  --data-urlencode 'token=YOUR_SERVICE_TOKEN'

The service returns company and contact information, including company, contact, email, phone, and people. The application must validate those values instead of allowing an external response to spread unexamined through the domain.

Create the Laravel project configuration

You need PHP 8.3 or newer, Composer, and a Laravel application with an authenticated lead form. For a new application, create the project and run its initial migrations:

composer create-project laravel/laravel crm-prefill
cd crm-prefill
php artisan migrate

Add a dedicated entry to config/services.php. Keeping both the credential and endpoint behind Laravel configuration makes tests deterministic and lets config:cache work correctly.

<?php

return [
    // Existing service configuration...

    'website_company_data' => [
        'endpoint' => env(
            'WEBSITE_COMPANY_DATA_ENDPOINT',
            'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract'
        ),
        'token' => env('WEBSITE_COMPANY_DATA_TOKEN'),
    ],
];

Store the copied token in the project’s local .env file. Commit only a placeholder to .env.example.

WEBSITE_COMPANY_DATA_ENDPOINT=https://ai.mihajlo.mk/api/website-to-company-data/v1/extract
WEBSITE_COMPANY_DATA_TOKEN=YOUR_SERVICE_TOKEN

The finished integration adds four main pieces: a response DTO, an API service, a validated controller endpoint, and a small browser-side adapter for the existing CRM form. No enriched draft is persisted until the salesperson submits the normal lead form.

Map the external response at the boundary

Create app/Data/CompanyData.php. This example’s form uses scalar inputs for company, contact, email, and phone, so non-string values are treated as upstream schema drift rather than guessed into an arbitrary representation. People remain an array because the UI may present multiple candidates.

<?php

namespace App\Data;

use UnexpectedValueException;

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 fromApi(array $payload): self
    {
        $string = static function (string $key) use ($payload): ?string {
            if (!array_key_exists($key, $payload) || $payload[$key] === null) {
                return null;
            }

            if (!is_string($payload[$key])) {
                throw new UnexpectedValueException(
                    "Expected {$key} to be a string or null."
                );
            }

            $value = trim($payload[$key]);

            return $value === '' ? null : $value;
        };

        $people = $payload['people'] ?? [];

        if (!is_array($people)) {
            throw new UnexpectedValueException(
                'Expected people to be an array.'
            );
        }

        return new self(
            company: $string('company'),
            contact: $string('contact'),
            email: $string('email'),
            phone: $string('phone'),
            people: $people,
        );
    }

    public function toArray(): array
    {
        return [
            'company' => $this->company,
            'contact' => $this->contact,
            'email' => $this->email,
            'phone' => $this->phone,
            'people' => $this->people,
        ];
    }
}

Build a failure-aware HTTP service

Create app/Exceptions/CompanyDataException.php for errors the controller can safely translate into public responses:

<?php

namespace App\Exceptions;

use RuntimeException;
use Throwable;

final class CompanyDataException extends RuntimeException
{
    public function __construct(
        public readonly string $reason,
        public readonly int $httpStatus,
        string $message,
        ?Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

Now create app/Services/WebsiteCompanyData.php. It uses Laravel’s built-in HTTP client, limits connection and total response time, and retries only connection failures, rate limits, and selected transient server failures. Authentication and validation failures are not retried.

<?php

namespace App\Services;

use App\Data\CompanyData;
use App\Exceptions\CompanyDataException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use LogicException;
use UnexpectedValueException;

final class WebsiteCompanyData
{
    public function extract(
        string $website,
        string $correlationId
    ): CompanyData {
        $endpoint = (string) config('services.website_company_data.endpoint');
        $token = (string) config('services.website_company_data.token');

        if ($token === '') {
            throw new LogicException(
                'WEBSITE_COMPANY_DATA_TOKEN is not configured.'
            );
        }

        $delayMicroseconds = 0;

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            if ($delayMicroseconds > 0) {
                usleep($delayMicroseconds);
            }

            try {
                $response = Http::acceptJson()
                    ->connectTimeout(3)
                    ->timeout(12)
                    ->get($endpoint, [
                        'website' => $website,
                        'token' => $token,
                    ]);
            } catch (ConnectionException $exception) {
                Log::warning('Company enrichment connection failure', [
                    'correlation_id' => $correlationId,
                    'attempt' => $attempt,
                    'exception' => $exception::class,
                ]);

                if ($attempt === 3) {
                    throw new CompanyDataException(
                        'upstream_unavailable',
                        503,
                        'Company enrichment is temporarily unavailable.',
                        $exception,
                    );
                }

                $delayMicroseconds = $attempt === 1 ? 200_000 : 600_000;
                continue;
            }

            if ($response->successful()) {
                $payload = $response->json();

                if (!is_array($payload)) {
                    throw new CompanyDataException(
                        'invalid_response',
                        502,
                        'The enrichment service returned an invalid response.'
                    );
                }

                try {
                    return CompanyData::fromApi($payload);
                } catch (UnexpectedValueException $exception) {
                    throw new CompanyDataException(
                        'invalid_response',
                        502,
                        'The enrichment response did not match the expected schema.',
                        $exception,
                    );
                }
            }

            $status = $response->status();
            $retryable = $status === 429
                || in_array($status, [500, 502, 503, 504], true);

            Log::warning('Company enrichment HTTP failure', [
                'correlation_id' => $correlationId,
                'attempt' => $attempt,
                'upstream_status' => $status,
                'retryable' => $retryable,
            ]);

            if ($retryable && $attempt < 3) {
                $delayMicroseconds = $attempt === 1 ? 200_000 : 600_000;

                if ($status === 429) {
                    $retryAfter = filter_var(
                        $response->header('Retry-After'),
                        FILTER_VALIDATE_INT
                    );

                    if ($retryAfter !== false) {
                        $delayMicroseconds = max(
                            $delayMicroseconds,
                            min($retryAfter, 2) * 1_000_000
                        );
                    }
                }

                continue;
            }

            throw match (true) {
                in_array($status, [401, 403], true) =>
                    new CompanyDataException(
                        'upstream_authentication',
                        502,
                        'Company enrichment is not configured correctly.'
                    ),
                in_array($status, [400, 422], true) =>
                    new CompanyDataException(
                        'website_rejected',
                        422,
                        'The website could not be accepted for enrichment.'
                    ),
                $status === 429 =>
                    new CompanyDataException(
                        'rate_limited',
                        429,
                        'The enrichment limit was reached. Try again shortly.'
                    ),
                default =>
                    new CompanyDataException(
                        'upstream_unavailable',
                        503,
                        'Company enrichment is temporarily unavailable.'
                    ),
            };
        }

        throw new LogicException('Unreachable enrichment state.');
    }
}

The maximum retry count is deliberately small. Longer retry storms increase response latency and consume more quota while the dependency is already unhealthy. The bounded Retry-After handling respects short rate-limit windows without allowing an upstream header to hold a PHP worker indefinitely.

Expose an authenticated prefill endpoint

Create app/Http/Requests/PrefillLeadRequest.php:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class PrefillLeadRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'website' => [
                'required',
                'string',
                'max:2048',
                'url:http,https',
                function (string $attribute, mixed $value, $fail): void {
                    $host = parse_url((string) $value, PHP_URL_HOST);

                    if (!is_string($host) || strtolower($host) === 'localhost') {
                        $fail('The website must have a public hostname.');
                        return;
                    }

                    if (
                        filter_var($host, FILTER_VALIDATE_IP)
                        && !filter_var(
                            $host,
                            FILTER_VALIDATE_IP,
                            FILTER_FLAG_NO_PRIV_RANGE |
                            FILTER_FLAG_NO_RES_RANGE
                        )
                    ) {
                        $fail('Private and reserved IP addresses are not allowed.');
                    }
                },
            ],
        ];
    }
}

Create app/Http/Controllers/LeadPrefillController.php:

<?php

namespace App\Http\Controllers;

use App\Exceptions\CompanyDataException;
use App\Http\Requests\PrefillLeadRequest;
use App\Services\WebsiteCompanyData;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

final class LeadPrefillController extends Controller
{
    public function __invoke(
        PrefillLeadRequest $request,
        WebsiteCompanyData $service
    ): JsonResponse {
        $correlationId = (string) Str::uuid();

        try {
            $company = $service->extract(
                $request->string('website')->toString(),
                $correlationId
            );

            return response()->json([
                'data' => $company->toArray(),
                'meta' => ['correlation_id' => $correlationId],
            ]);
        } catch (CompanyDataException $exception) {
            Log::notice('Lead prefill failed', [
                'correlation_id' => $correlationId,
                'reason' => $exception->reason,
            ]);

            return response()->json([
                'error' => [
                    'code' => $exception->reason,
                    'message' => $exception->getMessage(),
                    'correlation_id' => $correlationId,
                ],
            ], $exception->httpStatus);
        }
    }
}

Register the route in routes/web.php. Authentication, CSRF protection, and a per-user throttle protect the service token and plan quota:

<?php

use App\Http\Controllers\LeadPrefillController;
use Illuminate\Support\Facades\Route;

Route::post('/leads/prefill', LeadPrefillController::class)
    ->middleware(['auth', 'throttle:10,1'])
    ->name('leads.prefill');

Fill the CRM form without destroying user input

Add the following to resources/js/lead-prefill.js and import it from your application entry point. The existing form should have data-lead-form, an @csrf field, and inputs named website, company, contact, email, and phone.

const form = document.querySelector('[data-lead-form]');

if (form) {
    const website = form.elements.website;

    website.addEventListener('blur', async () => {
        if (!website.value.trim()) return;

        const controller = new AbortController();
        const timer = window.setTimeout(() => controller.abort(), 45_000);

        try {
            const response = await fetch('/leads/prefill', {
                method: 'POST',
                credentials: 'same-origin',
                headers: {
                    'Accept': 'application/json',
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': form.elements._token.value,
                },
                body: JSON.stringify({ website: website.value.trim() }),
                signal: controller.signal,
            });

            const payload = await response.json();

            if (!response.ok) {
                throw new Error(
                    payload.error?.message ?? 'Lead prefill failed.'
                );
            }

            for (const field of ['company', 'contact', 'email', 'phone']) {
                const value = payload.data[field];

                if (
                    typeof value === 'string' &&
                    form.elements[field] &&
                    !form.elements[field].value
                ) {
                    form.elements[field].value = value;
                }
            }

            form.dispatchEvent(new CustomEvent('company-people-loaded', {
                detail: payload.data.people,
            }));
        } catch (error) {
            form.dispatchEvent(new CustomEvent('company-prefill-failed', {
                detail: error,
            }));
        } finally {
            window.clearTimeout(timer);
        }
    });
}

Only empty fields are populated. That small rule matters: external enrichment is a suggestion, while the salesperson remains the authority over the lead being created. The company-people-loaded event also leaves presentation choices—such as a contact selector—to the CRM interface instead of coupling them to transport code.

Test the integration without calling the service

Laravel’s Http::fake() provides a deterministic transport. Create tests/Feature/WebsiteCompanyDataTest.php:

<?php

namespace Tests\Feature;

use App\Exceptions\CompanyDataException;
use App\Services\WebsiteCompanyData;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class WebsiteCompanyDataTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        config()->set(
            'services.website_company_data.endpoint',
            'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract'
        );
        config()->set(
            'services.website_company_data.token',
            'test-service-token'
        );
    }

    public function test_it_maps_company_data_and_sends_the_contract(): void
    {
        Http::fake([
            'ai.mihajlo.mk/*' => Http::response([
                'company' => 'Example Company',
                'contact' => 'Sales',
                'email' => '[email protected]',
                'phone' => '+1 555 0100',
                'people' => [['display' => 'Primary contact']],
            ], 200),
        ]);

        $result = app(WebsiteCompanyData::class)->extract(
            'https://example.com',
            'test-correlation-id'
        );

        $this->assertSame('Example Company', $result->company);
        $this->assertCount(1, $result->people);

        Http::assertSent(function ($request): bool {
            return $request->method() === 'GET'
                && $request['website'] === 'https://example.com'
                && $request['token'] === 'test-service-token';
        });
    }

    public function test_authentication_failure_is_not_retried(): void
    {
        Http::fake([
            'ai.mihajlo.mk/*' => Http::response([], 401),
        ]);

        try {
            app(WebsiteCompanyData::class)->extract(
                'https://example.com',
                'test-correlation-id'
            );

            $this->fail('Expected CompanyDataException.');
        } catch (CompanyDataException $exception) {
            $this->assertSame(
                'upstream_authentication',
                $exception->reason
            );
        }

        Http::assertSentCount(1);
    }

    public function test_it_rejects_an_unexpected_response_shape(): void
    {
        Http::fake([
            'ai.mihajlo.mk/*' => Http::response([
                'company' => ['unexpected' => 'object'],
                'people' => [],
            ], 200),
        ]);

        $this->expectException(CompanyDataException::class);

        app(WebsiteCompanyData::class)->extract(
            'https://example.com',
            'test-correlation-id'
        );
    }
}

Run the suite with php artisan test. These tests verify the exact method, endpoint-bound parameters, token placement, response mapping, and the important rule that an authentication failure receives no retries.

Security, observability, and deployment

  • Protect the endpoint: retain authentication, CSRF validation, authorization appropriate to lead creation, and throttling. Anonymous visitors must not be able to spend the service quota.
  • Redact credentials: because the required authentication mechanism is a query parameter, never log the complete outgoing URL. The service class logs only status, attempt, reason, and a locally generated correlation ID.
  • Limit input: accept only HTTP or HTTPS URLs, cap their length, and reject obvious local, private, and reserved targets.
  • Measure useful signals: monitor latency, rate-limit responses, transient failures, invalid schemas, and authentication failures. Alert on sustained changes, not an isolated bad website.
  • Preserve review: enrichment should prefill a draft, not silently create or overwrite a lead. A human should confirm the result before the normal save operation.

In production, inject WEBSITE_COMPANY_DATA_TOKEN through the platform’s secret manager, allow outbound HTTPS access to ai.mihajlo.mk, and rebuild Laravel’s configuration cache:

php artisan optimize:clear
php artisan config:cache
php artisan test

A missing token usually indicates stale cached configuration. A 401 or 403 points to an invalid, revoked, or incorrectly deployed service token. A 422 means the submitted website was rejected. A 429 indicates quota or rate limiting; the integration retries briefly and then returns a structured failure. Repeated 5xx responses or connection timeouts should be treated as dependency availability problems, not as reasons to submit unlimited retries.

Final verification checklist

  • The salesperson must be authenticated and authorized to create leads.
  • Entering a valid company website triggers exactly one prefill interaction.
  • The request uses the exact GET endpoint with website and token query parameters.
  • Company, contact, email, phone, and people are mapped at the API boundary.
  • Existing form values are never overwritten.
  • Malformed responses, timeouts, authentication failures, rate limits, and upstream outages produce structured failure states.
  • No token or complete authenticated URL appears in logs, tests, source control, or browser responses.
  • The automated tests pass after production configuration is cached.

The most valuable part of this feature is not that it removes a few keystrokes. It changes the lead form from an empty administrative chore into a reviewable draft. With a narrow API boundary, cautious retries, and human confirmation, a single company website becomes enough to start useful CRM work without turning enrichment data into unquestioned truth.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.