Symfony: Enrich Quote Requests with Company Data via AI Without Lag
A quote form should feel immediate. The visitor clicks submit, receives confirmation, and moves on. Company enrichment is valuable, but making an external website-analysis request inside that HTTP cycle turns a simple form into a slow and fragile dependency chain.
The production-friendly design is straightforward: persist the quote first, enqueue an enrichment message, and let a Symfony Messenger worker call the Website to Company data service. The form remains fast even when the remote service is slow, rate-limited, or temporarily unavailable.
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 its service-scoped token.
This service requires a token. If you regenerate it, the previously active token is revoked, so deploy the replacement everywhere that runs the integration before relying on it. Never place the real value in PHP source, a test fixture, a screenshot, or a committed environment file.
Confirm the exact API contract
The integration uses GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Authentication is supplied through the token query parameter, while the public company URL is supplied through website.
Run one minimal request from a trusted terminal, substituting temporary shell placeholders rather than committing credentials:
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'
Store the credential in Symfony’s environment-backed configuration. Use .env.local during local development; production should inject the variable through its secret manager or process environment.
# .env.local
MIHAJLO_COMPANY_DATA_TOKEN=YOUR_SERVICE_TOKEN
Design the asynchronous Symfony workflow
This implementation expects PHP 8.3 or later, a Symfony application with Doctrine configured, and a database supported by Doctrine DBAL. Install Symfony’s HTTP client and Messenger integration:
composer require symfony/http-client symfony/messenger \
symfony/orm-pack symfony/doctrine-messenger
php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction
The resulting path is deliberately small:
- The controller validates and persists the quote request.
- It dispatches a message containing only the database identifier.
- The worker loads the record and calls the external API.
- An application-boundary mapper retains only
company,contact,email,phone, andpeople. - The worker records either an enriched profile or a structured failure state.
Dispatch happens after Doctrine flushes the quote, preventing a fast worker from attempting to load a row that has not been committed yet. The trade-off is eventual consistency: confirmation is immediate, while enrichment becomes visible shortly afterward.
A compact project layout is enough:
src/
Controller/QuoteController.php
Entity/QuoteRequest.php
Message/EnrichQuoteRequest.php
MessageHandler/EnrichQuoteRequestHandler.php
Service/CompanyData.php
Service/CompanyDataClient.php
Service/CompanyDataFailure.php
tests/
Service/CompanyDataClientTest.php
config/packages/messenger.yaml
config/services.yaml
Configure dependency injection and Messenger
# config/services.yaml
services:
App\Service\CompanyDataClient:
arguments:
$token: '%env(MIHAJLO_COMPANY_DATA_TOKEN)%'
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: 'doctrine://default?queue_name=quote_enrichment'
retry_strategy:
max_retries: 1
delay: 1000
failed:
dsn: 'doctrine://default?queue_name=failed_quote_enrichment'
routing:
App\Message\EnrichQuoteRequest: async
The client performs its own bounded remote-call retries. Messenger’s single retry is reserved for worker-level failures such as a lost database connection or terminated process. This separation prevents multiple retry layers from multiplying requests unexpectedly.
Build a defensive API boundary
Remote JSON should not leak directly into controllers or entities. The mapper below recognizes only the five documented data areas and sanitizes values into JSON-compatible types. Missing or differently shaped optional values become null rather than causing notices deep inside the application.
<?php
// src/Service/CompanyData.php
namespace App\Service;
final readonly class CompanyData
{
private function __construct(private array $fields) {}
public static function fromApi(array $payload): self
{
$fields = [];
foreach (['company', 'contact', 'email', 'phone', 'people'] as $name) {
$fields[$name] = self::jsonValue($payload[$name] ?? null);
}
return new self($fields);
}
public function toArray(): array
{
return $this->fields;
}
private static function jsonValue(mixed $value): mixed
{
if ($value === null || is_scalar($value)) {
return $value;
}
if (is_array($value)) {
foreach ($value as $key => $item) {
$value[$key] = self::jsonValue($item);
}
return $value;
}
return null;
}
}
// src/Service/CompanyDataFailure.php
namespace App\Service;
final class CompanyDataFailure extends \RuntimeException
{
public function __construct(public readonly string $kind)
{
parent::__construct($kind);
}
}
The HTTP client uses finite connection and overall response limits. It retries transport failures, HTTP 429 responses, and server errors only. Authentication and other client errors fail immediately because repeating an invalid request wastes quota and worker capacity.
<?php
// src/Service/CompanyDataClient.php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class CompanyDataClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract';
public function __construct(
private HttpClientInterface $http,
private LoggerInterface $logger,
private string $token,
) {}
public function extract(string $website): CompanyData
{
for ($attempt = 1; $attempt <= 3; ++$attempt) {
try {
$response = $this->http->request('GET', self::ENDPOINT, [
'query' => [
'token' => $this->token,
'website' => $website,
],
'timeout' => 5.0,
'max_duration' => 10.0,
]);
$status = $response->getStatusCode();
$body = $response->getContent(false);
} catch (TransportExceptionInterface $exception) {
$this->logger->warning('Company enrichment transport failure.', [
'attempt' => $attempt,
]);
if ($attempt === 3) {
throw new CompanyDataFailure('transport');
}
$this->backoff($attempt);
continue;
}
if ($status === 200) {
try {
$payload = json_decode(
$body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException) {
throw new CompanyDataFailure('invalid_json');
}
if (!is_array($payload)) {
throw new CompanyDataFailure('invalid_payload');
}
return CompanyData::fromApi($payload);
}
if ($status === 401 || $status === 403) {
throw new CompanyDataFailure('authentication');
}
if ($status === 429 || $status >= 500) {
$this->logger->warning('Company enrichment deferred.', [
'attempt' => $attempt,
'status' => $status,
]);
if ($attempt < 3) {
$this->backoff($attempt);
continue;
}
throw new CompanyDataFailure(
$status === 429 ? 'rate_limited' : 'upstream'
);
}
throw new CompanyDataFailure('request_rejected');
}
throw new CompanyDataFailure('unavailable');
}
private function backoff(int $attempt): void
{
$milliseconds = min(2000, 250 * (2 ** ($attempt - 1)));
usleep($milliseconds * 1000);
}
}
The short exponential delay runs inside a background worker, never inside the visitor’s request. Three attempts place a firm ceiling on both latency and service consumption. A persistent 429 becomes rate_limited instead of an infinite retry loop.
Persist first, enrich afterward
The quote entity needs a JSON column for the mapped result and explicit lifecycle fields such as queued, processing, enriched, and failed. The failure value should be a controlled category, not a raw exception that could expose URLs, credentials, or response content.
<?php
// src/Message/EnrichQuoteRequest.php
namespace App\Message;
final readonly class EnrichQuoteRequest
{
public function __construct(public int $quoteId) {}
}
// Relevant methods on src/Entity/QuoteRequest.php
public function beginEnrichment(): void
{
$this->enrichmentStatus = 'processing';
$this->enrichmentError = null;
}
public function completeEnrichment(array $data): void
{
$this->companyData = $data;
$this->enrichmentStatus = 'enriched';
$this->enrichmentError = null;
}
public function failEnrichment(string $kind): void
{
$this->enrichmentStatus = 'failed';
$this->enrichmentError = $kind;
}
// src/MessageHandler/EnrichQuoteRequestHandler.php
namespace App\MessageHandler;
use App\Entity\QuoteRequest;
use App\Message\EnrichQuoteRequest;
use App\Service\CompanyDataClient;
use App\Service\CompanyDataFailure;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class EnrichQuoteRequestHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
private CompanyDataClient $client,
) {}
public function __invoke(EnrichQuoteRequest $message): void
{
$quote = $this->entityManager
->getRepository(QuoteRequest::class)
->find($message->quoteId);
if (!$quote instanceof QuoteRequest) {
return;
}
$quote->beginEnrichment();
$this->entityManager->flush();
try {
$data = $this->client->extract($quote->getWebsite());
$quote->completeEnrichment($data->toArray());
} catch (CompanyDataFailure $failure) {
$quote->failEnrichment($failure->kind);
}
$this->entityManager->flush();
}
}
The controller validates an HTTP or HTTPS URL, rejects embedded credentials, saves the quote, and returns 202 Accepted. For a browser form, retain Symfony Form’s CSRF protection; an API-facing version should use the application’s normal authentication and authorization policy.
<?php
// src/Controller/QuoteController.php
namespace App\Controller;
use App\Entity\QuoteRequest;
use App\Message\EnrichQuoteRequest;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
final class QuoteController extends AbstractController
{
#[Route('/quotes', methods: ['POST'])]
public function create(
Request $request,
EntityManagerInterface $entityManager,
MessageBusInterface $bus,
): JsonResponse {
$website = trim($request->request->getString('website'));
$summary = trim($request->request->getString('summary'));
$parts = parse_url($website);
$validUrl = filter_var($website, FILTER_VALIDATE_URL) !== false;
$validScheme = isset($parts['scheme'])
&& in_array(strtolower($parts['scheme']), ['http', 'https'], true);
$hasCredentials = isset($parts['user']) || isset($parts['pass']);
if (!$validUrl || !$validScheme || $hasCredentials || $summary === '') {
return $this->json(['error' => 'invalid_quote'], 422);
}
$quote = new QuoteRequest($website, $summary);
$entityManager->persist($quote);
$entityManager->flush();
$bus->dispatch(new EnrichQuoteRequest($quote->getId()));
return $this->json([
'id' => $quote->getId(),
'enrichmentStatus' => 'queued',
], 202);
}
}
Test the boundary without making network calls
MockHttpClient provides deterministic responses and lets the test verify the exact HTTP method and query contract. Add further cases for malformed JSON, transport exceptions, 429 responses, and exhausted server-error retries.
<?php
// tests/Service/CompanyDataClientTest.php
namespace App\Tests\Service;
use App\Service\CompanyDataClient;
use App\Service\CompanyDataFailure;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class CompanyDataClientTest extends TestCase
{
public function testItMapsTheDocumentedFields(): void
{
$http = new MockHttpClient(function (
string $method,
string $url,
array $options
): MockResponse {
self::assertSame('GET', $method);
self::assertSame('secret-for-test', $options['query']['token']);
self::assertSame(
'https://example.com',
$options['query']['website']
);
return new MockResponse(json_encode([
'company' => ['name' => 'Example'],
'contact' => ['city' => 'Skopje'],
'email' => '[email protected]',
'phone' => null,
'people' => [],
'ignored' => 'not persisted',
], JSON_THROW_ON_ERROR), ['http_code' => 200]);
});
$client = new CompanyDataClient(
$http,
new NullLogger(),
'secret-for-test'
);
$data = $client->extract('https://example.com')->toArray();
self::assertSame('Example', $data['company']['name']);
self::assertArrayNotHasKey('ignored', $data);
}
public function testAuthenticationFailureIsNotRetried(): void
{
$calls = 0;
$http = new MockHttpClient(function () use (&$calls): MockResponse {
++$calls;
return new MockResponse('', ['http_code' => 401]);
});
$client = new CompanyDataClient($http, new NullLogger(), 'bad-token');
try {
$client->extract('https://example.com');
self::fail('Expected an authentication failure.');
} catch (CompanyDataFailure $failure) {
self::assertSame('authentication', $failure->kind);
self::assertSame(1, $calls);
}
}
}
Secure, observe, and deploy the integration
Because authentication appears in the mandated query parameter, URL logging deserves special attention. Configure HTTP tracing, proxies, exception reporting, and log processors to redact any token parameter. The application’s own logs should contain only safe dimensions such as attempt number, status code, quote identifier, duration, and failure category.
Treat enriched company and people data according to its sensitivity and your retention policy. Restrict who can view it, avoid returning the full profile from public status endpoints, and send only the company website to the service. Validate URL schemes and reject user information embedded in URLs.
Monitor queue depth, the age of the oldest message, enrichment duration, and counts grouped by enriched, rate_limited, authentication, and upstream. An abrupt rise in authentication failures often means a token was regenerated without updating every worker.
Deploy database migrations before starting code that writes the new fields. Then run Messenger under systemd, Supervisor, Kubernetes, or another process manager that restarts failed workers:
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console messenger:consume async \
--time-limit=3600 \
--memory-limit=128M \
--no-interaction
php bin/console messenger:failed:show
Restart workers during each deployment so they load new code and rotated environment values. Scale worker count cautiously: additional workers improve throughput but can reach plan quotas faster.
Common failure patterns
- Every request fails with authentication: verify the service-scoped token, environment injection, and whether regeneration revoked the deployed value.
- Quotes stay queued: confirm that a worker is consuming
asyncand that the Doctrine transport table is accessible. - 429 responses dominate: reduce worker concurrency, inspect usage against the active plan, and avoid manually replaying failures in bulk.
- Successful responses become
invalid_payload: capture only a redacted structural description, compare it with the official documentation, and update the boundary mapper deliberately. - Workers process missing quotes: ensure dispatch occurs after persistence and that producer and consumer use the same database environment.
Final verification checklist
- The form returns
202without waiting for company extraction. - The exact GET endpoint receives both
tokenandwebsite. - The token comes from environment-backed configuration and is redacted from logs.
- Only company, contact, email, phone, and people data cross the application boundary.
- Timeouts and retry counts are bounded; authentication failures are never retried.
- Rate limits and upstream failures produce understandable operational states.
- Tests use
MockHttpClientand never contact the live service. - A supervised Messenger worker is running after deployment.
The important architectural decision is not the API call itself; it is refusing to make the visitor wait for it. Once enrichment becomes a bounded, observable background operation, the quote form remains dependable while the business still gains structured company context. That is the kind of integration users barely notice and operators can actually trust.