Tutorials

Symfony: Resolve Social Links to Consistent Creator Profile Cards with AI

Symfony: Resolve Social Links to Consistent Creator Profile Cards with AI

A creator contact manager often begins with a deceptively simple field: “social link.” Then the data arrives. One person pastes an Instagram URL, another enters a username, and a third supplies a LinkedIn profile reference. Comparing, deduplicating, and presenting those values quickly becomes unreliable.

The Identity Resolver solves the boundary problem. It accepts public Facebook, Instagram, and LinkedIn references and returns a normalized public identity object. In this tutorial, we will build a Symfony application service that turns those results into consistent creator profile cards, with defensive mapping, bounded retries, structured failures, tests, and production-safe logging.

Get access before writing integration code

Start with the Identity Resolver service page, then read the official documentation. The documentation is also the authoritative source for registration information and login information.

The current public endpoint requires no account token or API key. Consequently, there is no registration, login, plan selection, token-copying screen, or credential to complete before the first request. Do not invent an authorization header or place a placeholder token in the application. Recheck the documentation before deployment in case the access contract changes later.

The exact operation is:

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

Send platform plus one supported reference parameter: username, id, identifier, profile, or url. Here is a minimal request using a deliberately generic example value:

curl --get \
  'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
  --data-urlencode 'platform=instagram' \
  --data-urlencode 'username=example_creator'

Because no credential exists, there is no secret to store after this test. We will still keep the endpoint in environment-backed configuration so staging, testing, and future API migrations do not require source changes.

Create the Symfony project

This implementation targets PHP 8.3 or newer and a current Symfony application with the FrameworkBundle, HttpClient, Monolog, and PHPUnit tooling. Create a small API-oriented project as follows:

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

Add the service URI to .env, committing only this non-secret default:

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

A deployment may override it in .env.local or, preferably, through the hosting platform’s environment configuration. There is intentionally no IDENTITY_RESOLVER_TOKEN.

The project has three important boundaries:

  • The controller validates contact-manager input and shapes the application response.
  • The resolver owns HTTP behavior, retries, and response decoding.
  • The domain mapper accepts an uncertain external object and produces a deterministic local card.

We will keep resolution synchronous. A user adding one social reference benefits from an immediate result, and introducing Messenger would create more operational machinery than this interaction needs. For bulk imports, the same resolver can later be called from a Messenger handler.

Model a stable application response

The supplied contract promises a normalized public identity response but does not prescribe fields we can safely hard-code here. Guessing keys such as a display name or avatar URL would couple the application to assumptions rather than documentation.

Instead, the card combines local contact data with the validated public object. It also calculates a deterministic fingerprint from recursively sorted JSON. That fingerprint belongs to our application; it is not presented as an identifier issued by the service.

<?php
// src/Identity/ProfileCard.php
namespace App\Identity;

