Symfony: Prefill CRM Leads Instantly with Website-to-Company Data API
A blank CRM lead is a small productivity trap. A salesperson knows the company’s website, but still has to copy its name, contact details, and people into separate fields before the real work can begin.
This tutorial replaces that friction with one Symfony endpoint. The browser submits a company website, Symfony calls the Website to Company data service, validates the response at the integration boundary, and returns a safe lead draft for review. The design is synchronous because a salesperson is waiting for the form, but it still includes bounded timeouts, selective retries, structured errors, tests, and production logging.
Get access and copy the service token
- Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
- Open the Website to Company data service page.
- Choose the 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 a token. Regenerating it revokes the previously active token, so coordinate rotation with deployment: update the production secret first, deploy or restart the application, and verify the integration before removing any temporary operational safeguards.
The exact request is an HTTP GET to https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Authentication uses the token query parameter, while the company site is supplied as website. Confirm access with a disposable shell variable so the credential does not enter source control:
export WEBSITE_COMPANY_TOKEN='YOUR_SERVICE_TOKEN'
curl --fail-with-body --get \
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract' \
--data-urlencode "token=${WEBSITE_COMPANY_TOKEN}" \
--data-urlencode 'website=https://example.com'
Be aware that command history and process inspection can expose command-line values. Use this only as a local verification method with appropriate shell controls. The application will read the token from environment-backed configuration.
Create the Symfony project
The implementation needs Symfony’s HTTP client, validation support, logging bundle, and test utilities. PHP 8.3 or later and Composer are the only local prerequisites.
composer create-project symfony/skeleton crm-prefill
cd crm-prefill
composer require symfony/http-client symfony/validator symfony/monolog-bundle
composer require --dev symfony/test-pack
The resulting feature has three deliberate layers:
CompanyEnrichmentconverts untrusted JSON into a stable application shape.WebsiteCompanyClientowns authentication, timeouts, retries, and upstream failures.LeadPrefillControllervalidates browser input and translates integration outcomes into HTTP responses.
This is enough architecture for a small CRM without introducing Messenger, a database, or a queue. A background job would make sense for batch imports, but it would make an interactive form slower and more complicated.
Configure the environment and dependency injection
Put a harmless placeholder in .env, which documents the required variable, and keep the real development token in .env.local. Symfony excludes .env.local from normal source-control workflows.
# .env
WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
# .env.local
WEBSITE_COMPANY_TOKEN=replace-with-your-development-token
Bind the credential and fixed endpoint explicitly in config/services.yaml:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Integration\WebsiteCompanyClient:
arguments:
$serviceToken: '%env(string:WEBSITE_COMPANY_TOKEN)%'
$endpoint: 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract'
Map external data at the boundary
The service contract exposes company, contact, email, phone, and people data. Do not let the controller or CRM template depend on unchecked response values. Missing optional fields should produce empty values; an invalid response root should fail explicitly.
<?php
// src/Integration/CompanyEnrichment.php
namespace App\Integration;
final readonly class CompanyEnrichment
{
public function __construct(
public array $company,
public array $contact,
public ?string $email,
public ?string $phone,
public array $people,
) {
}
public static function fromPayload(array $payload): self
{
return new self(
company: self::object($payload['company'] ?? null),
contact: self::object($payload['contact'] ?? null),
email: self::text($payload['email'] ?? null),
phone: self::text($payload['phone'] ?? null),
people: self::people($payload['people'] ?? null),
);
}
public function toArray(): array
{
return [
'company' => $this->company,
'contact' => $this->contact,
'email' => $this->email,
'phone' => $this->phone,
'people' => $this->people,
];
}
private static function object(mixed $value): array
{
return is_array($value) && !array_is_list($value) ? $value : [];
}
private static function text(mixed $value): ?string
{
if (!is_string($value)) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}
private static function people(mixed $value): array
{
if (!is_array($value)) {
return [];
}
return array_values(array_filter($value, 'is_array'));
}
}
This mapper intentionally makes no assumptions about undocumented nested company or person fields. The UI may inspect the mapped arrays, but it should treat them as enrichment suggestions rather than verified facts.
Build a resilient HTTP client
Retries are useful only for transient failures. The client retries transport problems and HTTP 429, 502, 503, or 504 responses. It does not retry authentication or request-validation failures. Three total attempts, short backoff, a three-second inactivity timeout, and an eight-second total request limit keep the salesperson’s wait bounded.
<?php
// src/Integration/IntegrationException.php
namespace App\Integration;
final class IntegrationException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly ?int $upstreamStatus = null,
string $message = 'Company enrichment failed.',
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
<?php
// src/Integration/WebsiteCompanyClient.php
namespace App\Integration;
use JsonException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
final class WebsiteCompanyClient
{
private const RETRYABLE = [429, 502, 503, 504];
public function __construct(
private readonly HttpClientInterface $http,
private readonly LoggerInterface $logger,
private readonly string $serviceToken,
private readonly string $endpoint,
) {
}
public function extract(string $website): CompanyEnrichment
{
$host = parse_url($website, PHP_URL_HOST) ?: 'unknown';
for ($attempt = 1; $attempt <= 3; ++$attempt) {
try {
$response = $this->http->request('GET', $this->endpoint, [
'query' => [
'token' => $this->serviceToken,
'website' => $website,
],
'headers' => ['Accept' => 'application/json'],
'timeout' => 3.0,
'max_duration' => 8.0,
]);
$status = $response->getStatusCode();
if ($status >= 200 && $status < 300) {
return $this->decode($response);
}
$this->logger->warning('Company enrichment rejected', [
'host' => $host,
'attempt' => $attempt,
'upstream_status' => $status,
]);
if (in_array($status, self::RETRYABLE, true) && $attempt < 3) {
$this->pause($response, $attempt);
continue;
}
$kind = match (true) {
$status === 429 => 'rate_limited',
in_array($status, [401, 403], true) => 'authentication',
in_array($status, [400, 422], true) => 'request_rejected',
default => 'upstream_failure',
};
throw new IntegrationException($kind, $status);
} catch (TransportExceptionInterface $error) {
$this->logger->warning('Company enrichment transport failure', [
'host' => $host,
'attempt' => $attempt,
]);
if ($attempt === 3) {
throw new IntegrationException(
'unavailable',
previous: $error,
);
}
usleep($attempt === 1 ? 200_000 : 500_000);
}
}
throw new IntegrationException('unavailable');
}
private function decode(ResponseInterface $response): CompanyEnrichment
{
try {
$payload = json_decode(
$response->getContent(false),
true,
512,
JSON_THROW_ON_ERROR,
);
} catch (JsonException $error) {
throw new IntegrationException(
'invalid_response',
$response->getStatusCode(),
previous: $error,
);
}
if (!is_array($payload) || array_is_list($payload)) {
throw new IntegrationException(
'invalid_response',
$response->getStatusCode(),
);
}
return CompanyEnrichment::fromPayload($payload);
}
private function pause(ResponseInterface $response, int $attempt): void
{
$retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null;
if (is_string($retryAfter) && is_numeric($retryAfter)) {
usleep((int) (min(2.0, max(0.0, (float) $retryAfter)) * 1_000_000));
return;
}
usleep($attempt === 1 ? 200_000 : 500_000);
}
}
The logger records the hostname, attempt, and status, but never the token, full query string, response body, email, phone, or people data. Because the authentication contract puts the token in the query, proxy and access-log query-string redaction is especially important.
Expose the lead-prefill endpoint
The controller accepts JSON such as {"website":"https://example.com"}. It checks the scheme, hostname, credentials, and length before calling the service.
<?php
// src/Controller/LeadPrefillController.php
namespace App\Controller;
use App\Integration\IntegrationException;
use App\Integration\WebsiteCompanyClient;
use JsonException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class LeadPrefillController
{
#[Route('/crm/leads/prefill', methods: ['POST'])]
public function __invoke(
Request $request,
WebsiteCompanyClient $client,
): JsonResponse {
try {
$input = json_decode($request->getContent(), true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return new JsonResponse(['error' => 'invalid_json'], 400);
}
$website = is_array($input) ? ($input['website'] ?? null) : null;
$parts = is_string($website) ? parse_url($website) : false;
$valid = is_string($website)
&& strlen($website) <= 2048
&& filter_var($website, FILTER_VALIDATE_URL) !== false
&& is_array($parts)
&& in_array($parts['scheme'] ?? null, ['http', 'https'], true)
&& isset($parts['host'])
&& !isset($parts['user'], $parts['pass']);
if (!$valid) {
return new JsonResponse(['error' => 'invalid_website'], 422);
}
try {
$enrichment = $client->extract($website);
} catch (IntegrationException $error) {
$status = match ($error->kind) {
'request_rejected' => 422,
'rate_limited', 'unavailable', 'upstream_failure' => 503,
default => 502,
};
return new JsonResponse(['error' => $error->kind], $status);
}
return new JsonResponse([
'website' => $website,
'prefill' => $enrichment->toArray(),
]);
}
}
Keep the lead as a draft until the salesperson confirms it. Enrichment should prefill fields, not silently overwrite a person’s edits or turn third-party data into an authoritative CRM record.
Test without making real API calls
MockHttpClient gives the integration a deterministic transport. The test also verifies that both required query parameters reach the correct endpoint.
<?php
// tests/Integration/WebsiteCompanyClientTest.php
namespace App\Tests\Integration;
use App\Integration\WebsiteCompanyClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class WebsiteCompanyClientTest extends TestCase
{
public function testItMapsACompanyResponse(): void
{
$callback = function (string $method, string $url): MockResponse {
self::assertSame('GET', $method);
self::assertSame(
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
strtok($url, '?'),
);
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
self::assertSame('test-token', $query['token']);
self::assertSame('https://example.com', $query['website']);
return new MockResponse(json_encode([
'company' => ['name' => 'Example Company'],
'contact' => ['location' => 'Example City'],
'email' => '[email protected]',
'phone' => '+1 555 0100',
'people' => [['name' => 'Alex Example']],
], JSON_THROW_ON_ERROR), [
'http_code' => 200,
'response_headers' => ['content-type: application/json'],
]);
};
$client = new WebsiteCompanyClient(
new MockHttpClient($callback),
new NullLogger(),
'test-token',
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
);
$result = $client->extract('https://example.com');
self::assertSame('Example Company', $result->company['name']);
self::assertSame('[email protected]', $result->email);
self::assertCount(1, $result->people);
}
}
php bin/phpunit
symfony server:start
curl --request POST 'http://127.0.0.1:8000/crm/leads/prefill' \
--header 'Content-Type: application/json' \
--data '{"website":"https://example.com"}'
Security, observability, and deployment
Protect the CRM route with the application’s normal authentication and authorization rules. Add per-user throttling at the application or gateway layer, and consider CSRF protection if an HTML form submits through a cookie-authenticated session. Never return upstream bodies directly to the browser.
For observability, graph request duration and counts by outcome: success, invalid input, authentication failure, rate limit, invalid response, and unavailable upstream. Alert on sustained authentication failures because they commonly indicate an expired, regenerated, or incorrectly deployed token. Avoid high-cardinality labels such as full URLs and never attach enriched personal data to traces.
In production, inject WEBSITE_COMPANY_TOKEN through the hosting platform’s secret manager or protected environment configuration. Do not bake it into an image or committed environment file. After changing the value, restart long-running PHP workers or application containers so they receive the new environment.
Common failure paths
- 401 or 403: verify the service-scoped token and plan activation. Do not retry blindly.
- 400 or 422: confirm that
websiteis a public HTTP or HTTPS URL and that the request uses the documented parameter names. - 429: respect the bounded retry behavior, inspect plan usage, and ask users to try again later.
- 502, 503, 504, or transport errors: preserve the CRM form and offer a manual retry instead of losing entered data.
- Invalid JSON or unexpected field types: treat the upstream response as invalid; do not guess or persist partially trusted raw data.
Final verification checklist
- The salesperson can submit only a company website and receive a lead draft.
- The exact
GETendpoint receivestokenandwebsitequery parameters. - Company, contact, email, phone, and people data are mapped at one application boundary.
- Timeouts and retries are bounded, while authentication and validation failures are not retried.
- Tests run without network access or real credentials.
- Logs, traces, fixtures, and source code contain no service token or enriched personal data.
- The CRM requires human confirmation before saving the prefilled values.
The best enrichment feature does not feel like a data pipeline. It feels like the form already understands the company the salesperson is about to contact. A narrow Symfony boundary, defensive mapping, and disciplined failure handling make that instant convenience safe enough to operate long after the first successful demo.