Tutorials

Symfony: Create Consistent Creator Profile Cards from Social Links with Identity Resolver API

Symfony: Create Consistent Creator Profile Cards from Social Links with Identity Resolver API

A creator contact manager often begins with a few pasted social links. Before long, the same person appears as an Instagram URL, a LinkedIn identifier, and a differently formatted Facebook reference. The database may accept all three, but the interface becomes inconsistent and duplicate detection becomes unreliable.

The Identity Resolver API addresses that boundary problem. It accepts public Facebook, Instagram, and LinkedIn references and returns a normalized public identity object. In this tutorial, we will integrate it into a Symfony application that turns submitted social references into consistent profile cards without coupling the domain model to undocumented response fields.

Get access before writing integration code

Begin with the official Identity Resolver documentation. The current public endpoint requires no account token or API key, so there is no credential to copy before the first request.

  1. Read the supported platform and identifier rules in the documentation.
  2. Review the service and plan page for current availability and plan information.
  3. No registration is required for this public endpoint. If account features are introduced later, use the service page as the authoritative starting point for registration and login navigation instead of guessing unpublished account URLs.
  4. Do not create a placeholder token or send an Authorization header. An invented credential can turn a simple public request into a deployment or cache-key problem.

The exact call is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Send platform plus one supported selector: username, id, identifier, profile, or url.

Make the first test with a public profile reference that you are authorized to process:

curl --get \
  --data-urlencode "platform=linkedin" \
  --data-urlencode "url=https://www.linkedin.com/in/YOUR_PUBLIC_PROFILE" \
  --header "Accept: application/json" \
  "https://ai.mihajlo.mk/api/identity-resolver/v1/resolve"

There is no credential to store. Put only the configurable service location in Symfony’s uncommitted .env.local file:

IDENTITY_RESOLVER_BASE_URI=https://ai.mihajlo.mk/api/identity-resolver/

If authentication is added in the future, follow the documentation available at that time and place the real secret in the deployment secret store, not in committed environment files.

Choose a deliberately small architecture

This feature fits a synchronous workflow: a user submits a social reference and expects a preview card immediately. Messenger would add operational complexity without improving that interaction. A later bulk-import feature could resolve identities asynchronously, but it should be a separate use case.

The implementation has four boundaries:

  • A scoped Symfony HTTP client owns timeouts and retry policy.
  • An API client validates parameters and converts network outcomes into explicit result states.
  • A domain mapper creates a stable card identity from the normalized response.
  • A controller handles authorization, CSRF protection, input, and presentation.

A compact project structure looks like this:

src/
  Controller/CreatorCardController.php
  Domain/CreatorProfileCard.php
  Integration/IdentityResolverClient.php
  Integration/IdentityResolverResult.php
templates/
  creator/card.html.twig
tests/
  Integration/IdentityResolverClientTest.php
config/packages/framework.yaml
.env.local

Install the first-party components if the application does not already contain them:

composer require symfony/http-client symfony/twig-bundle symfony/security-csrf
composer require --dev symfony/test-pack

Configure bounded retries and timeouts

An interactive request should not occupy a PHP worker indefinitely. Configure a scoped client with a short connection timeout, an overall duration limit, and retries only for throttling and transient server failures:

# config/packages/framework.yaml
framework:
  http_client:
    scoped_clients:
      identity.client:
        base_uri: '%env(IDENTITY_RESOLVER_BASE_URI)%'
        timeout: 3
        max_duration: 8
        retry_failed:
          max_retries: 2
          delay: 250
          multiplier: 2
          http_codes: [429, 500, 502, 503, 504]

This policy never retries ordinary validation responses or authentication failures. Retrying a bad platform name merely creates additional traffic. A bounded retry for 429 or a temporary 5xx response is reasonable for an idempotent GET request, but the application still needs to expose failure when the retry budget is exhausted.

Build a defensive API boundary

Use a result object so expected upstream failures do not leak through the application as loosely classified exceptions:

<?php
// src/Integration/IdentityResolverResult.php
namespace App\Integration;

final readonly class IdentityResolverResult
{
    private function __construct(
        public bool $ok,
        public ?array $identity,
        public ?string $failure,
        public ?int $status,
        public ?string $retryAfter,
    ) {}

    public static function success(array $identity): self
    {
        return new self(true, $identity, null, 200, null);
    }

    public static function failure(
        string $failure,
        ?int $status = null,
        ?string $retryAfter = null,
    ): self {
        return new self(false, null, $failure, $status, $retryAfter);
    }
}

The client accepts only contract-defined selectors. It logs a one-way fingerprint rather than the submitted profile value, preventing public identifiers from being copied unnecessarily into centralized logs.

<?php
// src/Integration/IdentityResolverClient.php
namespace App\Integration;

