Symfony: Normalize Social Links with AI Identity Resolution for Community Directories
A community directory rarely receives tidy social data. One member pastes a full Instagram URL, another supplies a Facebook profile link copied from a mobile browser, and a third submits LinkedIn in yet another form. Saving those strings unchanged creates duplicate identities, inconsistent links, and brittle search logic.
This tutorial builds a production-oriented Symfony feature that accepts Facebook, Instagram, and LinkedIn profile URLs, validates them locally, resolves each reference through the Identity Resolver, and returns a predictable domain result. The external response remains defensively validated because an integration boundary should never assume more fields than its documented contract guarantees.
Get access before writing integration code
Start with the Identity Resolver service page, then read the official documentation. The same documentation is the authoritative place to check registration guidance and login guidance.
For the current public endpoint, neither registration nor login is required for the first request. There is no account token or API key to copy, and the request must not contain an invented bearer token. If that access policy changes later, follow the documentation rather than guessing an authentication header.
The exact request is an HTTP GET to https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. It accepts platform together with a supported username, id, identifier, profile, or url parameter. Our directory uses url because members submit links.
Make a minimal request before building the feature:
curl --fail-with-body --get \
--header 'Accept: application/json' \
--data-urlencode 'platform=instagram' \
--data-urlencode 'url=https://www.instagram.com/example/' \
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'
Use a real public profile URL you are entitled to process. A nonexistent example account may produce a legitimate not-found response, so it is unsuitable for confirming successful resolution.
There is no credential to store in this integration. Record the endpoint in .env.local, which Symfony does not normally commit, but do not create a fake secret:
IDENTITY_RESOLVER_ENDPOINT=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
Choose a small, explicit architecture
The controller owns HTTP input validation and batch orchestration. A dedicated client owns the remote protocol, timeouts, retries, JSON validation, and logging. A domain object separates the rest of the directory from Symfony HttpClient response objects.
This synchronous design is appropriate for three links submitted with a form: it is easy to deploy and gives immediate feedback. Messenger would become useful if normalization moved into bulk imports or if the user did not need an immediate result. Adding a queue here would create operational work without improving the ordinary submission flow.
The relevant project structure is:
community-directory/
├── .env.local
├── config/services.yaml
├── src/Controller/NormalizeSocialLinksController.php
├── src/Identity/IdentityResolution.php
├── src/Identity/IdentityResolverException.php
├── src/Identity/IdentityResolver.php
└── tests/Identity/IdentityResolverTest.php
Create the Symfony project and configuration
You need PHP 8.3 or later, Composer, and a current Symfony application. For a new minimal project, install the framework, HTTP client, and test runner:
composer create-project symfony/skeleton community-directory
cd community-directory
composer require symfony/http-client
composer require --dev phpunit/phpunit:^11.0
Bind the endpoint by argument name. This keeps environment lookup outside application code and makes the client straightforward to construct in tests.
# config/services.yaml
parameters:
identity_resolver.endpoint: '%env(string:IDENTITY_RESOLVER_ENDPOINT)%'
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Identity\IdentityResolver:
arguments:
$endpoint: '%identity_resolver.endpoint%'
Map the remote document at the application boundary
The service promises a normalized public identity response, but this tutorial does not invent individual response fields. The mapper verifies that the root is a nonempty JSON object, then carries that object as identity. Code that later depends on a documented field should validate that field here before exposing it to the rest of the application.
<?php
// src/Identity/IdentityResolution.php
namespace App\Identity;
final readonly class IdentityResolution
{
public function __construct(
public string $platform,
public string $submittedUrl,
public array $identity,
) {}
public function toArray(): array
{
return [
'status' => 'resolved',
'platform' => $this->platform,
'submitted_url' => $this->submittedUrl,
'identity' => $this->identity,
];
}
}
<?php
// src/Identity/IdentityResolverException.php
namespace App\Identity;
final class IdentityResolverException extends \RuntimeException
{
public function __construct(
public readonly string $failureCode,
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Build a bounded and retry-aware API client
The client permits three attempts. It retries transport failures, HTTP 429, and server failures because those conditions can be transient. It does not blindly retry other 4xx responses: repeating invalid input or an authentication failure wastes capacity and hides the real problem.
Connection activity is constrained by timeout, while max_duration bounds the complete request. Backoff is deliberately short because this work remains on the user-facing request path. Logs include platform, status, and attempt, but exclude the submitted URL and response body to avoid leaking public-profile data into a second system.
<?php
// src/Identity/IdentityResolver.php
namespace App\Identity;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class IdentityResolver
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
private readonly string $endpoint,
) {}
public function resolve(string $platform, string $url): IdentityResolution
{
for ($attempt = 1; $attempt <= 3; ++$attempt) {
try {
$response = $this->httpClient->request('GET', $this->endpoint, [
'query' => [
'platform' => $platform,
'url' => $url,
],
'headers' => ['Accept' => 'application/json'],
'timeout' => 2.0,
'max_duration' => 5.0,
]);
$status = $response->getStatusCode();
if (($status === 429 || $status >= 500) && $attempt < 3) {
$headers = $response->getHeaders(false);
$retryAfter = $headers['retry-after'][0] ?? null;
$this->logger->warning('Identity resolution will be retried.', [
'platform' => $platform,
'status' => $status,
'attempt' => $attempt,
]);
$this->pause($attempt, $retryAfter);
continue;
}
if ($status === 429) {
throw new IdentityResolverException(
'rate_limited',
'The identity service is temporarily rate limited.',
);
}
if ($status >= 500) {
throw new IdentityResolverException(
'upstream_unavailable',
'The identity service is temporarily unavailable.',
);
}
if ($status < 200 || $status >= 300) {
throw new IdentityResolverException(
'request_rejected',
sprintf('The identity service rejected the request with HTTP %d.', $status),
);
}
try {
$data = json_decode(
$response->getContent(false),
true,
512,
JSON_THROW_ON_ERROR,
);
} catch (\JsonException $exception) {
throw new IdentityResolverException(
'malformed_response',
'The identity service returned invalid JSON.',
$exception,
);
}
if (!is_array($data) || $data === [] || array_is_list($data)) {
throw new IdentityResolverException(
'malformed_response',
'The identity service did not return a nonempty identity object.',
);
}
return new IdentityResolution($platform, $url, $data);
} catch (TransportExceptionInterface $exception) {
$this->logger->warning('Identity resolution transport failure.', [
'platform' => $platform,
'attempt' => $attempt,
'exception_class' => $exception::class,
]);
if ($attempt === 3) {
throw new IdentityResolverException(
'transport_failure',
'The identity service could not be reached.',
$exception,
);
}
$this->pause($attempt, null);
}
}
throw new IdentityResolverException(
'upstream_unavailable',
'Identity resolution ended without a result.',
);
}
private function pause(int $attempt, ?string $retryAfter): void
{
if ($retryAfter !== null && ctype_digit($retryAfter)) {
$milliseconds = min(2000, (int) $retryAfter * 1000);
} else {
$milliseconds = $attempt === 1 ? 100 : 250;
}
usleep($milliseconds * 1000);
}
}
Accept and normalize a directory submission
The route accepts a JSON object named links. Each key must be one of the three supported platforms. Local URL checks reject credentials, fragments, non-HTTPS schemes, and deceptive hosts such as linkedin.com.attacker.example before any remote call.
Each valid link produces its own outcome. One unavailable profile therefore does not erase two successful resolutions.
<?php
// src/Controller/NormalizeSocialLinksController.php
namespace App\Controller;
use App\Identity\IdentityResolver;
use App\Identity\IdentityResolverException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class NormalizeSocialLinksController
{
private const HOSTS = [
'facebook' => 'facebook.com',
'instagram' => 'instagram.com',
'linkedin' => 'linkedin.com',
];
#[Route(
'/api/community/social-links/normalize',
name: 'community_social_links_normalize',
methods: ['POST'],
)]
public function __invoke(
Request $request,
IdentityResolver $resolver,
): JsonResponse {
try {
$payload = json_decode(
$request->getContent(),
true,
32,
JSON_THROW_ON_ERROR,
);
} catch (\JsonException) {
return new JsonResponse(['error' => 'invalid_json'], 400);
}
$links = is_array($payload) ? ($payload['links'] ?? null) : null;
if (!is_array($links) || $links === []) {
return new JsonResponse(['error' => 'links_must_be_a_nonempty_object'], 422);
}
$results = [];
foreach ($links as $platform => $url) {
if (!is_string($platform) || !array_key_exists($platform, self::HOSTS)) {
$results[(string) $platform] = [
'status' => 'failed',
'error' => 'unsupported_platform',
];
continue;
}
if (!is_string($url) || !$this->isAllowedUrl($url, self::HOSTS[$platform])) {
$results[$platform] = [
'status' => 'failed',
'error' => 'invalid_profile_url',
];
continue;
}
try {
$results[$platform] = $resolver->resolve($platform, $url)->toArray();
} catch (IdentityResolverException $exception) {
$results[$platform] = [
'status' => 'failed',
'error' => $exception->failureCode,
];
}
}
return new JsonResponse(['results' => $results]);
}
private function isAllowedUrl(string $url, string $baseHost): bool
{
if (filter_var($url, FILTER_VALIDATE_URL) === false) {
return false;
}
$parts = parse_url($url);
if (!is_array($parts)) {
return false;
}
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = strtolower(rtrim((string) ($parts['host'] ?? ''), '.'));
if ($scheme !== 'https' || isset($parts['user']) || isset($parts['pass'])) {
return false;
}
if (isset($parts['fragment']) || ($parts['path'] ?? '/') === '/') {
return false;
}
return $host === $baseHost || str_ends_with($host, '.'.$baseHost);
}
}
Test without calling the public service
MockHttpClient gives the tests a deterministic transport. These tests verify query construction, mapping, retry behavior, and the crucial rule that validation-style failures are not retried.
<?php
// tests/Identity/IdentityResolverTest.php
namespace App\Tests\Identity;
use App\Identity\IdentityResolver;
use App\Identity\IdentityResolverException;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class IdentityResolverTest extends TestCase
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve';
public function testItMapsAValidIdentityObject(): void
{
$client = new MockHttpClient(function (
string $method,
string $url,
array $options,
): MockResponse {
self::assertSame('GET', $method);
self::assertSame(self::ENDPOINT, $url);
self::assertSame('instagram', $options['query']['platform']);
self::assertSame(
'https://www.instagram.com/example/',
$options['query']['url'],
);
return new MockResponse('{"public_reference":"normalized-value"}', [
'http_code' => 200,
'response_headers' => ['content-type: application/json'],
]);
});
$resolver = new IdentityResolver($client, new NullLogger(), self::ENDPOINT);
$result = $resolver->resolve(
'instagram',
'https://www.instagram.com/example/',
);
self::assertSame('instagram', $result->platform);
self::assertSame(
['public_reference' => 'normalized-value'],
$result->identity,
);
}
public function testItRetriesAServiceFailure(): void
{
$client = new MockHttpClient([
new MockResponse('temporary', ['http_code' => 503]),
new MockResponse('{"public_reference":"resolved"}', [
'http_code' => 200,
]),
]);
$resolver = new IdentityResolver($client, new NullLogger(), self::ENDPOINT);
$result = $resolver->resolve(
'linkedin',
'https://www.linkedin.com/in/example/',
);
self::assertSame('resolved', $result->identity['public_reference']);
self::assertSame(2, $client->getRequestsCount());
}
public function testItDoesNotRetryARejectedRequest(): void
{
$client = new MockHttpClient(
new MockResponse('invalid', ['http_code' => 400]),
);
$resolver = new IdentityResolver($client, new NullLogger(), self::ENDPOINT);
try {
$resolver->resolve(
'facebook',
'https://www.facebook.com/example/',
);
self::fail('An exception was expected.');
} catch (IdentityResolverException $exception) {
self::assertSame('request_rejected', $exception->failureCode);
self::assertSame(1, $client->getRequestsCount());
}
}
}
Run the suite with vendor/bin/phpunit. The fabricated fields in these fixtures test our defensive container only; application code must not treat them as documented service fields.
Verify the complete request
Start Symfony through your normal local web server and submit all three platforms:
curl --fail-with-body \
--request POST \
--header 'Content-Type: application/json' \
--data '{
"links": {
"facebook": "https://www.facebook.com/example/",
"instagram": "https://www.instagram.com/example/",
"linkedin": "https://www.linkedin.com/in/example/"
}
}' \
'https://directory.example/api/community/social-links/normalize'
Replace the host and sample profiles with your local application and appropriate public references. The response contains one resolved or failed result per submitted platform.
Security, observability, and deployment
Public profile data is still user-linked data. Apply the directory’s retention policy, authorize the submission route, add CSRF protection when it is called by a browser form, and enforce request-size and application-level rate limits. Do not log URLs, raw identity documents, or response bodies. If normalized payloads are persisted, store only the documented fields the directory actually needs.
Expose counters for resolutions by platform, result class, latency bucket, retries, and rate limiting. Never use usernames or URLs as metric labels. Alert on sustained transport failures, malformed responses, and rising 429 rates; a single transient retry is usually operational information, not an incident.
In production, set IDENTITY_RESOLVER_ENDPOINT in the deployment environment and warm the Symfony cache after release. The value is configuration rather than a secret, but keeping it outside code enables controlled environment changes. If authentication is introduced in the future, place the real credential in the deployment secret store or Symfony secrets, never in .env, logs, fixtures, or source control.
Common failures worth rehearsing
- HTTP 400: verify the supported platform spelling and that exactly one supported identity parameter, such as
url, is being sent. - HTTP 401 or 403: do not invent a token. Recheck the official documentation in case the public authentication policy has changed.
- HTTP 429: preserve the structured
rate_limitedoutcome, honor a bounded numericRetry-After, and reduce caller traffic rather than adding unlimited retries. - HTTP 5xx or transport failure: return a temporary failure that can be retried by the user; do not silently save the unnormalized input as if resolution succeeded.
- Malformed JSON: treat it as an upstream contract failure and retain enough status metadata for diagnosis without recording the response body.
- Valid URL, wrong host: reject it locally. Suffix checks must include the dot boundary so lookalike domains cannot pass validation.
Final verification checklist
- Confirm the endpoint and current no-token policy against the official documentation.
- Run the PHPUnit suite and verify that retry and no-retry paths pass.
- Submit one valid Facebook, Instagram, and LinkedIn URL together.
- Test malformed JSON, an unsupported platform, a deceptive hostname, and an HTTP URL.
- Confirm that logs and metrics contain no submitted URL or identity payload.
- Verify bounded timeouts and graceful behavior when outbound networking is unavailable.
- Confirm that partial failure leaves successful platform resolutions intact.
The durable part of this feature is not the controller or even the remote call. It is the boundary: narrow input, explicit failure states, bounded waiting, restrained retries, and a domain result that refuses to pretend uncertain data is trustworthy. With that boundary in place, three messy social links become stable directory identities without turning a simple community feature into an operational gamble.