Symfony: Normalizirajte društvene poveznice pomoću AI Identity Resolvera
A social-link field looks simple until people paste mobile URLs, tracking parameters, profile aliases, or identifiers in several different forms. If a community directory stores those submissions unchanged, duplicate identities and brittle links quickly leak into search, moderation, and profile pages.
This tutorial builds a production-oriented Symfony endpoint that accepts Facebook, Instagram, and LinkedIn profile links, sends each one to the Identity Resolver, and returns a stable application-level object. The design keeps the remote response intact rather than guessing undocumented fields, while adding validation, bounded retries, structured failures, logging, and deterministic tests.
Get access before writing integration code
Begin at the official Identity Resolver service page, then read the official documentation. The current public endpoint requires no account token or API key.
For this endpoint, registration is not required and login is not required. These links deliberately return to the authoritative access documentation instead of suggesting an account page that is not part of the supplied onboarding contract.
- Open the documentation and confirm that the public endpoint still requires no authentication.
- Do not create an API key, copy a token, or add an
Authorizationheader. There is currently no credential-copying step. - Use the exact method and endpoint:
GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. - Send
platformplus one supported input parameter. This project usesurl; the contract also permits a supportedusername,id,identifier, orprofile.
Make the first test with a real public profile URL that you are permitted to process:
curl --get 'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
--data-urlencode 'platform=linkedin' \
--data-urlencode 'url=https://www.linkedin.com/in/replace-with-a-real-public-profile'
No token belongs in that command. The response is the normalized public identity. Because this article does not assume fields beyond the supplied contract, the application will validate that the response is a non-empty JSON object and preserve it as an opaque identity payload.
There is no credential to store. Put only the service location in .env.local, keeping deployment-specific configuration outside source code:
IDENTITY_RESOLVER_BASE_URI="https://ai.mihajlo.mk/api/identity-resolver"
Choose a deliberately small architecture
The feature is synchronous: a directory form submits up to three links, and the user receives normalized results before the profile is saved. That gives immediate feedback and avoids persisting unverified input. Messenger would be useful for bulk imports, but it adds little to an interactive three-link request.
The boundary has four responsibilities:
- The controller validates JSON, supported platforms, URL length, scheme, port, and social-network host.
- The resolver client owns the exact remote endpoint, timeouts, retries, and HTTP-status handling.
- A domain object provides a stable local envelope without inventing the service’s response schema.
- The profile-saving layer accepts only entries whose local status is
resolved.
A compact project structure is enough:
src/
Controller/SocialLinkController.php
Identity/IdentityResolver.php
Identity/IdentityResolverException.php
Identity/ResolvedIdentity.php
tests/
Identity/IdentityResolverTest.php
config/
services.yaml
Create the Symfony project
Use PHP 8.3 or newer and a maintained Symfony release compatible with it. A new application can install the framework, HTTP client, logging integration, and test support with Composer:
composer create-project symfony/skeleton community-directory
cd community-directory
composer require symfony/http-client symfony/monolog-bundle
composer require --dev symfony/test-pack
If the directory already exists, install only the missing packages. The implementation uses HttpClientInterface, constructor injection, attribute routes, and MockHttpClient; it does not need a separate HTTP library.
Define a stable domain boundary
The remote object should not spread through controllers, templates, and database code. Wrap it in an application-owned envelope while retaining the complete normalized identity.
<?php
// src/Identity/ResolvedIdentity.php
namespace App\Identity;
final readonly class ResolvedIdentity
{
public function __construct(
public string $platform,
public string $submittedUrl,
public array $identity,
) {
}
public function toArray(): array
{
return [
'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 $kind,
string $message,
public readonly ?int $upstreamStatus = null,
) {
parent::__construct($message);
}
}
The kind value is an internal failure classification, not an upstream response field. It lets the controller distinguish rate limiting, transport problems, invalid responses, and rejected requests without exposing remote bodies to users.
Build the resilient HTTP client
The client fixes the destination and accepts only platform and URL values. A submitted URL becomes an encoded query parameter; it never controls the outbound host. Three attempts cover transient transport errors, HTTP 429, and server failures. Other 4xx responses are not retried because repeating an invalid request wastes capacity.
<?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 $http,
private readonly LoggerInterface $logger,
private readonly string $baseUri,
private readonly int $maxAttempts = 3,
) {
}
public function resolve(string $platform, string $url): ResolvedIdentity
{
$endpoint = rtrim($this->baseUri, '/').'/v1/resolve';
for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
try {
$response = $this->http->request('GET', $endpoint, [
'query' => [
'platform' => $platform,
'url' => $url,
],
'timeout' => 3.0,
'max_duration' => 8.0,
'headers' => [
'Accept' => 'application/json',
],
]);
$status = $response->getStatusCode();
$headers = $response->getHeaders(false);
} catch (TransportExceptionInterface $exception) {
$this->logger->warning('Identity Resolver transport failure', [
'platform' => $platform,
'attempt' => $attempt,
]);
if ($attempt === $this->maxAttempts) {
throw new IdentityResolverException(
'transport',
'The identity service could not be reached.',
);
}
$this->pause([], $attempt);
continue;
}
if ($status === 429 || $status >= 500) {
$this->logger->warning('Identity Resolver transient response', [
'platform' => $platform,
'attempt' => $attempt,
'status' => $status,
]);
if ($attempt === $this->maxAttempts) {
throw new IdentityResolverException(
$status === 429 ? 'rate_limited' : 'upstream_unavailable',
'The identity service is temporarily unavailable.',
$status,
);
}
$this->pause($headers, $attempt);
continue;
}
if ($status < 200 || $status >= 300) {
$this->logger->notice('Identity Resolver rejected a request', [
'platform' => $platform,
'status' => $status,
]);
throw new IdentityResolverException(
'upstream_rejected',
'The submitted identity could not be resolved.',
$status,
);
}
try {
$payload = $response->toArray(false);
} catch (\Throwable $exception) {
throw new IdentityResolverException(
'invalid_response',
'The identity service returned invalid JSON.',
$status,
);
}
if ($payload === []) {
throw new IdentityResolverException(
'invalid_response',
'The identity service returned an empty identity.',
$status,
);
}
return new ResolvedIdentity($platform, $url, $payload);
}
throw new \LogicException('The retry loop terminated unexpectedly.');
}
private function pause(array $headers, int $attempt): void
{
$retryAfter = $headers['retry-after'][0] ?? null;
if (is_string($retryAfter) && ctype_digit($retryAfter)) {
$seconds = min(2.0, (float) $retryAfter);
} else {
$seconds = min(0.6, 0.15 * (2 ** ($attempt - 1)));
}
usleep((int) ($seconds * 1_000_000));
}
}
The backoff is intentionally bounded. A synchronous form submission should not hang for an arbitrary Retry-After interval. After the retry budget is exhausted, the application returns a structured failure and lets the user retry later.
Wire environment-backed configuration
Bind the base URI centrally. Do not add a placeholder token or authorization header: doing so would misrepresent the current public authentication contract.
# config/services.yaml
parameters:
identity_resolver.base_uri: '%env(IDENTITY_RESOLVER_BASE_URI)%'
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Identity\IdentityResolver:
arguments:
$baseUri: '%identity_resolver.base_uri%'
Normalize a directory submission
The controller accepts a links object containing one or more supported platforms. Validation happens before any external call, so one malformed link cannot produce a partially validated submission.
<?php
// src/Controller/SocialLinkController.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 SocialLinkController
{
private const DOMAINS = [
'facebook' => 'facebook.com',
'instagram' => 'instagram.com',
'linkedin' => 'linkedin.com',
];
#[Route(
'/api/directory/social-links/normalize',
name: 'directory_social_links_normalize',
methods: ['POST']
)]
public function __invoke(
Request $request,
IdentityResolver $resolver,
): JsonResponse {
try {
$body = $request->toArray();
} catch (\JsonException) {
return new JsonResponse(['error' => 'invalid_json'], 400);
}
$links = $body['links'] ?? null;
if (!is_array($links) || $links === []) {
return new JsonResponse(['error' => 'links_required'], 422);
}
$unknown = array_diff(array_keys($links), array_keys(self::DOMAINS));
if ($unknown !== []) {
return new JsonResponse([
'error' => 'unsupported_platform',
'platforms' => array_values($unknown),
], 422);
}
foreach ($links as $platform => $url) {
if (!$this->validUrl($platform, $url)) {
return new JsonResponse([
'error' => 'invalid_social_url',
'platform' => $platform,
], 422);
}
}
$results = [];
$failures = 0;
$onlyRateLimits = true;
foreach ($links as $platform => $url) {
try {
$results[$platform] = [
'status' => 'resolved',
'value' => $resolver->resolve($platform, $url)->toArray(),
];
} catch (IdentityResolverException $exception) {
$failures++;
$onlyRateLimits = $onlyRateLimits
&& $exception->kind === 'rate_limited';
$results[$platform] = [
'status' => 'failed',
'error' => $exception->kind,
];
}
}
if ($failures === count($links)) {
return new JsonResponse(
['results' => $results],
$onlyRateLimits ? 429 : 503,
);
}
return new JsonResponse(['results' => $results]);
}
private function validUrl(string $platform, mixed $value): bool
{
if (!is_string($value) || $value === '' || strlen($value) > 2048) {
return false;
}
if (filter_var($value, FILTER_VALIDATE_URL) === false) {
return false;
}
$scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
$host = strtolower((string) parse_url($value, PHP_URL_HOST));
$port = parse_url($value, PHP_URL_PORT);
$user = parse_url($value, PHP_URL_USER);
$pass = parse_url($value, PHP_URL_PASS);
$base = self::DOMAINS[$platform];
$hostAllowed = $host === $base
|| str_ends_with($host, '.'.$base);
return $scheme === 'https'
&& $hostAllowed
&& ($port === null || $port === 443)
&& $user === null
&& $pass === null;
}
}
A form can now submit all three networks in one request. Persist only each value whose status is resolved; retain the returned identity object as JSON if the service contract does not define narrower fields your domain can safely depend on.
curl 'https://directory.example/api/directory/social-links/normalize' \
--request POST \
--header 'Content-Type: application/json' \
--data '{
"links": {
"facebook": "https://www.facebook.com/replace-with-a-real-profile",
"instagram": "https://www.instagram.com/replace-with-a-real-profile",
"linkedin": "https://www.linkedin.com/in/replace-with-a-real-profile"
}
}'
Test retries and boundary mapping
MockHttpClient makes the tests deterministic and prevents accidental network calls. The successful fixture below is deliberately opaque; its example-only key is not presented as part of the real API schema.
<?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
{
public function testMapsSuccessfulIdentityWithoutGuessingFields(): void
{
$client = new MockHttpClient([
new MockResponse('{"example-only":"opaque-value"}', [
'http_code' => 200,
'response_headers' => ['content-type: application/json'],
]),
]);
$resolver = new IdentityResolver(
$client,
new NullLogger(),
'https://ai.mihajlo.mk/api/identity-resolver',
1,
);
$result = $resolver->resolve(
'instagram',
'https://www.instagram.com/public-profile',
);
self::assertSame('instagram', $result->platform);
self::assertSame(
['example-only' => 'opaque-value'],
$result->identity,
);
}
public function testRetriesServerFailureThenSucceeds(): void
{
$client = new MockHttpClient([
new MockResponse('', ['http_code' => 503]),
new MockResponse('{"example-only":"resolved"}', [
'http_code' => 200,
]),
]);
$resolver = new IdentityResolver(
$client,
new NullLogger(),
'https://ai.mihajlo.mk/api/identity-resolver',
2,
);
self::assertSame(
['example-only' => 'resolved'],
$resolver->resolve(
'facebook',
'https://www.facebook.com/public-profile',
)->identity,
);
}
public function testDoesNotRetryRejectedRequest(): void
{
$calls = 0;
$client = new MockHttpClient(
function () use (&$calls): MockResponse {
$calls++;
return new MockResponse('', ['http_code' => 400]);
}
);
$resolver = new IdentityResolver(
$client,
new NullLogger(),
'https://ai.mihajlo.mk/api/identity-resolver',
3,
);
try {
$resolver->resolve(
'linkedin',
'https://www.linkedin.com/in/public-profile',
);
self::fail('An exception was expected.');
} catch (IdentityResolverException $exception) {
self::assertSame('upstream_rejected', $exception->kind);
self::assertSame(1, $calls);
}
}
}
Run the suite with php bin/phpunit. Add controller tests for your application’s authentication and ownership rules, because those policies belong to the directory rather than the resolver client.
Security and operational discipline
Protect the route with the same authentication and authorization used to edit a community profile. A signed-in member must not be able to normalize or save links against another member’s record. If browser sessions authenticate the request, apply the application’s CSRF strategy as well.
The host allowlist reduces malformed submissions and deceptive domains. The fixed base URI prevents server-side request forgery: user input remains query data and never becomes the destination. Apply request-rate limits at the application or edge, cap JSON body size, and avoid logging submitted URLs because usernames can be personal data.
The supplied authentication contract is public today. If it changes, add the credential to the deployment secret store and expose it through an environment-backed Symfony parameter. Never commit it, return it in errors, or place it in fixtures.
Useful logs contain platform, attempt, status, and internal failure kind. Alert on sustained increases in rate_limited, transport, or invalid_response. Do not alert on a single rejected profile; that is usually an input or support concern rather than an outage.
Deployment and common failure modes
Set IDENTITY_RESOLVER_BASE_URI in every runtime environment, including worker or container definitions even though this version is synchronous. Deploy with normal production commands:
composer install --no-dev --classmap-authoritative
php bin/console cache:clear --env=prod
php bin/console cache:warmup --env=prod
php bin/console debug:router directory_social_links_normalize
- Every URL returns 422: confirm the link uses HTTPS and the host genuinely belongs to the selected platform.
- The service returns 4xx: verify the platform and URL using the official documentation. The client correctly avoids retrying these requests.
- Requests end as 429: reduce submission frequency and retry later. Do not increase synchronous sleep indefinitely.
- Intermittent 503 responses: inspect structured logs and upstream availability. The bounded retry already handles short disruptions.
- Responses become
invalid_response: capture status and content type operationally, but do not expose or indiscriminately log the response body. - Local requests work but production fails: verify outbound HTTPS access, DNS, trusted certificate authorities, and the production environment variable.
Final verification checklist
- The application calls exactly
GET /api/identity-resolver/v1/resolvewithplatformandurl. - No token, API key, or invented authorization header is sent.
- Facebook, Instagram, and LinkedIn links are validated before network access.
- Connection inactivity and total request duration are bounded.
- Only transport errors, HTTP 429, and server errors are retried.
- The normalized public identity is preserved without assuming undocumented fields.
- Partial failures are explicit, and unresolved identities are not saved.
- Tests use
MockHttpClientand make no real external requests. - Logs contain operational context but exclude full profile URLs and response bodies.
The important result is not merely cleaner links. It is a dependable boundary between unpredictable human input and a directory’s long-lived data. Once that boundary validates early, retries selectively, maps defensively, and fails visibly, social profiles stop being fragile strings and become identities the rest of the application can safely use.