Tutorials

Symfony Quote Forms: Enrich Company Data from Website URLs Without Lag

Symfony Quote Forms: Enrich Company Data from Website URLs Without Lag

A quote form should feel instantaneous. Yet the website URL a prospect enters can unlock useful context: the company’s identity, public contact details, phone number, and key people. The wrong implementation makes the browser wait for an external API. The production-friendly design accepts the quote first, redirects immediately, and performs enrichment in a background worker.

This tutorial builds that design with PHP 8.3, Symfony 7.4, Doctrine, HttpClient, and Messenger. External data is mapped at a strict application boundary, failures become explicit states, and API delays never delay the customer-facing form.

Get access and copy the service token

Start by registering an account, or use the sign-in page if you already have one.

  1. Open the Website to Company data service page.
  2. Choose the available Free, Plus, or Pro plan and complete its activation.
  3. Open the official service documentation.
  4. Find the Service token panel and copy the service-scoped token.
  5. Store it in environment-backed project configuration, never in committed PHP or YAML files.

Regenerating the token revokes the previously active token, so coordinate rotation with deployment. This service does not have a no-token mode: every request must supply token={serviceToken} as a query parameter.

The exact API operation is GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. It accepts the website in the website query parameter. Test access 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'

Put the real credential in .env.local, which Symfony projects normally exclude from version control:

MIHAJLO_WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=company_enrichment&auto_setup=false

Architecture: save, redirect, enrich

The request path has four deliberate steps: validate the quote, persist it with an enrichment state of pending, dispatch a small message containing only its database identifier, and redirect. A Messenger worker later claims the record, calls the service, maps the returned company, contact, email, phone, and people data, then stores the result.

This introduces eventual consistency: company details may appear a few seconds after the quote. That trade-off is appropriate because enrichment supports follow-up work; it is not required to acknowledge the submission. Keeping the message small also avoids duplicating personal information in the queue.

Prerequisites and project structure

You need PHP 8.3 or newer, Composer, a Doctrine-supported database, and a process manager capable of keeping a console worker alive. Create the project and install only the components this workflow uses:

composer create-project symfony/skeleton:"7.4.*" quote-enrichment
cd quote-enrichment
composer require symfony/orm-pack symfony/form symfony/validator \
  symfony/twig-bundle symfony/http-client symfony/messenger \
  symfony/doctrine-messenger
composer require --dev symfony/test-pack
php bin/console doctrine:database:create
php bin/console messenger:setup-transports

The important files are:

  • src/Entity/QuoteRequest.php for the quote and enrichment state.
  • src/Integration/CompanyEnrichment.php and WebsiteCompanyClient.php for the API boundary.
  • src/Message/EnrichQuoteRequest.php and its handler for background execution.
  • src/Form/QuoteRequestType.php and src/Controller/QuoteController.php for form submission.
  • tests/Integration/WebsiteCompanyClientTest.php for deterministic transport tests.

Configure bounded requests and careful retries

A scoped client provides an inactivity timeout and an end-to-end duration limit. Because this is an idempotent GET request, retrying a small number of transient responses is reasonable. Authentication and validation responses are intentionally absent from the retry list.

# config/packages/framework.yaml
framework:
  http_client:
    scoped_clients:
      company_enrichment.client:
        base_uri: 'https://ai.mihajlo.mk/api/website-to-company-data/'
        timeout: 3
        max_duration: 8
        retry_failed:
          http_codes: [429, 502, 503, 504]
          max_retries: 2
          delay: 300
          multiplier: 2
          max_delay: 2000
          jitter: 0.2

  messenger:
    transports:
      async:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        retry_strategy:
          max_retries: 2
          delay: 1000
          multiplier: 2
          max_delay: 5000
    routing:
      App\Message\EnrichQuoteRequest: async

# config/services.yaml
services:
  App\Integration\WebsiteCompanyClient:
    arguments:
      $http: '@company_enrichment.client'
      $token: '%env(string:MIHAJLO_WEBSITE_COMPANY_TOKEN)%'

