Tutorials

Symfony: Standardize Social Media Links in Your Directory with AI Identity Resolver

Symfony: Standardize Social Media Links in Your Directory with AI Identity Resolver

A community directory rarely receives social profiles in one tidy format. One member pastes a full Instagram URL, another submits a Facebook link with tracking parameters, and someone else enters a LinkedIn profile copied from a mobile browser. If those values are stored unchanged, searching, deduplication, validation, and presentation quickly become unreliable.

This tutorial builds a production-oriented Symfony feature that sends submitted Facebook, Instagram, and LinkedIn links to the Identity Resolver service and returns a stable public identity object. The integration uses Symfony’s HTTP client, explicit domain mapping, bounded retries, structured failures, deterministic tests, and deployment-safe configuration.

Get access before writing integration code

Start with the Identity Resolver service page, which describes the service and its available plan information. Then read the official Identity Resolver documentation before making a request.

The usual questions about registration and login requirements have a particularly simple answer here: the current public endpoint requires no account token or API key. There is therefore no credential screen from which to copy a token, and no authorization header should be added. Do not invent or provision a placeholder secret for this integration.

The exact request is an HTTP GET to:

https://ai.mihajlo.mk/api/identity-resolver/v1/resolve

The endpoint accepts platform plus one supported username, id, identifier, profile, or url parameter. Because this project normalizes links submitted to a directory, it deliberately sends only platform and url.

Make a minimal request before building the feature:

curl --fail-with-body --get \
  'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
  --data-urlencode 'platform=linkedin' \
  --data-urlencode 'url=https://www.linkedin.com/in/example-profile/'

Replace the example with a public profile URL you are permitted to process. No Authorization header is needed. Treat the returned JSON as the normalized public identity object; the application boundary below intentionally avoids assuming undocumented field names.

Prerequisites and project shape

You need PHP 8.3 or newer, Composer, and a Symfony application capable of receiving JSON requests. A focused installation can be created with:

composer create-project symfony/skeleton community-directory
cd community-directory
composer require symfony/http-client symfony/monolog-bundle
composer require --dev symfony/test-pack

The feature has three layers:

  • The controller validates the directory submission and owns the application’s HTTP response.
  • IdentityResolver owns the remote protocol, timeouts, retries, and upstream error classification.
  • NormalizedSocialIdentity carries the platform and the opaque normalized identity object into the directory domain.

Normalization is synchronous because a person submitting a directory profile benefits from immediate feedback. Messenger would add operational machinery without improving this short request path. If imports later process thousands of existing members, the same service can be called from a Messenger handler without changing its API boundary.

The relevant structure is intentionally small:

config/services.yaml
src/Controller/NormalizeSocialLinkController.php
src/Identity/IdentityResolver.php
src/Identity/NormalizedSocialIdentity.php
src/Identity/ResolverFailure.php
tests/Identity/IdentityResolverTest.php

Configure the endpoint without inventing a credential

Keep deployment-specific values in environment-backed configuration. Since this public endpoint currently has no token, store only its URL in .env.local during development:

IDENTITY_RESOLVER_ENDPOINT=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve

Do not add IDENTITY_RESOLVER_TOKEN or YOUR_SERVICE_TOKEN; neither belongs to the supplied authentication contract. If authentication is introduced in the future, follow the official documentation and add the credential through the deployment platform’s secret store rather than committing it.

Bind the endpoint and HTTP client in config/services.yaml:

services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    App\Identity\IdentityResolver:
        arguments:
            $endpoint: '%env(string:IDENTITY_RESOLVER_ENDPOINT)%'

Map the response at the domain boundary

The published contract promises a normalized public identity response, but it does not justify guessing individual JSON properties. Preserve that object while giving the application a stable wrapper of its own:

<?php

namespace App\Identity;

final readonly class NormalizedSocialIdentity
{
    public function __construct(
        public string $platform,
        public array $publicIdentity,
    ) {
    }

    public function toArray(): array
    {
        return [
            'platform' => $this->platform,
            'identity' => $this->publicIdentity,
        ];
    }
}

This separation matters. Your controller contract remains platform plus identity, while the resolver’s documented public identity object remains intact. If the directory uses Doctrine, store the object in a JSON column alongside the member and platform, or map documented fields only after confirming them in the official documentation.

Represent operational failures explicitly:

<?php

namespace App\Identity;

final class ResolverFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $reason,
        public readonly ?int $upstreamStatus = null,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($reason, 0, $previous);
    }
}

Build a bounded, retry-aware HTTP client

The service class accepts only supported platforms and valid HTTP or HTTPS URLs. It retries transport errors, rate limits, and a narrow set of transient server responses. Client errors are not blindly retried. The delay is capped so a profile submission cannot occupy a PHP worker indefinitely.