use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

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

    public function __construct(
        #[Autowire(service: 'identity.client')]
        private HttpClientInterface $http,
        private LoggerInterface $logger,
    ) {}

    public function resolve(
        string $platform,
        string $selector,
        string $value,
    ): IdentityResolverResult {
        $platform = strtolower(trim($platform));
        $value = trim($value);

        if ($platform === '' || $value === '' ||
            !in_array($selector, self::SELECTORS, true)) {
            return IdentityResolverResult::failure('invalid_input', 422);
        }

        $context = [
            'platform' => $platform,
            'selector' => $selector,
            'reference_hash' => substr(hash('sha256', $value), 0, 12),
        ];

        try {
            $response = $this->http->request('GET', 'v1/resolve', [
                'headers' => ['Accept' => 'application/json'],
                'query' => [
                    'platform' => $platform,
                    $selector => $value,
                ],
            ]);

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

            if ($status === 429) {
                $retryAfter = $headers['retry-after'][0] ?? null;
                $this->logger->warning(
                    'Identity Resolver rate limit reached.',
                    $context + ['status' => $status]
                );

                return IdentityResolverResult::failure(
                    'rate_limited',
                    $status,
                    $retryAfter,
                );
            }

            if ($status === 400 || $status === 404 || $status === 422) {
                return IdentityResolverResult::failure(
                    'reference_rejected',
                    $status,
                );
            }

            if ($status === 401 || $status === 403) {
                $this->logger->error(
                    'Unexpected authentication response from public resolver.',
                    $context + ['status' => $status]
                );

                return IdentityResolverResult::failure(
                    'upstream_authentication',
                    $status,
                );
            }

            if ($status < 200 || $status >= 300) {
                $this->logger->error(
                    'Identity Resolver request failed.',
                    $context + ['status' => $status]
                );

                return IdentityResolverResult::failure(
                    'upstream_unavailable',
                    $status,
                );
            }

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

            if (!str_starts_with(ltrim($body), '{') ||
                !is_array($payload)) {
                return IdentityResolverResult::failure(
                    'malformed_response',
                    $status,
                );
            }

            return IdentityResolverResult::success($payload);
        } catch (\JsonException $exception) {
            $this->logger->error(
                'Identity Resolver returned invalid JSON.',
                $context + ['exception' => $exception::class]
            );

            return IdentityResolverResult::failure('malformed_response');
        } catch (TransportExceptionInterface $exception) {
            $this->logger->warning(
                'Identity Resolver transport failure.',
                $context + ['exception' => $exception::class]
            );

            return IdentityResolverResult::failure('transport_failure');
        }
    }
}

The code deliberately does not guess fields inside the normalized identity object. The official response can evolve without silently mapping an assumed property to the wrong domain field.

Create a stable domain card

A deterministic hash of recursively sorted response data gives the contact manager an internal identity key. It is not a service-issued identifier and should not be presented as one. It is simply a reproducible application key for comparison and caching.

<?php
// src/Domain/CreatorProfileCard.php
namespace App\Domain;

final readonly class CreatorProfileCard
{
    public function __construct(
        public string $identityKey,
        public string $platform,
        public string $sourceReference,
        public array $identity,
    ) {}

    public static function fromResolvedIdentity(
        string $platform,
        string $sourceReference,
        array $identity,
    ): self {
        $canonical = self::sortRecursively($identity);
        $json = json_encode(
            $canonical,
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES,
        );

        return new self(
            hash('sha256', $json),
            strtolower($platform),
            $sourceReference,
            $identity,
        );
    }

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

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

        return $value;
    }
}

Keep the complete normalized object at the boundary until the documentation establishes which fields your product should promote. When that mapping is introduced, version it explicitly and store the service response separately from user-authored notes.

Connect the resolver to the contact manager

The controller uses POST because resolving a profile is an application action, even though its upstream call is GET. Protect it with authentication in the application firewall and a form-specific CSRF token.

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

use App\Domain\CreatorProfileCard;
use App\Integration\IdentityResolverClient;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class CreatorCardController extends AbstractController
{
    #[Route('/creator-cards/resolve', name: 'creator_card_resolve',
        methods: ['POST'])]
    public function resolve(
        Request $request,
        IdentityResolverClient $resolver,
    ): Response {
        $this->denyAccessUnlessGranted('ROLE_USER');

        if (!$this->isCsrfTokenValid(
            'resolve_creator',
            $request->request->getString('_token'),
        )) {
            return new Response('Invalid CSRF token.', 403);
        }

        $platform = $request->request->getString('platform');
        $selector = $request->request->getString('selector');
        $reference = $request->request->getString('reference');

        $result = $resolver->resolve(
            $platform,
            $selector,
            $reference,
        );

        if (!$result->ok) {
            $status = match ($result->failure) {
                'invalid_input', 'reference_rejected' => 422,
                'rate_limited' => 429,
                default => 503,
            };

            return $this->render('creator/card.html.twig', [
                'card' => null,
                'failure' => $result->failure,
                'retryAfter' => $result->retryAfter,
            ], new Response(status: $status));
        }

        $card = CreatorProfileCard::fromResolvedIdentity(
            $platform,
            $reference,
            $result->identity,
        );

        return $this->render('creator/card.html.twig', [
            'card' => $card,
            'failure' => null,
            'retryAfter' => null,
        ]);
    }
}

