Tutorials

Symfony: Auto-Populate Creator Profiles from Social Links with Identity Resolver

Symfony: Auto-Populate Creator Profiles from Social Links with Identity Resolver

A pasted social link looks simple until a contact manager must turn Facebook, Instagram, and LinkedIn references into predictable data. One creator enters a username, another pastes a full URL, and a third supplies a platform identifier. If every controller interprets those values independently, inconsistent profile cards are inevitable.

This tutorial builds a Symfony feature that accepts any supported reference, resolves it through Identity Resolver, and returns one stable application-level profile-card envelope. The integration validates inputs, isolates the external API, retries only transient failures, and treats the response as untrusted data at the boundary.

Get access before writing integration code

Start with the official Identity Resolver documentation. The current public endpoint requires no account token or API key. There is consequently no credential to copy, no authorization header to configure, and no plan-selection step before the first request.

  1. Open the service and plan page to review the capability.
  2. Read the official documentation and confirm the supported platforms and identifier types.
  3. Registration is not required for the current public endpoint; use the official documentation as the authoritative registration guidance.
  4. Login is also unnecessary before testing; consult the same official source for any future login guidance if access terms change.
  5. Do not invent or send a placeholder bearer token. If authentication is introduced later, follow the documentation’s then-current onboarding flow and keep the resulting credential outside source control.

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

curl --get 'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
  --data-urlencode 'platform=instagram' \
  --data-urlencode 'username=CREATOR_USERNAME' \
  --header 'Accept: application/json'

No credential belongs in this command. Before building the feature, store only the environment-specific service location. If a future version requires a secret, add it to the deployment secret store rather than committing it to .env.

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

Design the boundary, not just the request

The contact manager will expose a local POST endpoint. A caller submits a platform, a reference type, and its value. The application converts those inputs into the upstream GET request and returns a consistent envelope containing the platform, original reference, and normalized identity object.

This design deliberately does not guess undocumented response fields. The external object remains available under identity, while the surrounding card shape belongs to our application. Once the official schema identifies fields that should become first-class domain properties, they can be mapped explicitly without leaking transport concerns into controllers.

Resolution remains synchronous because a user adding one social link benefits from immediate feedback. For bulk imports, the same resolver can sit behind Symfony Messenger, but introducing a queue for a single interactive lookup would add operational cost without improving the basic workflow.

Prerequisites and project structure

You need PHP 8.3 or newer, Composer, and a current supported Symfony release. Create a small application and install the first-party HTTP and testing components:

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

The relevant files are intentionally few:

creator-contacts/
├── .env
├── .env.local
├── config/
│   └── services.yaml
├── src/
│   ├── Controller/ResolveCreatorCardController.php
│   └── Identity/
│       ├── IdentityResolutionException.php
│       └── IdentityResolver.php
└── tests/
    └── Identity/IdentityResolverTest.php

Implement the Identity Resolver client

First define a structured exception. Its machine-readable failure name lets the controller distinguish an upstream outage from a malformed response without inspecting message text.

<?php
// src/Identity/IdentityResolutionException.php

namespace App\Identity;

final class IdentityResolutionException extends \RuntimeException
{
    public function __construct(
        public readonly string $failure,
        public readonly bool $retryable,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($failure, 0, $previous);
    }
}

The service below enforces the documented platform and selector sets, applies bounded timeouts, and makes at most three attempts. Only transport failures, HTTP 429, and server-side 5xx responses are retried. Validation errors and other client-side responses fail immediately.

<?php
// src/Identity/IdentityResolver.php

namespace App\Identity;

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

