Tutorials

Native PHP: Automatically Pre-fill CRM Leads from Company Websites

Native PHP: Automatically Pre-fill CRM Leads from Company Websites

A salesperson should not have to copy a company name, phone number, email address, contact details, and team information from a website into a CRM one field at a time. A better workflow asks for the website once, enriches the draft lead, and leaves the human to verify the result.

This tutorial builds that workflow in Native PHP 8.3. The application exposes a small JSON endpoint that validates a submitted website, calls the Website to Company data service, maps the response into a domain object, and returns CRM-ready fields. The integration includes bounded timeouts, selective retries, structured errors, safe logging, and deterministic PHPUnit tests.

Get access and copy the service token

Before writing integration code, register an account or sign in. Open the Website to Company data service page, choose an 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 shown there. This service is not token-free: every request must send that credential through the token query parameter.

Regenerating the token revokes the previously active token. Treat regeneration as a credential rotation: update the application environment and redeploy every instance that uses the old value.

The exact API operation is GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Confirm access with a minimal request:

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

The request sends only the documented token and website parameters. Never paste a real token into source control, test fixtures, screenshots, or support messages.

Choose a deliberately small architecture

The CRM should call our application, not the external API directly. Keeping the token server-side prevents browser exposure and gives us one boundary for validation, response mapping, retries, and telemetry.

The project has four responsibilities:

  • Controller: accepts the salesperson’s website and returns JSON.
  • Client: applies the API contract and retry policy.
  • Transport: performs the native cURL request.
  • Domain mapper: converts external data into a stable CRM-facing object.
crm-prefill/
├── composer.json
├── .env
├── config/bootstrap.php
├── public/index.php
├── src/
│   ├── Company/CompanyProfile.php
│   ├── Company/IntegrationFailure.php
│   ├── Company/WebsiteCompanyClient.php
│   └── Http/
│       ├── CurlTransport.php
│       └── Transport.php
└── tests/WebsiteCompanyClientTest.php

Create the Composer configuration and install PHPUnit. PHP’s cURL extension is the only production dependency.

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}
composer install
composer dump-autoload
php -m | grep curl

Keep configuration outside the application

For local development, add .env to .gitignore and store the copied token there:

WEBSITE_COMPANY_TOKEN="YOUR_SERVICE_TOKEN"

Native PHP does not load environment files automatically. The following bootstrap supports a simple local file while allowing production environment variables to take precedence:

<?php
// config/bootstrap.php

declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';

$localFile = dirname(__DIR__) . '/.env';

if (is_file($localFile)) {
    $values = parse_ini_file($localFile, false, INI_SCANNER_RAW);

    if ($values === false) {
        throw new RuntimeException('Unable to parse .env');
    }

    foreach ($values as $name => $value) {
        if (getenv((string) $name) === false) {
            putenv($name . '=' . $value);
        }
    }
}

$token = getenv('WEBSITE_COMPANY_TOKEN');

if (!is_string($token) || trim($token) === '') {
    throw new RuntimeException('WEBSITE_COMPANY_TOKEN is not configured');
}

return ['service_token' => $token];

On production PHP-FPM hosts, inject WEBSITE_COMPANY_TOKEN through the process manager or secret store instead of deploying .env. If a local file is retained, keep it outside the public document root with restrictive permissions.

Build a bounded native cURL transport

The transport follows redirects neither for convenience nor recovery. That prevents accidentally forwarding the query-string credential to another host. Connection and total timeouts ensure a slow enrichment call cannot occupy a PHP worker indefinitely.

<?php
// src/Http/Transport.php

declare(strict_types=1);

namespace App\Http;

interface Transport
{
    /**
     * @return array{
     *   status: int,
     *   headers: array<string,string>,
     *   body: string
     * }
     */
    public function get(
        string $url,
        array $query,
        int $connectTimeoutMs,
        int $timeoutMs
    ): array;
}
<?php
// src/Http/CurlTransport.php

declare(strict_types=1);

namespace App\Http;

use RuntimeException;