The HTTP retries cover quota responses and short upstream outages. If those attempts are exhausted, the quote records a structured failure instead of blocking indefinitely. Messenger’s retries remain useful for unexpected handler or database failures.

Map uncertain JSON at the boundary

External JSON must not flow directly into entities or templates. The mapper accepts only the five contract fields, applies types defensively, and ignores additional fields. An absent contract shape is treated as an invalid response rather than silently stored.

<?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
    {
        $known = ['company', 'contact', 'email', 'phone', 'people'];

        if (!array_filter($known, fn (string $key) => array_key_exists($key, $payload))) {
            throw new WebsiteCompanyFailure('invalid_response', 'Expected fields are absent.');
        }

        $people = is_array($payload['people'] ?? null)
            ? array_values(array_filter($payload['people'], 'is_array'))
            : [];

        return new self(
            is_array($payload['company'] ?? null) ? $payload['company'] : null,
            is_array($payload['contact'] ?? null) ? $payload['contact'] : null,
            self::text($payload['email'] ?? null),
            self::text($payload['phone'] ?? null),
            $people,
        );
    }

    public function toArray(): array
    {
        return get_object_vars($this);
    }

    private static function text(mixed $value): ?string
    {
        if (!is_string($value) || trim($value) === '') {
            return null;
        }

        return trim($value);
    }
}

// src/Integration/WebsiteCompanyFailure.php
namespace App\Integration;

final class WebsiteCompanyFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        string $message,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

The client uses the required query-parameter authentication contract. It never puts the token or response body into exception messages.

<?php
// src/Integration/WebsiteCompanyClient.php
namespace App\Integration;

use JsonException;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class WebsiteCompanyClient
{
    public function __construct(
        private HttpClientInterface $http,
        private string $token,
    ) {}

    public function extract(string $website): CompanyEnrichment
    {
        try {
            $response = $this->http->request('GET', 'v1/extract', [
                'query' => [
                    'website' => $website,
                    'token' => $this->token,
                ],
            ]);

            $status = $response->getStatusCode();

            if ($status === 401 || $status === 403) {
                throw new WebsiteCompanyFailure('authentication', 'Service authentication failed.');
            }
            if (in_array($status, [400, 404, 422], true)) {
                throw new WebsiteCompanyFailure('invalid_request', 'The website was rejected.');
            }
            if ($status === 429) {
                throw new WebsiteCompanyFailure('rate_limited', 'Service quota is temporarily unavailable.');
            }
            if ($status >= 500) {
                throw new WebsiteCompanyFailure('temporary', 'The service is temporarily unavailable.');
            }
            if ($status < 200 || $status >= 300) {
                throw new WebsiteCompanyFailure('remote_error', 'Unexpected service response.');
            }

            $payload = json_decode(
                $response->getContent(false),
                true,
                512,
                JSON_THROW_ON_ERROR,
            );

            if (!is_array($payload)) {
                throw new WebsiteCompanyFailure('invalid_response', 'Response is not a JSON object.');
            }

            return CompanyEnrichment::fromPayload($payload);
        } catch (WebsiteCompanyFailure $failure) {
            throw $failure;
        } catch (JsonException $exception) {
            throw new WebsiteCompanyFailure('invalid_response', 'Response is not valid JSON.', $exception);
        } catch (TransportExceptionInterface $exception) {
            throw new WebsiteCompanyFailure('temporary', 'Service request could not complete.', $exception);
        }
    }
}

Persist an explicit enrichment state

The QuoteRequest entity should contain ordinary quote fields such as customerEmail, website, and summary, plus enrichmentStatus, nullable JSON enrichmentData, and nullable enrichmentError. Initialize the status to pending and add these transition methods:

<?php
// Relevant methods in src/Entity/QuoteRequest.php
public function enrichmentSucceeded(CompanyEnrichment $result): void
{
    $this->enrichmentStatus = 'succeeded';
    $this->enrichmentData = $result->toArray();
    $this->enrichmentError = null;
}

public function enrichmentFailed(string $kind): void
{
    $this->enrichmentStatus = 'failed';
    $this->enrichmentData = null;
    $this->enrichmentError = $kind;
}

