Туториали

Symfony Quote Forms: Enrich Leads with Company Data Without Sacrificing Speed

Symfony формулари за понуди: збогатете ги потенцијалните клиенти со податоци за компанијата без да ја жртвувате брзината

A quote form should feel instant, even when the business wants richer leads. The tempting implementation is to submit the form, call an enrichment API, save the response, and finally show confirmation. That design works until the remote service is slow, temporarily unavailable, or enforcing a quota. Then an optional enhancement becomes the reason a prospective customer waits—or abandons the form.

This tutorial builds the safer version in Symfony and PHP 8.3: save the quote request immediately, dispatch a background message, and enrich it from the company’s public website outside the browser request. The resulting lead contains structured company, contact, email, phone, and people data, while the customer-facing path remains independent of API latency.

Prerequisites and the shape of the solution

You need PHP 8.3 or newer, a Symfony application with Doctrine ORM, a database supported by Doctrine, and an existing quote form that collects a public company website. The examples use Symfony HttpClient, Messenger, Validator, and PHPUnit.

composer require symfony/http-client symfony/messenger symfony/doctrine-messenger
composer require symfony/validator doctrine/doctrine-bundle
composer require --dev symfony/test-pack

The synchronous path validates and stores the quote, marks enrichment as queued, dispatches a message, and returns a confirmation. A Messenger worker later loads the quote, calls the Website to Company data service, maps the external response into an application-owned object, and saves the result.

Doctrine Messenger is a practical default for a small team because it uses the database already in the project. A dedicated broker may offer higher throughput, but it is unnecessary for a modest quote pipeline. The important architectural boundary is asynchronous execution, not the particular queue transport.

Get access before writing integration code

  1. Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
  2. Open the Website to Company data service page. Choose the available Free, Plus, or Pro plan and complete its activation.
  3. Open the official service documentation, find the Service token panel, and copy the service-scoped token shown there.
  4. Store that token in environment-backed project configuration. If you regenerate it, the previously active token is revoked, so deployments and workers using the old value must be updated together.

This service does require a token. Its exact request is GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract, with both website and token supplied as query parameters.

Before building the feature, make one minimal request from a trusted development shell. Replace both placeholders; do not commit the resulting command to shell scripts containing a real credential.

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 credential in .env.local for local development. That file should remain uncommitted. In production, inject the same variable through the hosting platform’s secret manager or environment configuration.

# .env
WEBSITE_COMPANY_TOKEN=

# .env.local
WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
MESSENGER_TRANSPORT_DSN=doctrine://default

Persist the workflow, not merely the response

The quote record needs enough state to explain what happened after the browser request ended. Add a nullable JSON result, a status, and a short failure category. Avoid storing the token, complete request URL, or raw exception trace.

<?php
// src/Entity/QuoteRequest.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class QuoteRequest
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 2048)]
    private string $website;

    #[ORM\Column(length: 24)]
    private string $enrichmentStatus = 'not_requested';

    #[ORM\Column(type: 'json', nullable: true)]
    private ?array $companyData = null;

    #[ORM\Column(length: 40, nullable: true)]
    private ?string $enrichmentFailure = null;

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

    public function queueEnrichment(): void
    {
        $this->enrichmentStatus = 'queued';
        $this->enrichmentFailure = null;
    }

    public function isEnrichmentQueued(): bool
    {
        return $this->enrichmentStatus === 'queued';
    }

    public function completeEnrichment(array $data): void
    {
        $this->companyData = $data;
        $this->enrichmentStatus = 'completed';
        $this->enrichmentFailure = null;
    }

    public function failEnrichment(string $category): void
    {
        $this->enrichmentStatus = 'failed';
        $this->enrichmentFailure = $category;
    }
}

Generate and review the migration rather than hand-writing schema SQL. The migration should add only the three enrichment columns if the quote table already contains the website and customer fields.

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

Map the external contract at one boundary

External JSON should not spread through controllers or entities. The service contract identifies company, contact, email, phone, and people, but defensive code must still tolerate absent optional values and reject unexpected types. This mapper preserves strings or structured arrays without pretending undocumented nested fields exist.

<?php
// src/Enrichment/CompanyProfile.php
namespace App\Enrichment;

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

    public static function fromApi(array $payload): self
    {
        return new self(
            self::value($payload, 'company'),
            self::value($payload, 'contact'),
            self::value($payload, 'email'),
            self::value($payload, 'phone'),
            isset($payload['people']) && is_array($payload['people'])
                ? array_values($payload['people'])
                : [],
        );
    }

    private static function value(array $payload, string $key): array|string|null
    {
        $value = $payload[$key] ?? null;

        return is_array($value) || is_string($value) ? $value : null;
    }

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