final class CurlTransport implements Transport
{
    public function get(
        string $url,
        array $query,
        int $connectTimeoutMs,
        int $timeoutMs
    ): array {
        $headers = [];
        $requestUrl = $url . '?' . http_build_query(
            $query,
            '',
            '&',
            PHP_QUERY_RFC3986
        );

        $handle = curl_init($requestUrl);

        if ($handle === false) {
            throw new RuntimeException('Unable to initialize cURL');
        }

        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $timeoutMs,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
            CURLOPT_HEADERFUNCTION => static function (
                $curl,
                string $line
            ) use (&$headers): int {
                $length = strlen($line);
                $position = strpos($line, ':');

                if ($position !== false) {
                    $name = strtolower(trim(substr($line, 0, $position)));
                    $headers[$name] = trim(substr($line, $position + 1));
                }

                return $length;
            },
        ]);

        $body = curl_exec($handle);

        if ($body === false) {
            throw new RuntimeException('Network error: ' . curl_error($handle));
        }

        return [
            'status' => (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
            'headers' => $headers,
            'body' => $body,
        ];
    }
}

Map the API at the application boundary

The service returns company, contact, email, phone, and people data. External JSON must not leak unchecked throughout the CRM, so the mapper normalizes nullable scalar fields and verifies that people is an array. Unexpected shapes become explicit schema failures rather than silently populating incorrect fields.

<?php
// src/Company/CompanyProfile.php

declare(strict_types=1);

namespace App\Company;

final readonly class CompanyProfile
{
    public function __construct(
        public ?string $company,
        public ?string $contact,
        public ?string $email,
        public ?string $phone,
        public array $people
    ) {}

    public static function fromApi(array $data): self
    {
        $people = $data['people'] ?? [];

        if (!is_array($people)) {
            throw new IntegrationFailure(
                'invalid_schema',
                'The people field is not an array.'
            );
        }

        return new self(
            self::text($data, 'company'),
            self::text($data, 'contact'),
            self::text($data, 'email'),
            self::text($data, 'phone'),
            array_values($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(array $data, string $key): ?string
    {
        $value = $data[$key] ?? null;

        if ($value === null || $value === '') {
            return null;
        }

        if (!is_scalar($value)) {
            throw new IntegrationFailure(
                'invalid_schema',
                "The {$key} field is not scalar."
            );
        }

        return trim((string) $value);
    }
}
<?php
// src/Company/IntegrationFailure.php

declare(strict_types=1);

namespace App\Company;

use RuntimeException;

final class IntegrationFailure extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        string $message,
        public readonly ?int $upstreamStatus = null
    ) {
        parent::__construct($message);
    }
}

Add selective retries and safe telemetry

Authentication and rejected-request failures are deterministic, so retrying them only wastes quota. Network errors, HTTP 429 responses, and server-side 5xx failures may be transient. The client retries those conditions at most twice after the first attempt, respects a numeric Retry-After value up to two seconds, and otherwise uses capped exponential backoff with jitter.

<?php
// src/Company/WebsiteCompanyClient.php

declare(strict_types=1);

namespace App\Company;

use App\Http\Transport;
use Closure;
use JsonException;
use RuntimeException;

final class WebsiteCompanyClient
{
    private Closure $pause;
    private Closure $log;

    public function __construct(
        private readonly Transport $transport,
        private readonly string $token,
        ?Closure $pause = null,
        ?Closure $log = null
    ) {
        $this->pause = $pause ?? static fn(int $ms) => usleep($ms * 1000);
        $this->log = $log ?? static function (array $context): void {
            error_log((string) json_encode($context, JSON_UNESCAPED_SLASHES));
        };
    }

    public function extract(string $website): CompanyProfile
    {
        $correlationId = bin2hex(random_bytes(8));

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->get(
                    'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
                    ['token' => $this->token, 'website' => $website],
                    2000,
                    8000
                );
            } catch (RuntimeException $error) {
                ($this->log)([
                    'event' => 'company_enrichment_network_error',
                    'correlation_id' => $correlationId,
                    'attempt' => $attempt,
                ]);

                if ($attempt === 3) {
                    throw new IntegrationFailure(
                        'network',
                        'The enrichment service could not be reached.'
                    );
                }

                ($this->pause)($this->backoff($attempt));
                continue;
            }

            $status = $response['status'];

            if ($status >= 200 && $status < 300) {
                try {
                    $payload = json_decode(
                        $response['body'],
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );
                } catch (JsonException) {
                    throw new IntegrationFailure(
                        'invalid_schema',
                        'The enrichment service returned invalid JSON.',
                        $status
                    );
                }

                if (!is_array($payload)) {
                    throw new IntegrationFailure(
                        'invalid_schema',
                        'The enrichment response is not an object.',
                        $status
                    );
                }

                return CompanyProfile::fromApi($payload);
            }

            ($this->log)([
                'event' => 'company_enrichment_http_error',
                'correlation_id' => $correlationId,
                'attempt' => $attempt,
                'upstream_status' => $status,
            ]);

            if ($status === 401 || $status === 403) {
                throw new IntegrationFailure(
                    'authentication',
                    'The service token was rejected.',
                    $status
                );
            }

            $retryable = $status === 429 || $status >= 500;

            if (!$retryable) {
                throw new IntegrationFailure(
                    'request_rejected',
                    'The enrichment request was rejected.',
                    $status
                );
            }

            if ($attempt === 3) {
                $kind = $status === 429 ? 'rate_limited' : 'upstream';
                throw new IntegrationFailure(
                    $kind,
                    'The enrichment service is temporarily unavailable.',
                    $status
                );
            }

            ($this->pause)($this->delay($attempt, $response['headers']));
        }

        throw new IntegrationFailure('upstream', 'Enrichment failed.');
    }

    private function delay(int $attempt, array $headers): int
    {
        $retryAfter = $headers['retry-after'] ?? null;

        if (is_string($retryAfter) && ctype_digit($retryAfter)) {
            return min(2000, (int) $retryAfter * 1000);
        }

        return $this->backoff($attempt);
    }

    private function backoff(int $attempt): int
    {
        return min(2000, 200 * (2 ** ($attempt - 1)) + random_int(0, 100));
    }
}