public function getId(): ?int { return $this->id; }
public function getWebsite(): string { return $this->website; }

Use explicit Doctrine column names for enrichment_status, enrichment_data, and enrichment_error. Generate and review the migration:

php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction

Run enrichment in Messenger

The message contains only the quote ID. The atomic update prevents duplicate deliveries from enriching the same pending record twice. The handler logs identifiers and failure categories, but not websites, contact data, response bodies, or tokens.

<?php
// src/Message/EnrichQuoteRequest.php
namespace App\Message;

final readonly class EnrichQuoteRequest
{
    public function __construct(public int $quoteId) {}
}

// src/MessageHandler/EnrichQuoteRequestHandler.php
namespace App\MessageHandler;

use App\Entity\QuoteRequest;
use App\Integration\WebsiteCompanyClient;
use App\Integration\WebsiteCompanyFailure;
use App\Message\EnrichQuoteRequest;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final readonly class EnrichQuoteRequestHandler
{
    public function __construct(
        private Connection $connection,
        private EntityManagerInterface $entityManager,
        private WebsiteCompanyClient $client,
        private LoggerInterface $logger,
    ) {}

    public function __invoke(EnrichQuoteRequest $message): void
    {
        $claimed = $this->connection->executeStatement(
            'UPDATE quote_request
             SET enrichment_status = :processing
             WHERE id = :id AND enrichment_status = :pending',
            ['processing' => 'processing', 'pending' => 'pending', 'id' => $message->quoteId],
        );

        if ($claimed !== 1) {
            return;
        }

        $quote = $this->entityManager->find(QuoteRequest::class, $message->quoteId);
        if (!$quote) {
            return;
        }

        try {
            $quote->enrichmentSucceeded($this->client->extract($quote->getWebsite()));
            $this->logger->info('Quote enrichment succeeded.', ['quote_id' => $message->quoteId]);
        } catch (WebsiteCompanyFailure $failure) {
            $quote->enrichmentFailed($failure->kind);
            $this->logger->warning('Quote enrichment failed.', [
                'quote_id' => $message->quoteId,
                'failure_kind' => $failure->kind,
            ]);
        }

        $this->entityManager->flush();
    }
}

Keep the controller fast

Apply Symfony’s Url constraint with only HTTP and HTTPS protocols, a reasonable length limit, and normal CSRF-protected form handling. The controller must flush before dispatching so the worker cannot observe a missing database record.

<?php
// src/Controller/QuoteController.php
namespace App\Controller;

use App\Entity\QuoteRequest;
use App\Form\QuoteRequestType;
use App\Message\EnrichQuoteRequest;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;

final class QuoteController extends AbstractController
{
    #[Route('/quote', name: 'quote_new', methods: ['GET', 'POST'])]
    public function new(
        Request $request,
        EntityManagerInterface $entityManager,
        MessageBusInterface $bus,
    ): Response {
        $quote = new QuoteRequest();
        $form = $this->createForm(QuoteRequestType::class, $quote);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $entityManager->persist($quote);
            $entityManager->flush();

            $bus->dispatch(new EnrichQuoteRequest($quote->getId()));

            return $this->redirectToRoute('quote_received');
        }

        return $this->render('quote/new.html.twig', ['form' => $form]);
    }

    #[Route('/quote/received', name: 'quote_received', methods: ['GET'])]
    public function received(): Response
    {
        return new Response('<p>Your quote request has been received.</p>');
    }
}

If queue dispatch fails after the quote is committed, the submission still exists in pending. A scheduled reconciliation command can redispatch old pending rows; keep that operation idempotent by relying on the handler’s atomic claim.

Test without calling the live service

MockHttpClient makes transport behavior deterministic and verifies the exact method, path, and query parameters.

<?php
// tests/Integration/WebsiteCompanyClientTest.php
namespace App\Tests\Integration;