final class IdentityResolver
{
    private const PLATFORMS = ['facebook', 'instagram', 'linkedin'];
    private const SELECTORS = ['username', 'id', 'identifier', 'profile', 'url'];
    private \Closure $sleep;

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly LoggerInterface $logger,
        #[Autowire('%env(string:IDENTITY_RESOLVER_BASE_URI)%')]
        private readonly string $baseUri,
        ?\Closure $sleep = null,
    ) {
        $this->sleep = $sleep ?? static fn (int $microseconds) => usleep($microseconds);
    }

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

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

        if (!in_array($selector, self::SELECTORS, true)) {
            throw new \InvalidArgumentException('Unsupported reference type.');
        }

        if ($value === '' || strlen($value) > 2048) {
            throw new \InvalidArgumentException('Reference must contain 1 to 2048 bytes.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('GET', $this->endpoint(), [
                    'headers' => ['Accept' => 'application/json'],
                    'query' => [
                        'platform' => $platform,
                        $selector => $value,
                    ],
                    'timeout' => 5.0,
                    'max_duration' => 10.0,
                ]);

                $status = $response->getStatusCode();
            } catch (TransportExceptionInterface $exception) {
                if ($attempt < 3) {
                    $this->logRetry('transport', $attempt, $platform, $value);
                    ($this->sleep)(250_000 * (2 ** ($attempt - 1)));
                    continue;
                }

                throw new IdentityResolutionException(
                    'upstream_unavailable',
                    true,
                    $exception,
                );
            }

            if ($status >= 200 && $status < 300) {
                try {
                    $data = json_decode(
                        $response->getContent(false),
                        true,
                        512,
                        JSON_THROW_ON_ERROR,
                    );
                } catch (\JsonException $exception) {
                    throw new IdentityResolutionException(
                        'invalid_upstream_response',
                        false,
                        $exception,
                    );
                }

                if (!is_array($data) || array_is_list($data)) {
                    throw new IdentityResolutionException(
                        'invalid_upstream_response',
                        false,
                    );
                }

                return $data;
            }

            $transient = $status === 429 || $status >= 500;

            if ($transient && $attempt < 3) {
                $headers = $response->getHeaders(false);
                $retryAfter = $headers['retry-after'][0] ?? null;
                $delay = is_numeric($retryAfter)
                    ? min((int) $retryAfter, 2) * 1_000_000
                    : 250_000 * (2 ** ($attempt - 1));

                $this->logRetry('http_'.$status, $attempt, $platform, $value);
                ($this->sleep)($delay);
                continue;
            }

            throw new IdentityResolutionException(
                $status === 429 ? 'upstream_rate_limited' : 'upstream_rejected_request',
                $transient,
            );
        }

        throw new IdentityResolutionException('upstream_unavailable', true);
    }

    private function endpoint(): string
    {
        return rtrim($this->baseUri, '/').'/v1/resolve';
    }

    private function logRetry(
        string $reason,
        int $attempt,
        string $platform,
        string $value,
    ): void {
        $this->logger->warning('Identity resolution will be retried.', [
            'reason' => $reason,
            'attempt' => $attempt,
            'platform' => $platform,
            'reference_hash' => hash('sha256', $value),
        ]);
    }
}

The resolver logs a hash instead of the submitted username or URL. That still supports correlation while reducing accidental disclosure of creator data. The base URI is fixed by configuration, so user input can never choose the destination host; this is an important SSRF boundary.

Expose a profile-card controller

The controller turns the normalized identity into a predictable local representation. Invalid input receives 422, while external-service failures receive 503 with a structured reason that a frontend or import worker can handle.

<?php
// src/Controller/ResolveCreatorCardController.php

namespace App\Controller;

use App\Identity\IdentityResolutionException;
use App\Identity\IdentityResolver;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class ResolveCreatorCardController
{
    #[Route('/api/creator-cards/resolve', methods: ['POST'])]
    public function __invoke(
        Request $request,
        IdentityResolver $resolver,
    ): JsonResponse {
        $input = $request->toArray();

        $platform = (string) ($input['platform'] ?? '');
        $type = (string) ($input['type'] ?? '');
        $value = (string) ($input['value'] ?? '');

        try {
            $identity = $resolver->resolve($platform, $type, $value);
        } catch (\InvalidArgumentException $exception) {
            return new JsonResponse([
                'status' => 'invalid_input',
                'message' => $exception->getMessage(),
            ], 422);
        } catch (IdentityResolutionException $exception) {
            return new JsonResponse([
                'status' => 'resolution_failed',
                'failure' => $exception->failure,
                'retryable' => $exception->retryable,
            ], 503);
        }

        return new JsonResponse([
            'status' => 'resolved',
            'card' => [
                'platform' => strtolower(trim($platform)),
                'reference' => [
                    'type' => strtolower(trim($type)),
                    'value' => trim($value),
                ],
                'identity' => $identity,
            ],
        ]);
    }
}