The logs contain an event name, local correlation ID, attempt number, and status. They intentionally omit the token, complete request URL, response body, and submitted website. Because authentication uses a query parameter, reverse proxies and tracing systems must also be configured to redact query strings.

Expose the CRM prefill endpoint

The controller accepts JSON such as {"website":"https://example.com"}. It permits only HTTP and HTTPS URLs with a host, then translates integration failures into stable application-level states.

<?php
// public/index.php

declare(strict_types=1);

use App\Company\IntegrationFailure;
use App\Company\WebsiteCompanyClient;
use App\Http\CurlTransport;

$config = require dirname(__DIR__) . '/config/bootstrap.php';

header('Content-Type: application/json; charset=utf-8');

if ($_SERVER['REQUEST_METHOD'] !== 'POST'
    || parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) !== '/lead-prefill') {
    http_response_code(404);
    echo '{"error":{"kind":"not_found"}}';
    exit;
}

try {
    $input = json_decode(
        file_get_contents('php://input'),
        true,
        32,
        JSON_THROW_ON_ERROR
    );

    $website = is_array($input) ? ($input['website'] ?? null) : null;
    $valid = is_string($website)
        ? filter_var($website, FILTER_VALIDATE_URL)
        : false;
    $scheme = $valid !== false ? parse_url($website, PHP_URL_SCHEME) : null;
    $host = $valid !== false ? parse_url($website, PHP_URL_HOST) : null;

    if ($valid === false
        || !in_array($scheme, ['http', 'https'], true)
        || !is_string($host)
        || $host === '') {
        http_response_code(422);
        echo json_encode([
            'error' => [
                'kind' => 'validation',
                'message' => 'Supply a complete HTTP or HTTPS company website.',
            ],
        ], JSON_THROW_ON_ERROR);
        exit;
    }

    $client = new WebsiteCompanyClient(
        new CurlTransport(),
        $config['service_token']
    );

    echo json_encode([
        'data' => $client->extract($website)->toArray(),
    ], JSON_THROW_ON_ERROR);
} catch (JsonException) {
    http_response_code(400);
    echo '{"error":{"kind":"invalid_json"}}';
} catch (IntegrationFailure $failure) {
    $status = match ($failure->kind) {
        'rate_limited', 'network' => 503,
        default => 502,
    };

    http_response_code($status);
    echo json_encode([
        'error' => [
            'kind' => $failure->kind,
            'message' => $failure->getMessage(),
        ],
    ], JSON_THROW_ON_ERROR);
}

The CRM can place the returned values into an unsaved lead form. Keep them editable: public websites can be incomplete or outdated, and enrichment should assist the salesperson rather than silently become authoritative data.

Test without making external requests

A fake transport makes success, retries, and authentication failures deterministic. Tests must never use a live token or consume plan quota.

<?php
// tests/WebsiteCompanyClientTest.php

declare(strict_types=1);