Render every card with the same visual hierarchy. Twig auto-escaping must remain enabled:

<article class="creator-card">
{% if card %}
  <h2>{{ card.platform|title }} creator</h2>
  <p>Source: {{ card.sourceReference }}</p>
  <p>Identity key: {{ card.identityKey }}</p>
  <ul>
  {% for name, value in card.identity %}
    <li>
      <strong>{{ name }}:</strong>
      {{ value is iterable ? value|json_encode : value }}
    </li>
  {% endfor %}
  </ul>
{% else %}
  <h2>Profile unavailable</h2>
  <p>Resolution state: {{ failure }}</p>
  {% if retryAfter %}
    <p>Retry after: {{ retryAfter }}</p>
  {% endif %}
{% endif %}
</article>

Test without calling the live service

MockHttpClient makes request construction and boundary behavior deterministic. An empty JSON object is sufficient here because the test must not invent undocumented response fields.

<?php
// tests/Integration/IdentityResolverClientTest.php
namespace App\Tests\Integration;

use App\Integration\IdentityResolverClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class IdentityResolverClientTest extends TestCase
{
    public function testItSendsTheSelectedReference(): void
    {
        $http = new MockHttpClient(
            function (string $method, string $url): MockResponse {
                self::assertSame('GET', $method);
                self::assertSame(
                    '/api/identity-resolver/v1/resolve',
                    parse_url($url, PHP_URL_PATH),
                );

                parse_str(
                    (string) parse_url($url, PHP_URL_QUERY),
                    $query,
                );

                self::assertSame('linkedin', $query['platform']);
                self::assertSame(
                    'https://example.test/public-profile',
                    $query['url'],
                );

                return new MockResponse('{}', ['http_code' => 200]);
            },
            'https://ai.mihajlo.mk/api/identity-resolver/',
        );

        $result = (new IdentityResolverClient(
            $http,
            new NullLogger(),
        ))->resolve(
            'linkedin',
            'url',
            'https://example.test/public-profile',
        );

        self::assertTrue($result->ok);
        self::assertSame([], $result->identity);
    }

    public function testItRejectsAnUnsupportedSelector(): void
    {
        $http = new MockHttpClient();
        $client = new IdentityResolverClient($http, new NullLogger());

        $result = $client->resolve('instagram', 'handle', 'creator');

        self::assertFalse($result->ok);
        self::assertSame('invalid_input', $result->failure);
        self::assertSame(0, $http->getRequestsCount());
    }

    public function testItRejectsANonObjectResponse(): void
    {
        $http = new MockHttpClient(
            new MockResponse('[]', ['http_code' => 200]),
        );

        $result = (new IdentityResolverClient(
            $http,
            new NullLogger(),
        ))->resolve('facebook', 'identifier', 'public-reference');

        self::assertFalse($result->ok);
        self::assertSame('malformed_response', $result->failure);
    }
}

Run the suite with php bin/phpunit. Add a controller test for your actual firewall and CSRF configuration because authentication policy is application-specific.

Security, observability, and deployment

Accept only the five documented selector names; never let a request parameter become an arbitrary query key. Limit input length before calling the service, keep the route behind authentication, preserve Twig escaping, and apply Symfony RateLimiter at the application boundary if many users can trigger resolutions.

Do not log complete URLs, usernames, response bodies, or submitted identifiers. Useful metrics are request count, latency, terminal status class, retry count, and structured failure name. Alert on sustained transport failures, malformed responses, or unexpected 401/403 responses rather than on an isolated rejected profile.

During deployment, inject IDENTITY_RESOLVER_BASE_URI, warm Symfony’s cache, run automated tests, and verify outbound HTTPS access from the PHP runtime. Do not perform a live API request during container build or cache warmup; temporary network trouble should not make an otherwise valid release artifact impossible to build.

Common failures

  • Rejected reference: confirm the platform and use one documented selector rather than translating field names inside the controller.
  • Rate limited: honor the terminal 429, expose a retryable UI state, and avoid automatic browser polling.
  • Malformed JSON: retain the structured failure and investigate upstream behavior without logging the body.
  • Timeout: show a temporary failure; do not extend worker timeouts until slow requests appear successful.
  • Duplicate-looking cards: compare normalized identity keys, but retain source references for auditability.

Final verification checklist

  • The application calls exactly GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve.
  • Every request contains platform and exactly one supported reference parameter.
  • No token, fabricated credential, or unnecessary authorization header is sent.
  • Timeouts and retry counts are bounded, and validation failures are not retried.
  • Unknown response data is validated at the API boundary and safely escaped in the card.
  • Tests use MockHttpClient and never contact the production endpoint.
  • Logs contain operational context without raw public profile references or response bodies.

Consistency is not achieved by forcing every social network into an imagined universal schema. It comes from establishing a careful boundary: accept the documented reference forms, preserve the normalized identity object, classify failures honestly, and render every result through one stable domain model. That boundary turns pasted links into dependable contact-manager records while leaving room for the service contract—and the product built around it—to evolve safely.

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.