Build a bounded, retry-aware HTTP client

The client below allows three total attempts for transport failures, HTTP 429, and server errors. It uses short, bounded backoff and honors a numeric Retry-After value up to two seconds. Authentication and other client errors are not retried: repeating a bad token or invalid request only wastes quota and worker time.

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

use JsonException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class WebsiteCompanyClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract';

    public function __construct(
        private readonly HttpClientInterface $http,
        #[Autowire('%env(WEBSITE_COMPANY_TOKEN)%')]
        private readonly string $token,
    ) {}

    public function extract(string $website): CompanyProfile
    {
        if ($this->token === '') {
            throw new EnrichmentException('configuration');
        }

        for ($attempt = 0; $attempt < 3; $attempt++) {
            try {
                $response = $this->http->request('GET', self::ENDPOINT, [
                    'query' => [
                        'website' => $website,
                        'token' => $this->token,
                    ],
                    'timeout' => 5.0,
                    'max_duration' => 10.0,
                    'headers' => ['Accept' => 'application/json'],
                ]);

                $status = $response->getStatusCode();

                if (($status === 429 || $status >= 500) && $attempt < 2) {
                    $this->backoff($attempt, $response->getHeaders(false));
                    continue;
                }

                if ($status === 401 || $status === 403) {
                    throw new EnrichmentException('authentication');
                }
                if ($status === 429) {
                    throw new EnrichmentException('quota');
                }
                if ($status < 200 || $status >= 300) {
                    throw new EnrichmentException(
                        $status >= 500 ? 'upstream' : 'request'
                    );
                }

                try {
                    $payload = json_decode(
                        $response->getContent(false),
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );
                } catch (JsonException) {
                    throw new EnrichmentException('malformed_response');
                }

                if (!is_array($payload)) {
                    throw new EnrichmentException('malformed_response');
                }

                return CompanyProfile::fromApi($payload);
            } catch (TransportExceptionInterface) {
                if ($attempt === 2) {
                    throw new EnrichmentException('transport');
                }
                usleep([250000, 750000][$attempt]);
            }
        }

        throw new EnrichmentException('transport');
    }

    private function backoff(int $attempt, array $headers): void
    {
        $retryAfter = $headers['retry-after'][0] ?? null;
        $seconds = is_string($retryAfter) && ctype_digit($retryAfter)
            ? min(2.0, (float) $retryAfter)
            : [0.25, 0.75][$attempt];

        usleep((int) ($seconds * 1000000));
    }
}

final class EnrichmentException extends \RuntimeException
{
    public function __construct(public readonly string $category)
    {
        parent::__construct('Company enrichment failed: '.$category);
    }
}

The timeouts protect worker capacity, while Messenger protects form latency. The token is deliberately absent from exception messages and logs. Because authentication lives in the query string by contract, also ensure reverse proxies and observability tools do not record full outbound URLs.

Dispatch only after the quote exists

The message carries an internal identifier, never customer details or a token. Persist and flush first so a fast worker cannot consume a message before its quote row is visible.

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

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

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