use App\Company\IntegrationFailure;
use App\Company\WebsiteCompanyClient;
use App\Http\Transport;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements Transport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function get(
        string $url,
        array $query,
        int $connectTimeoutMs,
        int $timeoutMs
    ): array {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class WebsiteCompanyClientTest extends TestCase
{
    public function testMapsAProfile(): void
    {
        $transport = new FakeTransport([[
            'status' => 200,
            'headers' => [],
            'body' => json_encode([
                'company' => 'Example Ltd',
                'contact' => 'Sales',
                'email' => '[email protected]',
                'phone' => '+1 555 0100',
                'people' => [['name' => 'Alex']],
            ], JSON_THROW_ON_ERROR),
        ]]);

        $client = new WebsiteCompanyClient(
            $transport,
            'test-token',
            static fn(int $ms) => null,
            static fn(array $context) => null
        );

        $profile = $client->extract('https://example.com');

        self::assertSame('Example Ltd', $profile->company);
        self::assertSame('[email protected]', $profile->email);
        self::assertCount(1, $profile->people);
    }

    public function testRetriesAServiceFailureThenSucceeds(): void
    {
        $transport = new FakeTransport([
            ['status' => 503, 'headers' => [], 'body' => ''],
            ['status' => 200, 'headers' => [], 'body' => '{}'],
        ]);

        $client = new WebsiteCompanyClient(
            $transport,
            'test-token',
            static fn(int $ms) => null,
            static fn(array $context) => null
        );

        $client->extract('https://example.com');

        self::assertSame(2, $transport->calls);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $transport = new FakeTransport([[
            'status' => 401,
            'headers' => [],
            'body' => '',
        ]]);

        $client = new WebsiteCompanyClient(
            $transport,
            'test-token',
            static fn(int $ms) => null,
            static fn(array $context) => null
        );

        try {
            $client->extract('https://example.com');
            self::fail('Expected an IntegrationFailure');
        } catch (IntegrationFailure $failure) {
            self::assertSame('authentication', $failure->kind);
            self::assertSame(1, $transport->calls);
        }
    }
}
vendor/bin/phpunit tests
php -S 127.0.0.1:8080 -t public
curl --fail-with-body \
  -H 'Content-Type: application/json' \
  -d '{"website":"https://example.com"}' \
  http://127.0.0.1:8080/lead-prefill

Production hardening and deployment

Run tests before installing production dependencies, then deploy behind a configured PHP-FPM web server with public as the document root:

vendor/bin/phpunit tests
composer install --no-dev --classmap-authoritative
php -r 'exit(extension_loaded("curl") ? 0 : 1);'

Set application and proxy request deadlines slightly above the client’s eight-second timeout. Track success, validation failure, authentication failure, rate limiting, upstream failure, latency, and retry counts. Alert on sustained authentication failures because they commonly indicate an expired, regenerated, or incorrectly deployed token.

Do not cache enrichment indiscriminately. If repeated lookups are common, cache by a normalized website host for a short business-approved period, and consider whether contact details are personal data under your retention policy. Apply CRM authorization and CSRF protection where the surrounding application requires them.

Common failure modes

  • HTTP 401 or 403: verify activation and the current service-scoped token; do not retry automatically.
  • HTTP 429: the plan’s available capacity may be exhausted or temporarily limited; preserve the lead draft and let the user retry later.
  • Timeouts or 5xx responses: retry only within the bounded policy, then return a recoverable CRM error.
  • Invalid schema: retain the sanitized failure metadata and compare the response with the official documentation; never force unexpected arrays into text fields.
  • Empty fields: treat them as legitimate partial enrichment, not as a failed request.

Final verification checklist

  • The service plan is active and the current token comes from environment-backed configuration.
  • The request uses the documented GET endpoint with only token and website query parameters.
  • Redirects are disabled, timeouts are bounded, and only transient failures are retried.
  • Company, contact, email, phone, and people data are mapped at the API boundary.
  • Logs and tests contain no real credentials or response data.
  • The CRM receives editable prefill values and preserves the salesperson’s draft when enrichment fails.
  • Success, authentication errors, rate limiting, latency, and retry activity are observable after deployment.

The important result is not merely fewer keystrokes. It is a clean boundary between an external enrichment service and the CRM’s own data model. With that boundary in place, one company website becomes a useful lead draft without turning transient network behavior, changing public information, or sensitive credentials into hidden operational risk.

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.