final readonly class ProfileCard
{
    public function __construct(
        public string $creatorName,
        public string $platform,
        public string $submittedReference,
        public string $identityFingerprint,
        public array $publicIdentity,
    ) {
    }

    public static function fromResolved(
        string $creatorName,
        string $platform,
        string $reference,
        array $identity,
    ): self {
        $canonical = self::canonicalize($identity);
        $json = json_encode(
            $canonical,
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
        );

        return new self(
            $creatorName,
            strtolower($platform),
            $reference,
            hash('sha256', $json),
            $canonical,
        );
    }

    private static function canonicalize(array $value): array
    {
        if (!array_is_list($value)) {
            ksort($value);
        }

        foreach ($value as $key => $item) {
            if (is_array($item)) {
                $value[$key] = self::canonicalize($item);
            }
        }

        return $value;
    }

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

A database-backed contact manager can persist those fields in its own entity. Keeping persistence outside the API client prevents transport failures from leaking into domain code and lets the application decide whether refreshed public data should replace an earlier snapshot.

Build the resilient resolver

Create a typed exception so callers can distinguish invalid input, upstream rejection, temporary unavailability, and malformed data.

<?php
// src/Identity/IdentityResolverException.php
namespace App\Identity;

final class IdentityResolverException extends \RuntimeException
{
    public function __construct(
        public readonly string $failureType,
        string $message,
        public readonly bool $retryable = false,
        public readonly ?int $upstreamStatus = null,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

The HTTP service allows only documented parameter names. It uses a short connection timeout, a bounded overall duration, and at most three total attempts. Only transport failures, rate limiting, and server failures are retried. Other 4xx responses usually indicate a request that will not improve through repetition.

<?php
// src/Identity/IdentityResolver.php
namespace App\Identity;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class IdentityResolver
{
    private const PARAMETERS = [
        'username', 'id', 'identifier', 'profile', 'url',
    ];

    public function __construct(
        private HttpClientInterface $httpClient,
        private LoggerInterface $logger,
        private string $endpoint,
        private ?\Closure $sleep = null,
    ) {
    }

    public function resolve(
        string $platform,
        string $parameter,
        string $reference,
    ): array {
        $platform = strtolower(trim($platform));
        $reference = trim($reference);

        if ($platform === '' || $reference === ''
            || !in_array($parameter, self::PARAMETERS, true)) {
            throw new IdentityResolverException(
                'validation',
                'A platform, supported parameter, and reference are required.'
            );
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->httpClient->request('GET', $this->endpoint, [
                    'query' => [
                        'platform' => $platform,
                        $parameter => $reference,
                    ],
                    'timeout' => 3.0,
                    'max_duration' => 8.0,
                    'headers' => ['Accept' => 'application/json'],
                ]);

                $status = $response->getStatusCode();
                $headers = $response->getHeaders(false);

                if (($status === 429 || $status >= 500) && $attempt < 3) {
                    $this->pause($attempt, $headers['retry-after'][0] ?? null);
                    continue;
                }

                if ($status < 200 || $status >= 300) {
                    throw new IdentityResolverException(
                        $status === 429 ? 'rate_limited' : 'upstream_rejected',
                        'The identity service rejected the request.',
                        $status === 429 || $status >= 500,
                        $status,
                    );
                }

                $decoded = json_decode(
                    $response->getContent(false),
                    true,
                    512,
                    JSON_THROW_ON_ERROR
                );

                if (!is_array($decoded) || $decoded === []) {
                    throw new IdentityResolverException(
                        'invalid_response',
                        'The identity service returned no usable identity object.'
                    );
                }

                return $decoded;
            } catch (TransportExceptionInterface $exception) {
                if ($attempt === 3) {
                    throw new IdentityResolverException(
                        'transport',
                        'The identity service is temporarily unreachable.',
                        true,
                        null,
                        $exception,
                    );
                }

                $this->logger->warning('Identity resolution transport failure', [
                    'attempt' => $attempt,
                    'platform' => $platform,
                ]);
                $this->pause($attempt, null);
            } catch (\JsonException $exception) {
                throw new IdentityResolverException(
                    'invalid_response',
                    'The identity service returned invalid JSON.',
                    false,
                    null,
                    $exception,
                );
            }
        }

        throw new \LogicException('Unreachable retry state.');
    }

    private function pause(int $attempt, ?string $retryAfter): void
    {
        $seconds = ctype_digit((string) $retryAfter)
            ? min(2.0, (float) $retryAfter)
            : 0.15 * (2 ** ($attempt - 1));

        $sleeper = $this->sleep ?? static fn (int $microseconds) =>
            usleep($microseconds);

        $sleeper((int) ($seconds * 1_000_000));
    }
}

Notice what the logs omit: the submitted URL, username, response body, and full query string. Public information can still be sensitive in aggregate, so observability should record operational context without turning logs into a shadow contact database.

Wire dependency injection

Configure the scalar endpoint explicitly in config/services.yaml:

services:
    _defaults:
        autowire: true
        autoconfigure: true

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

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

Symfony injects HttpClientInterface and the logger automatically. The optional sleeper remains useful for deterministic tests and requires no production configuration.

Expose the profile-card endpoint

The controller accepts JSON from the creator contact manager. Its parameter choice is explicit, which supports URLs as well as usernames and other documented reference forms.

<?php
// src/Controller/ProfileCardController.php
namespace App\Controller;

use App\Identity\IdentityResolver;
use App\Identity\IdentityResolverException;
use App\Identity\ProfileCard;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class ProfileCardController extends AbstractController
{
    #[Route('/api/creator-profile-cards', methods: ['POST'])]
    public function create(
        Request $request,
        IdentityResolver $resolver,
    ): JsonResponse {
        try {
            $input = $request->toArray();
        } catch (\JsonException) {
            return $this->json(['error' => 'invalid_json'], 400);
        }

        $name = trim((string) ($input['creatorName'] ?? ''));
        $platform = trim((string) ($input['platform'] ?? ''));
        $parameter = trim((string) ($input['parameter'] ?? ''));
        $reference = trim((string) ($input['reference'] ?? ''));

        if ($name === '' || mb_strlen($name) > 120
            || mb_strlen($reference) > 2048) {
            return $this->json(['error' => 'invalid_input'], 422);
        }

        try {
            $identity = $resolver->resolve(
                $platform,
                $parameter,
                $reference
            );

            return $this->json(
                ProfileCard::fromResolved(
                    $name,
                    $platform,
                    $reference,
                    $identity
                )->toArray(),
                201
            );
        } catch (IdentityResolverException $exception) {
            $status = match ($exception->failureType) {
                'validation' => 422,
                'rate_limited' => 503,
                'transport' => 503,
                default => 502,
            };

            return $this->json([
                'error' => $exception->failureType,
                'retryable' => $exception->retryable,
            ], $status);
        }
    }
}

In a browser-facing deployment, protect this route with the contact manager’s normal authentication and authorization. If cookie authentication is used, retain Symfony’s CSRF protections. Apply request-size limits and application-level rate limiting so an attacker cannot use your server as an unrestricted proxy.

Test without calling the live service

MockHttpClient makes transport behavior deterministic. This test verifies the exact HTTP method and query while avoiding dependence on a live public profile.

<?php
// tests/Identity/IdentityResolverTest.php
namespace App\Tests\Identity;

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

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

                return new MockResponse(
                    '{"identity":{"kind":"public-profile"}}',
                    ['http_code' => 200]
                );
            }
        );

        $resolver = new IdentityResolver(
            $client,
            new NullLogger(),
            'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
            static fn (int $microseconds) => null,
        );

        self::assertSame(
            ['identity' => ['kind' => 'public-profile']],
            $resolver->resolve(
                'linkedin',
                'url',
                'https://www.linkedin.com/in/example'
            )
        );
    }

    public function testItRetriesRateLimitingThenSucceeds(): void
    {
        $client = new MockHttpClient([
            new MockResponse('', ['http_code' => 429]),
            new MockResponse('{"identity":{"resolved":true}}', [
                'http_code' => 200,
            ]),
        ]);

        $resolver = new IdentityResolver(
            $client,
            new NullLogger(),
            'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
            static fn (int $microseconds) => null,
        );

        $result = $resolver->resolve('facebook', 'username', 'example');
        self::assertTrue($result['identity']['resolved']);
        self::assertSame(2, $client->getRequestsCount());
    }
}