use App\Integration\WebsiteCompanyClient;
use App\Integration\WebsiteCompanyFailure;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class WebsiteCompanyClientTest extends TestCase
{
    public function testMapsContractFields(): void
    {
        $http = new MockHttpClient(function (string $method, string $url): MockResponse {
            self::assertSame('GET', $method);
            self::assertSame('/api/website-to-company-data/v1/extract', parse_url($url, PHP_URL_PATH));

            parse_str(parse_url($url, PHP_URL_QUERY) ?? '', $query);
            self::assertSame('https://example.com', $query['website']);
            self::assertSame('test-token', $query['token']);

            return new MockResponse(json_encode([
                'company' => ['name' => 'Example Company'],
                'contact' => ['city' => 'Example City'],
                'email' => '[email protected]',
                'phone' => '+1 555 0100',
                'people' => [['name' => 'Alex Example']],
            ], JSON_THROW_ON_ERROR));
        }, 'https://ai.mihajlo.mk/');

        $result = (new WebsiteCompanyClient($http, 'test-token'))
            ->extract('https://example.com');

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

    public function testRejectsUnknownJsonShape(): void
    {
        $client = new WebsiteCompanyClient(
            new MockHttpClient(new MockResponse('{"unexpected":true}')),
            'test-token',
        );

        $this->expectException(WebsiteCompanyFailure::class);
        $client->extract('https://example.com');
    }

    public function testClassifiesAuthenticationFailure(): void
    {
        $client = new WebsiteCompanyClient(
            new MockHttpClient(new MockResponse('', ['http_code' => 401])),
            'test-token',
        );

        try {
            $client->extract('https://example.com');
            self::fail('Expected authentication failure.');
        } catch (WebsiteCompanyFailure $failure) {
            self::assertSame('authentication', $failure->kind);
        }
    }
}

Security, operations, and common failures

Query-parameter authentication deserves special care because URLs may be captured by proxies, profilers, or HTTP logs. Never log the complete request URL. Disable production profiling, restrict access to infrastructure logs, redact token query values at any reverse proxy, and rotate the token immediately if it is exposed.

Treat returned company and people data as untrusted input. Escape it in templates, authorize access to administrative quote screens, define a retention policy, and avoid copying enrichment JSON into analytics or exception reports. Form validation should reject non-HTTP schemes, while application rate limiting and CSRF protection reduce quota abuse.

Deploy migrations and the Messenger transport before starting workers. Then supervise the consumer with a service manager:

php bin/console doctrine:migrations:migrate --no-interaction
php bin/console messenger:setup-transports
php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M

Restart workers on every release so they load new code. Monitor queue age, pending records, success and failure counts by failure category, worker exits, and enrichment duration. Use php bin/console messenger:failed:show to inspect exhausted Messenger deliveries and messenger:failed:retry only after correcting the cause.

  • Authentication failures: confirm the deployed secret and remember that regeneration revoked the old token. Do not retry 401 or 403 responses.
  • Invalid website failures: keep the quote, record invalid_request, and allow a staff member to correct and redispatch it.
  • Rate limits: let bounded HTTP retries handle brief pressure, then record rate_limited. Do not create an unbounded retry loop.
  • Malformed JSON: classify it as invalid_response; never guess a new response structure inside domain code.
  • Growing queue: verify the worker is running, inspect failed messages, and compare arrival rate with processing capacity before adding consumers.

Final verification checklist

  • The browser redirects after database persistence and message dispatch, without waiting for enrichment.
  • The outgoing request is exactly a GET to the supplied extraction endpoint with website and token query parameters.
  • The service token exists only in environment-backed configuration and is redacted from logs.
  • Company, contact, email, phone, and people data cross a defensive application boundary.
  • Timeouts and retries are bounded; validation and authentication failures are not blindly retried.
  • Duplicate messages cannot process an already claimed quote.
  • Tests pass with php bin/phpunit, and the worker updates a real pending quote in a staging environment.

The important result is not merely richer quote data. It is a form that remains dependable when the enrichment service is slow, rate-limited, temporarily unavailable, or returns something unexpected. Accept the customer’s intent first; enrich it on your own operational clock. That separation is what turns a convenient API call into a production integration.

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.