A Twig interface can now render a common card shell while selectively displaying documented scalar properties from identity. Persist the original reference alongside the normalized object so cards can be re-resolved after a creator changes a handle or the service contract evolves.

Test success, retries, and malformed responses

MockHttpClient makes the suite deterministic and prevents tests from consuming network availability or service quota. Injecting a no-op sleeper keeps retry tests fast.

<?php
// tests/Identity/IdentityResolverTest.php

namespace App\Tests\Identity;

use App\Identity\IdentityResolutionException;
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 testItRetriesATransientFailure(): void
    {
        $client = new MockHttpClient([
            new MockResponse('temporary', ['http_code' => 500]),
            new MockResponse('{"normalized":true}', ['http_code' => 200]),
        ]);

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

        self::assertSame(
            ['normalized' => true],
            $resolver->resolve('instagram', 'username', 'creator_name'),
        );
        self::assertSame(2, $client->getRequestsCount());
    }

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

        $resolver = new IdentityResolver(
            $client,
            new NullLogger(),
            'https://ai.mihajlo.mk/api/identity-resolver',
        );

        $this->expectException(IdentityResolutionException::class);
        $resolver->resolve('linkedin', 'url', 'https://example.invalid/profile');
    }

    public function testItRejectsAnUnsupportedPlatformWithoutARequest(): void
    {
        $client = new MockHttpClient();
        $resolver = new IdentityResolver(
            $client,
            new NullLogger(),
            'https://ai.mihajlo.mk/api/identity-resolver',
        );

        $this->expectException(\InvalidArgumentException::class);
        $resolver->resolve('unknown', 'username', 'creator');
    }
}
php bin/phpunit
php bin/console debug:router
php bin/console debug:container App\\Identity\\IdentityResolver

Production security and operations

Protect the local POST route with the contact manager’s normal authentication and authorization. For a browser session, enforce CSRF protection; for a JSON API, require the application’s established API authentication. Add Symfony Rate Limiter at the local boundary so one client cannot turn your application into an uncontrolled proxy.

Do not log response bodies, full social URLs, cookies, or future credentials. Record a request correlation ID, platform, duration, outcome, retry count, and hashed reference. Alert on sustained transport failures, 429 responses, malformed JSON, and latency approaching the configured deadline.

The retry budget is intentionally small. HTTP 400-series validation or authorization failures will not improve when immediately repeated. A 429 may recover, but the service’s Retry-After value is capped here so an interactive request cannot occupy a PHP worker indefinitely. Bulk imports should move to Messenger, where delayed retries and dead-letter handling are a better fit.

Deployment notes

Set IDENTITY_RESOLVER_BASE_URI in each deployment environment and warm Symfony’s production cache after installing dependencies. Confirm that outbound HTTPS to ai.mihajlo.mk is permitted and that the host has a current CA certificate bundle. Keep PHP workers’ outer request timeout longer than the client’s bounded retry window.

Common failures are usually straightforward: a misspelled platform produces local 422 validation, an unsupported identifier form is rejected without retry, 429 indicates throttling, 5xx indicates a transient upstream problem, and malformed JSON becomes invalid_upstream_response. If the service later introduces authentication, update configuration only after checking the official documentation; do not guess an authorization scheme.

Final verification checklist

  • The initial curl request reaches the exact documented GET endpoint without an API key.
  • Facebook, Instagram, and LinkedIn are the only accepted platform values.
  • Exactly one supported selector is generated from the local type field.
  • Connection duration, total duration, attempt count, and backoff are bounded.
  • Only transport errors, 429, and 5xx responses are retried.
  • Logs contain operational context but not raw creator references or identity bodies.
  • Tests use MockHttpClient and never call the live service.
  • The contact manager always receives the same status, card, reference, and identity envelope.

The valuable part of this integration is not merely converting a social link into JSON. It is establishing a narrow, observable boundary between unpredictable user input, an external identity service, and the contact manager’s own domain. With that boundary in place, every creator card speaks the same local language—even when the links that created it do not.

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.