Run the suite with php bin/phpunit. Add controller tests for malformed JSON, oversized references, and each structured failure response. A separate mapper test should prove that differently ordered object keys generate the same fingerprint.

Deploy and operate it deliberately

Production configuration should supply IDENTITY_RESOLVER_URI through the runtime environment, warm Symfony’s cache, and run tests before switching traffic. No API credential is currently required. If authentication is introduced later, add it only according to the official documentation and inject it through Symfony secrets or the deployment platform’s secret store.

Monitor counts and latency by failure type, not by creator reference. A sudden rise in invalid_response can reveal a contract change; repeated rate_limited results indicate that concurrency or request frequency needs attention. Cache successful resolutions when product requirements allow it, but define an expiry because public profiles can change.

Common failure patterns

  • Immediate 4xx response: verify the platform and selected reference parameter. Do not retry unchanged validation failures.
  • Repeated 429 responses: respect the bounded backoff, return a retryable application error, and reduce request pressure.
  • Timeouts or 5xx responses: retry only within the fixed attempt and duration budget; never leave the user waiting indefinitely.
  • Valid JSON with an unexpected shape: reject empty or unusable data at the boundary instead of allowing template code to fail later.
  • Different fingerprints over time: treat the fingerprint as a snapshot fingerprint, not an immutable service-issued identifier.

Final verification checklist

  1. Confirm the current no-token access contract in the official documentation.
  2. Run the minimal curl request with a supported public reference.
  3. Verify the endpoint URI is environment-backed and no fake token exists.
  4. Run PHPUnit and confirm retry tests do not contact the network.
  5. Submit a creator name, platform, parameter, and reference to the Symfony route.
  6. Confirm the response contains local card fields, a fingerprint, and the normalized public identity object.
  7. Exercise invalid input, rate limiting, malformed JSON, timeout, and server-error paths.
  8. Check that logs contain operational metadata but no social reference or response payload.

The lasting design lesson is larger than this one integration: an external identity response should cross a narrow, defensive boundary before it enters your product. Once transport policy, validation, and domain mapping are separated, inconsistent social links stop infecting the rest of the contact manager. They become what they should have been all along: interchangeable inputs to one dependable creator card.

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.