use App\Entity\QuoteRequest;
use App\Form\QuoteRequestType;
use App\Message\EnrichQuote;
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_create')]
    public function __invoke(
        Request $request,
        EntityManagerInterface $entityManager,
        MessageBusInterface $bus,
    ): Response {
        $quote = new QuoteRequest();
        $form = $this->createForm(QuoteRequestType::class, $quote);
        $form->handleRequest($request);

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

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

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

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

Validate the website field as a URL and restrict accepted schemes to HTTPS or HTTP. If the submitted site will later be fetched by another system, reject loopback, private, link-local, and internal hostnames to reduce server-side request forgery risk. The enrichment endpoint, rather than your Symfony server, performs the public-site extraction, but accepting only genuine public company URLs remains sound input policy.

Process the message with explicit failure states

<?php
// src/MessageHandler/EnrichQuoteHandler.php
namespace App\MessageHandler;

use App\Enrichment\EnrichmentException;
use App\Enrichment\WebsiteCompanyClient;
use App\Entity\QuoteRequest;
use App\Message\EnrichQuote;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

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

    public function __invoke(EnrichQuote $message): void
    {
        $quote = $this->entityManager->find(
            QuoteRequest::class,
            $message->quoteId
        );

        if (!$quote || !$quote->isEnrichmentQueued()) {
            return;
        }

        try {
            $profile = $this->client->extract($quote->getWebsite());
            $quote->completeEnrichment($profile->toArray());

            $this->logger->info('Quote enrichment completed', [
                'quote_id' => $quote->getId(),
            ]);
        } catch (EnrichmentException $exception) {
            $quote->failEnrichment($exception->category);

            $this->logger->warning('Quote enrichment failed', [
                'quote_id' => $quote->getId(),
                'category' => $exception->category,
            ]);
        }

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

Configure the message route and create the Doctrine transport table. Since the client already performs bounded transient retries, the handler records a terminal state instead of throwing and multiplying attempts at the queue layer.

# config/packages/messenger.yaml
framework:
  messenger:
    transports:
      async: '%env(MESSENGER_TRANSPORT_DSN)%'
    routing:
      'App\Message\EnrichQuote': async
php bin/console messenger:setup-transports
php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M

Test the boundary without making network calls

MockHttpClient provides a deterministic transport. This test verifies the method, endpoint parameters, mapping, and retry path without exposing a credential or depending on service availability.

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

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

final class WebsiteCompanyClientTest extends TestCase
{
    public function testItMapsTheCompanyResponse(): void
    {
        $callback = function (string $method, string $url): MockResponse {
            self::assertSame('GET', $method);

            parse_str((string) 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 Ltd'],
                'contact' => ['page' => '/contact'],
                'email' => '[email protected]',
                'phone' => null,
                'people' => [['name' => 'Alex Example']],
            ], JSON_THROW_ON_ERROR), [
                'http_code' => 200,
                'response_headers' => ['content-type: application/json'],
            ]);
        };

        $client = new WebsiteCompanyClient(
            new MockHttpClient($callback),
            'test-token'
        );

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

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

    public function testItRetriesAQuotaResponse(): void
    {
        $http = new MockHttpClient([
            new MockResponse('', [
                'http_code' => 429,
                'response_headers' => ['retry-after: 0'],
            ]),
            new MockResponse('{"company":"Example","people":[]}', [
                'http_code' => 200,
            ]),
        ]);

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

        self::assertSame('Example', $profile->company);
        self::assertSame(2, $http->getRequestsCount());
    }
}

Add a controller test that submits a valid form and asserts that EnrichQuote reaches Messenger’s test transport. Add a handler test with a persisted quote and mocked client, then assert the status and JSON fields after processing. Those tests cover the asynchronous seam independently from the HTTP contract.

Security, observability, and deployment

  • Secrets: keep the service token out of source control, fixtures, exception messages, profiler exports, and full-URL HTTP logs. Rotate it immediately if exposed.
  • Data minimization: retain only enrichment fields needed for quoting or follow-up. Company pages can contain personal data, so define access controls and a deletion policy.
  • Metrics: count queued, completed, failed, quota, authentication, malformed-response, and transport outcomes. Alert on sustained queue growth or a sudden authentication failure.
  • Correlation: log the internal quote ID and failure category. Do not log the token, full response body, or unnecessary personal fields.
  • Workers: run messenger:consume under systemd, Supervisor, a container orchestrator, or the platform’s worker facility. Restart workers after deployment so they load new code and rotated environment values.

Deploy database migrations before releasing code that writes the new columns. Then deploy the application, inject WEBSITE_COMPANY_TOKEN and MESSENGER_TRANSPORT_DSN, create the transport table once, start the consumer, and verify queue depth. During token rotation, update the secret and restart every web and worker process because regenerating the service token revokes the previous one.

Common failures worth designing for

A quote stuck in queued usually means no consumer is running, the transport table is missing, or web and worker processes use different DSNs. An authentication failure points to a missing, revoked, or stale token. Repeated quota results should pause manual redrives until plan capacity or request frequency is understood.

A malformed_response state means the service returned invalid JSON or a non-object payload. Preserve the failure category, but do not save or log the raw body by default. A request failure commonly indicates an unsuitable website value, so inspect validation and normalization before adding retries.

Final verification checklist

  • The quote confirmation appears without waiting for enrichment.
  • The database records queued, followed by completed or a structured failure category.
  • The worker calls the exact GET endpoint with website and token query parameters.
  • Company, contact, email, phone, and people values cross the API boundary through CompanyProfile.
  • Timeouts and retries are bounded, while authentication and validation failures are not blindly retried.
  • Automated tests use MockHttpClient and never contact the live service.
  • No credential or sensitive response body appears in code, logs, fixtures, profiler output, or queue messages.
  • Production workers restart after deployments and secret rotation.

The durable lesson is broader than this one integration: optional enrichment should remain optional from the customer’s point of view. Save the intent first, acknowledge it quickly, and let a carefully bounded background process add context. The quote form stays fast on a perfect day, a slow day, and—most importantly—the day an upstream dependency is having trouble.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.