Symfony: Turn Website Lists into Reviewable Contacts with Company Data API
A spreadsheet full of company websites looks like a useful lead list, but it is still several steps away from being reviewable. Someone must visit each site, identify the company, find public contact details, and record the result consistently. That work is slow, difficult to resume, and easy to perform differently from row to row.
This tutorial builds a production-oriented Symfony command that accepts a UTF-8 CSV export, sends each website to the Website to Company data service, and writes a new CSV containing company, contact, email, phone, and people data. Individual failures become reviewable rows instead of terminating the batch.
The implementation targets PHP 8.3 or later and uses Symfony’s first-party HTTP client, dependency injection, console support, and testing utilities. It deliberately avoids an Excel-reading package: most spreadsheet applications can export CSV, and accepting that simple interchange format keeps the operational surface small.
Get access and copy the service token
Complete access setup before writing integration code:
- Register through the registration page, or use the sign-in page 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 shown there.
This service requires a token. Regenerating it revokes the previously active token, so token rotation must include updating every deployed environment that runs this integration. Never commit the value, place it in a fixture, or paste it into a support log.
Confirm the HTTP contract
The exact request is GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Authentication uses the token query parameter, while the public website is supplied through the website query parameter.
Make one minimal request before building the batch:
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'
Because the credential travels in the query string, use HTTPS exactly as shown. Configure reverse proxies and request tracing systems not to record full upstream URLs or query strings.
Store the token outside source control
For local Symfony development, put the credential in .env.local, which should remain uncommitted:
WEBSITE_TO_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
In production, inject the same variable through the hosting platform’s secret manager. Do not put the real value in the committed .env file.
Bootstrap the Symfony project
In a new project, install Symfony’s first-party HTTP, console, logging, and testing components:
composer create-project symfony/skeleton contact-research
cd contact-research
composer require symfony/http-client symfony/console symfony/monolog-bundle
composer require --dev symfony/test-pack
The relevant project structure will be:
contact-research/
├── config/services.yaml
├── src/Command/ResearchWebsitesCommand.php
├── src/CompanyData/CompanyResearch.php
├── src/CompanyData/ServiceFailure.php
├── src/CompanyData/WebsiteCompanyClient.php
└── tests/CompanyData/WebsiteCompanyClientTest.php
Bind the environment variable to the client constructor in config/services.yaml:
services:
_defaults:
autowire: true
autoconfigure: true
bind:
string $websiteToCompanyToken: '%env(WEBSITE_TO_COMPANY_TOKEN)%'
App\:
resource: '../src/'
Choose a boundary that absorbs uncertain data
The command is intentionally synchronous. For the ordinary spreadsheet used by a freelancer or small team, one command is easier to deploy, observe, and rerun than a queue. A very large or continuously arriving workload would justify Symfony Messenger, one message per website, and persistent result storage. Adding it here would create more failure states without improving the basic outcome.
The API boundary has three responsibilities: make the authenticated request, classify failures, and map only the documented top-level fields. It must not scatter assumptions about response shapes through the command.
Create src/CompanyData/CompanyResearch.php:
<?php
namespace App\CompanyData;
final readonly class CompanyResearch
{
public function __construct(
public ?string $company,
public ?string $contact,
public ?string $email,
public ?string $phone,
public array $people,
) {}
public static function fromPayload(array $payload): self
{
$people = $payload['people'] ?? [];
if (!is_array($people)) {
$people = $people === null ? [] : [$people];
}
return new self(
self::text($payload['company'] ?? null),
self::text($payload['contact'] ?? null),
self::text($payload['email'] ?? null),
self::text($payload['phone'] ?? null),
array_values($people),
);
}
private static function text(mixed $value): ?string
{
if ($value === null) {
return null;
}
if (is_scalar($value)) {
$text = trim((string) $value);
return $text === '' ? null : $text;
}
if (is_array($value)) {
$json = json_encode(
$value,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
return $json === false ? null : $json;
}
return null;
}
}
This mapper accepts scalar or structured values without inventing undocumented nested properties. Structured company or contact data remains visible as JSON in the review file; absent or unusable values become empty cells.
Add a typed integration failure in src/CompanyData/ServiceFailure.php:
<?php
namespace App\CompanyData;
use RuntimeException;
use Throwable;
final class ServiceFailure extends RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Build the resilient HTTP client
The client uses bounded timeouts and at most three attempts. It retries transport failures, HTTP 429, and temporary gateway or availability responses. It does not retry malformed input, authentication failures, or arbitrary client errors. That distinction matters: retrying a revoked token only consumes time and obscures the real problem.
Create src/CompanyData/WebsiteCompanyClient.php:
<?php
namespace App\CompanyData;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
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 HttpClientInterface $httpClient,
private LoggerInterface $logger,
private string $websiteToCompanyToken,
) {}
public function extract(string $website): CompanyResearch
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->httpClient->request('GET', self::ENDPOINT, [
'query' => [
'token' => $this->websiteToCompanyToken,
'website' => $website,
],
'timeout' => 10.0,
'max_duration' => 25.0,
]);
$status = $response->getStatusCode();
if ($status >= 200 && $status < 300) {
try {
return CompanyResearch::fromPayload(
$response->toArray(false)
);
} catch (DecodingExceptionInterface $e) {
throw new ServiceFailure(
'invalid_response',
'The service returned invalid JSON.',
$e
);
}
}
if (in_array($status, [400, 422], true)) {
throw new ServiceFailure(
'validation',
'The service rejected the website value.'
);
}
if (in_array($status, [401, 403], true)) {
throw new ServiceFailure(
'authentication',
'The service token was rejected.'
);
}
$retryable = $status === 429
|| in_array($status, [502, 503, 504], true);
if (!$retryable || $attempt === 3) {
$kind = $status === 429
? 'rate_limited'
: 'upstream_error';
throw new ServiceFailure(
$kind,
sprintf('The service returned HTTP %d.', $status)
);
}
$headers = $response->getHeaders(false);
$retryAfter = $headers['retry-after'][0] ?? null;
$delayMs = ctype_digit((string) $retryAfter)
? min(5000, (int) $retryAfter * 1000)
: 250 * (2 ** ($attempt - 1));
$this->logger->warning('Company lookup will be retried.', [
'host' => parse_url($website, PHP_URL_HOST),
'status' => $status,
'attempt' => $attempt,
'delay_ms' => $delayMs,
]);
usleep($delayMs * 1000);
} catch (TransportExceptionInterface $e) {
if ($attempt === 3) {
throw new ServiceFailure(
'transport',
'The service could not be reached.',
$e
);
}
$delayMs = 250 * (2 ** ($attempt - 1));
$this->logger->warning('Company lookup transport retry.', [
'host' => parse_url($website, PHP_URL_HOST),
'attempt' => $attempt,
'delay_ms' => $delayMs,
]);
usleep($delayMs * 1000);
}
}
throw new ServiceFailure('internal', 'Lookup attempts were exhausted.');
}
}
The token is never included in application logs. Even the website is reduced to its host, limiting accidental disclosure of paths or query parameters.
Turn a spreadsheet export into a review queue
Export the source sheet as UTF-8 CSV with a column named website. The command preserves one output row per non-empty input row and records ok, invalid_input, or a structured service failure.
Create src/Command/ResearchWebsitesCommand.php:
<?php
namespace App\Command;
use App\CompanyData\ServiceFailure;
use App\CompanyData\WebsiteCompanyClient;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:research-websites',
description: 'Enrich a CSV containing a website column.'
)]
final class ResearchWebsitesCommand extends Command
{
public function __construct(private WebsiteCompanyClient $client)
{
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('input', InputArgument::REQUIRED)
->addArgument('output', InputArgument::REQUIRED);
}
protected function execute(
InputInterface $input,
OutputInterface $output
): int {
$inputPath = (string) $input->getArgument('input');
$outputPath = (string) $input->getArgument('output');
if ($inputPath === $outputPath) {
$output->writeln('<error>Input and output must differ.</error>');
return Command::INVALID;
}
$source = fopen($inputPath, 'rb');
if ($source === false) {
$output->writeln('<error>Cannot open input CSV.</error>');
return Command::FAILURE;
}
$header = fgetcsv($source);
$websiteIndex = is_array($header)
? array_search('website', $header, true)
: false;
if ($websiteIndex === false) {
fclose($source);
$output->writeln('<error>Missing website header.</error>');
return Command::INVALID;
}
$temporaryPath = $outputPath . '.part';
$target = fopen($temporaryPath, 'wb');
if ($target === false) {
fclose($source);
$output->writeln('<error>Cannot open temporary output.</error>');
return Command::FAILURE;
}
fputcsv($target, [
'website', 'status', 'company', 'contact',
'email', 'phone', 'people', 'error',
]);
while (($row = fgetcsv($source)) !== false) {
$website = trim((string) ($row[$websiteIndex] ?? ''));
if (!$this->isPublicWebUrl($website)) {
$this->write($target, [
$website, 'invalid_input', '', '', '', '', '',
'Expected an absolute HTTP or HTTPS website URL.',
]);
continue;
}
try {
$result = $this->client->extract($website);
$this->write($target, [
$website,
'ok',
$result->company ?? '',
$result->contact ?? '',
$result->email ?? '',
$result->phone ?? '',
json_encode(
$result->people,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
) ?: '[]',
'',
]);
} catch (ServiceFailure $e) {
$this->write($target, [
$website, $e->kind, '', '', '', '', '', $e->getMessage(),
]);
}
}
fclose($source);
fclose($target);
if (!rename($temporaryPath, $outputPath)) {
$output->writeln('<error>Cannot publish output CSV.</error>');
return Command::FAILURE;
}
$output->writeln(sprintf('Review file written to %s', $outputPath));
return Command::SUCCESS;
}
private function isPublicWebUrl(string $website): bool
{
if (filter_var($website, FILTER_VALIDATE_URL) === false) {
return false;
}
$scheme = strtolower((string) parse_url($website, PHP_URL_SCHEME));
return in_array($scheme, ['http', 'https'], true)
&& parse_url($website, PHP_URL_HOST) !== null;
}
private function write($stream, array $cells): void
{
fputcsv($stream, array_map(
static function (mixed $value): string {
$text = (string) $value;
return preg_match('/^[=+\-@]/', $text) === 1
? "'" . $text
: $text;
},
$cells
));
}
}
The temporary file prevents consumers from seeing a half-written final CSV. The output writer also neutralizes spreadsheet formulas, because public website content must not be allowed to create executable spreadsheet cells.
Run the feature with:
php bin/console app:research-websites var/import/websites.csv var/export/contact-research.csv
Test the contract without making network calls
Symfony’s MockHttpClient provides a deterministic transport. The tests can verify the method, endpoint, query parameters, response mapping, and non-retryable authentication path without exposing a credential.
Create tests/CompanyData/WebsiteCompanyClientTest.php:
<?php
namespace App\Tests\CompanyData;
use App\CompanyData\ServiceFailure;
use App\CompanyData\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 testItMapsTheDocumentedFields(): void
{
$http = new MockHttpClient(
function (string $method, string $url, array $options): MockResponse {
self::assertSame('GET', $method);
self::assertSame(
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
$url
);
self::assertSame('test-token', $options['query']['token']);
self::assertSame(
'https://example.com',
$options['query']['website']
);
return new MockResponse(json_encode([
'company' => 'Example Company',
'contact' => 'General enquiries',
'email' => '[email protected]',
'phone' => '+1 555 0100',
'people' => [['name' => 'Public Contact']],
]), ['http_code' => 200]);
}
);
$client = new WebsiteCompanyClient(
$http,
new NullLogger(),
'test-token'
);
$result = $client->extract('https://example.com');
self::assertSame('Example Company', $result->company);
self::assertSame('[email protected]', $result->email);
self::assertCount(1, $result->people);
}
public function testItDoesNotRetryAuthenticationFailure(): void
{
$http = new MockHttpClient(
new MockResponse('', ['http_code' => 401])
);
$client = new WebsiteCompanyClient(
$http,
new NullLogger(),
'revoked-token'
);
try {
$client->extract('https://example.com');
self::fail('Expected ServiceFailure.');
} catch (ServiceFailure $e) {
self::assertSame('authentication', $e->kind);
self::assertSame(1, $http->getRequestsCount());
}
}
}
Run the suite with php bin/phpunit. A separate command test can use temporary CSV files and the Symfony console tester, but the most consequential boundary—the external call and its authentication behavior—is already deterministic here.
Operate it safely in production
Give the runtime write access only to the intended import and export directories. Treat the generated CSV as potentially sensitive business data: restrict access, set a retention period, and avoid attaching it to broad chat channels or public tickets.
Monitor structured failure counts by status. A sudden rise in authentication usually indicates a missing, rotated, or revoked token. Sustained rate_limited rows suggest that the batch size or schedule should be reduced, or that the active plan should be reviewed. invalid_response and repeated upstream_error deserve investigation rather than unlimited retries.
Common operational failures are straightforward:
- Every row reports authentication: verify the injected environment variable and update it after token regeneration.
- The command reports a missing header: rename the source column exactly to
websiteand export the first row as headers. - Rows report invalid input: use absolute URLs such as
https://example.com, not bare domains. - Many rows are rate-limited: preserve the output, reduce request frequency, and rerun only the affected websites later.
- A stale
.partfile remains: the previous process stopped before publication. Confirm no command is active before replacing it with a new run.
Final verification checklist
- The account and Free, Plus, or Pro plan are activated.
- The service-scoped token comes from the documentation page’s Service token panel.
- The real token exists only in environment-backed configuration.
- The minimal GET request succeeds with
tokenandwebsitequery parameters. - The CSV has a case-sensitive
websiteheader and absolute URLs. - Automated tests pass without contacting the live service.
- The command produces one reviewable result or failure row per non-empty input row.
- Logs and proxies do not expose the token or full authenticated URL.
- The deployment can write the temporary and final output files.
The useful artifact is not merely enriched data. It is a review queue with provenance, bounded failure behavior, and enough structure for a person to make the next decision confidently. That is the difference between an API demonstration and a dependable production tool: the happy path is only one row, while the real design is everything that keeps the rest of the spreadsheet understandable.