<?php

namespace App\Identity;

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

final class IdentityResolver
{
    private const PLATFORMS = ['facebook', 'instagram', 'linkedin'];
    private readonly \Closure $sleeper;

    public function __construct(
        private readonly HttpClientInterface $httpClient,
        private readonly string $endpoint,
        ?\Closure $sleeper = null,
    ) {
        $this->sleeper = $sleeper ?? static fn (int $microseconds) => usleep($microseconds);
    }

    public function resolveUrl(string $platform, string $url): NormalizedSocialIdentity
    {
        $platform = strtolower(trim($platform));
        $url = trim($url);

        if (!in_array($platform, self::PLATFORMS, true)) {
            throw new \InvalidArgumentException('Unsupported social platform.');
        }

        if (
            filter_var($url, FILTER_VALIDATE_URL) === false
            || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)
        ) {
            throw new \InvalidArgumentException('A valid HTTP or HTTPS profile URL is required.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->httpClient->request('GET', $this->endpoint, [
                    'query' => [
                        'platform' => $platform,
                        'url' => $url,
                    ],
                    'timeout' => 3.0,
                    'max_duration' => 8.0,
                ]);

                $status = $response->getStatusCode();

                if ($status >= 200 && $status < 300) {
                    $payload = $response->toArray(false);

                    if ($payload === [] || array_is_list($payload)) {
                        throw new ResolverFailure('malformed_response', $status);
                    }

                    return new NormalizedSocialIdentity($platform, $payload);
                }

                $retryable = $status === 429 || in_array($status, [502, 503, 504], true);

                if ($retryable && $attempt < 3) {
                    $retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null;
                    ($this->sleeper)($this->delay($attempt, $retryAfter));
                    continue;
                }

                if ($status >= 400 && $status < 500) {
                    throw new ResolverFailure('request_rejected', $status);
                }

                throw new ResolverFailure('upstream_unavailable', $status);
            } catch (DecodingExceptionInterface $exception) {
                throw new ResolverFailure('malformed_response', null, $exception);
            } catch (TransportExceptionInterface $exception) {
                if ($attempt === 3) {
                    throw new ResolverFailure('transport_failure', null, $exception);
                }

                ($this->sleeper)($this->delay($attempt, null));
            }
        }

        throw new ResolverFailure('upstream_unavailable');
    }

    private function delay(int $attempt, ?string $retryAfter): int
    {
        if ($retryAfter !== null && ctype_digit($retryAfter)) {
            return min(2_000_000, (int) $retryAfter * 1_000_000);
        }

        return min(2_000_000, 200_000 * (2 ** ($attempt - 1)));
    }
}

A malformed successful response is not retried because repeating an identical request is unlikely to repair a schema problem. Numeric Retry-After values are respected but capped at two seconds. The client sends no cookies, credentials, or authorization headers.

Expose the directory normalization route

The controller accepts a member-facing submission, invokes the resolver, logs operational context without logging the full profile URL or returned identity, and produces structured application errors:

<?php

namespace App\Controller;

use App\Identity\IdentityResolver;
use App\Identity\ResolverFailure;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class NormalizeSocialLinkController
{
    #[Route('/directory/social-links/normalize', methods: ['POST'])]
    public function __invoke(
        Request $request,
        IdentityResolver $resolver,
        LoggerInterface $logger,
    ): JsonResponse {
        try {
            $input = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);

            if (!is_array($input)) {
                throw new \InvalidArgumentException('A JSON object is required.');
            }

            $platform = $input['platform'] ?? null;
            $url = $input['url'] ?? null;

            if (!is_string($platform) || !is_string($url)) {
                throw new \InvalidArgumentException('Platform and URL must be strings.');
            }

            $identity = $resolver->resolveUrl($platform, $url);

            return new JsonResponse($identity->toArray(), 200);
        } catch (\JsonException|\InvalidArgumentException $exception) {
            return new JsonResponse([
                'error' => 'invalid_submission',
                'message' => $exception->getMessage(),
            ], 422);
        } catch (ResolverFailure $exception) {
            $logger->warning('Identity resolution failed.', [
                'reason' => $exception->reason,
                'upstream_status' => $exception->upstreamStatus,
            ]);

            $status = $exception->reason === 'request_rejected' ? 422 : 503;

            return new JsonResponse([
                'error' => $exception->reason,
            ], $status);
        }
    }
}

The route does not accept an arbitrary remote endpoint, so users cannot turn it into a server-side request forgery proxy. The submitted URL is sent only as an encoded query parameter to the configured resolver endpoint.

Test success, request shape, and retry behavior

MockHttpClient makes the tests deterministic and prevents accidental network traffic. Artificial fixture keys below test transparent boundary mapping; they do not claim to document the service’s response fields.

<?php

namespace App\Tests\Identity;

use App\Identity\IdentityResolver;
use PHPUnit\Framework\TestCase;
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 testItMapsAResolvedIdentityAndSendsTheContractedQuery(): void
    {
        $http = new MockHttpClient(
            function (string $method, string $url): MockResponse {
                self::assertSame('GET', $method);

                parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
                self::assertSame('instagram', $query['platform']);
                self::assertSame(
                    'https://www.instagram.com/example/',
                    $query['url']
                );

                return new MockResponse(
                    json_encode(['fixture' => 'normalized'], JSON_THROW_ON_ERROR),
                    ['http_code' => 200]
                );
            }
        );

        $resolver = new IdentityResolver($http, self::ENDPOINT);
        $result = $resolver->resolveUrl(
            'instagram',
            'https://www.instagram.com/example/'
        );

        self::assertSame('instagram', $result->platform);
        self::assertSame(['fixture' => 'normalized'], $result->publicIdentity);
    }

    public function testItRetriesATransientFailureWithoutActuallySleeping(): void
    {
        $http = new MockHttpClient([
            new MockResponse('', ['http_code' => 503]),
            new MockResponse(
                json_encode(['fixture' => 'recovered'], JSON_THROW_ON_ERROR),
                ['http_code' => 200]
            ),
        ]);

        $delays = [];
        $resolver = new IdentityResolver(
            $http,
            self::ENDPOINT,
            static function (int $delay) use (&$delays): void {
                $delays[] = $delay;
            }
        );

        $result = $resolver->resolveUrl(
            'facebook',
            'https://www.facebook.com/example/'
        );

        self::assertSame(['fixture' => 'recovered'], $result->publicIdentity);
        self::assertSame([200_000], $delays);
        self::assertSame(2, $http->getRequestsCount());
    }
}

Run the suite with php bin/phpunit. Add controller tests for malformed JSON and unsupported platforms if this endpoint is part of a larger application security boundary.

Security, observability, and deployment

Public social links are still user-provided data. Enforce request-size limits at the web server, require normal application authentication where directory editing is restricted, and apply CSRF protection to browser forms. Do not render fields from the normalized object as trusted HTML. Escape displayed values and allow only expected URL schemes.

Logs should contain the failure reason, upstream status, platform when useful, request correlation ID, and elapsed time. Avoid logging the submitted URL or complete response because either may contain personal data or unexpected query parameters. Track rates of request_rejected, transport_failure, malformed_response, and HTTP 429 responses separately.

At deployment, define IDENTITY_RESOLVER_ENDPOINT in each environment, warm the Symfony cache, and run the test suite before shifting traffic. Ensure outbound HTTPS access to ai.mihajlo.mk is permitted. Keep the PHP worker timeout above the client’s eight-second maximum duration while preserving a finite application-level request timeout.

Common failure modes

  • Every request receives 422: confirm the body is JSON and contains string values named platform and url.
  • A platform is rejected locally: use exactly facebook, instagram, or linkedin.
  • The upstream rejects a request: verify that the selected platform matches the submitted public URL. Do not retry unchanged client errors.
  • Responses intermittently become 503: inspect transport, timeout, 429, and upstream-status logs. The request has already exhausted bounded retries.
  • Tests contact the real service: inject MockHttpClient; never make live API availability part of a unit test.
  • Deployment expects a secret: remove the assumption. The current public endpoint requires no account token or API key.

Final verification checklist

  1. Confirm the official documentation still describes the public endpoint as token-free.
  2. Send the minimal curl request with an appropriate public profile URL.
  3. Run php bin/phpunit and verify both mapping and retry tests pass.
  4. Submit Facebook, Instagram, and LinkedIn URLs through the directory route.
  5. Verify successful responses contain your stable wrapper with platform and identity.
  6. Simulate invalid JSON, an unsupported platform, a timeout, HTTP 429, and HTTP 503.
  7. Confirm logs contain structured failure metadata but no complete social URL or identity payload.
  8. Verify production permits outbound HTTPS and supplies the endpoint through environment configuration.

Make normalization the boundary, not an afterthought

A reliable directory should not force every search page, template, export, and duplicate checker to reinterpret arbitrary social links. Normalize once, at the point where data enters the system, and carry a deliberate domain object forward.

The most important design choice is not the HTTP call itself. It is the boundary around that call: documented inputs, no invented authentication, defensive response handling, bounded failure behavior, privacy-conscious logs, and tests that never depend on the network. With those pieces in place, inconsistent social links become predictable directory data instead of permanent cleanup work.

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.