Vodiči

Symfony Social Link Normalization: Resolve Facebook, Instagram, LinkedIn with Identity Resolver

Symfony normalizacija poveznica društvenih mreža: razriješite Facebook, Instagram, LinkedIn pomoću Identity Resolvera

A community directory rarely receives clean social data. One member pastes a full Instagram URL, another submits a Facebook profile variant, and someone else copies a LinkedIn link containing tracking parameters. If those values are stored unchanged, duplicate detection, profile rendering, moderation, and future migrations all become harder.

The Identity Resolver solves this boundary problem by normalizing public Facebook, Instagram, and LinkedIn references into a stable identity object. In this tutorial, we will build a production-oriented Symfony integration that validates submitted links, calls the resolver, preserves its response without making undocumented schema assumptions, and stores the normalized identity on a directory member.

Get access before writing integration code

Start with the Identity Resolver service page, where you can review the service and plan information. The current public endpoint requires no account token and no API key.

The access flow is therefore deliberately short:

  1. Open the official Identity Resolver documentation.
  2. Review the supported platforms and input forms before integrating.
  3. Do not create, copy, or configure a token for the current public endpoint. There is no authorization header to send.
  4. If you want an account for other services or account management, use the registration page or login page. Neither step is required for this resolver request.

Because no credential exists for this endpoint, there is nothing to copy into Symfony secrets or an environment file. We will store only the service base URL in environment-backed configuration. If the official authentication contract changes later, follow the documentation rather than guessing an authorization scheme.

Verify the exact endpoint

The resolver uses GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Send platform plus one supported username, id, identifier, profile, or url parameter. Our directory accepts links, so it will send platform and url.

curl --get \
  'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
  --data-urlencode 'platform=instagram' \
  --data-urlencode 'url=https://www.instagram.com/example/'

No bearer token, API key, cookie, or custom authentication header belongs in that request. Inspect the returned JSON object, but do not make application decisions from fields that are not guaranteed by the official contract.

Prerequisites and project shape

This implementation assumes PHP 8.3 or newer, a Symfony application with the HttpClient, Doctrine ORM, Monolog, and PHPUnit integrations, and an existing DirectoryMember entity. Install the first-party components if the project does not already contain them:

composer require symfony/http-client symfony/monolog-bundle symfony/orm-pack
composer require --dev symfony/test-pack

The request remains synchronous because a directory submission needs immediate validation feedback. Messenger would add operational complexity without improving this short interaction. If normalization later becomes bulk enrichment, moving the same client behind a Messenger handler would be reasonable.

The relevant project structure is intentionally compact:

config/
  services.yaml
src/
  Controller/DirectorySocialLinkController.php
  Entity/DirectoryMember.php
  Identity/IdentityResolverClient.php
  Identity/ResolverFailure.php
  Identity/ResolverResult.php
tests/
  Identity/IdentityResolverClientTest.php
.env

Configure the environment and dependency injection

Add the non-secret base URL to .env. A production deployment should override it through the hosting environment rather than editing committed application files.

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

Bind that value explicitly so the client cannot accidentally receive an unrelated string parameter:

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

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

    App\Identity\IdentityResolverClient:
        arguments:
            $baseUri: '%env(string:IDENTITY_RESOLVER_BASE_URI)%'

Build a defensive API boundary

The supplied contract promises a normalized public identity response, but it does not give us permission to assume particular response fields. The boundary therefore verifies that successful content is JSON and preserves the complete object as an opaque associative array. Presentation and persistence code can evolve when a documented response schema is available.

Failures are returned as domain states instead of leaking transport exceptions into the controller:

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

enum ResolverFailure: string
{
    case InvalidInput = 'invalid_input';
    case Rejected = 'rejected';
    case RateLimited = 'rate_limited';
    case Unavailable = 'unavailable';
    case MalformedResponse = 'malformed_response';
}

// src/Identity/ResolverResult.php
namespace App\Identity;

final readonly class ResolverResult
{
    private function __construct(
        public bool $succeeded,
        public ?array $identity,
        public ?ResolverFailure $failure,
        public ?int $httpStatus,
    ) {}

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

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

The HTTP client applies bounded connection inactivity and total-duration limits. It retries only temporary transport failures, status 429, and server errors. Client errors are not blindly retried because a repeated invalid request remains invalid.

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

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

final class IdentityResolverClient
{
    private \Closure $sleep;

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly LoggerInterface $logger,
        private readonly string $baseUri,
        ?\Closure $sleep = null,
    ) {
        $this->sleep = $sleep ?? static fn (int $microseconds)
            => usleep($microseconds);
    }

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

        if (!in_array($platform, ['facebook', 'instagram', 'linkedin'], true)
            || !$this->isPublicProfileUrl($url)
        ) {
            return ResolverResult::failure(ResolverFailure::InvalidInput);
        }

        $endpoint = rtrim($this->baseUri, '/').'/v1/resolve';

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            $started = microtime(true);

            try {
                $response = $this->http->request('GET', $endpoint, [
                    'query' => [
                        'platform' => $platform,
                        'url' => $url,
                    ],
                    'timeout' => 2.0,
                    'max_duration' => 5.0,
                    'headers' => [
                        'Accept' => 'application/json',
                    ],
                ]);

                $status = $response->getStatusCode();
                $body = $response->getContent(false);
                $headers = $response->getHeaders(false);
            } catch (TransportExceptionInterface $exception) {
                $this->logger->warning('Identity resolver transport failure', [
                    'platform' => $platform,
                    'attempt' => $attempt,
                    'exception_class' => $exception::class,
                ]);

                if ($attempt === 3) {
                    return ResolverResult::failure(ResolverFailure::Unavailable);
                }

                ($this->sleep)($attempt * 100_000);
                continue;
            }

            $this->logger->info('Identity resolver request completed', [
                'platform' => $platform,
                'attempt' => $attempt,
                'status' => $status,
                'duration_ms' => (int) ((microtime(true) - $started) * 1000),
            ]);

            if ($status >= 200 && $status < 300) {
                try {
                    $identity = json_decode(
                        $body,
                        true,
                        512,
                        JSON_THROW_ON_ERROR,
                    );
                } catch (JsonException) {
                    return ResolverResult::failure(
                        ResolverFailure::MalformedResponse,
                        $status,
                    );
                }

                if (!is_array($identity)) {
                    return ResolverResult::failure(
                        ResolverFailure::MalformedResponse,
                        $status,
                    );
                }

                return ResolverResult::success($identity, $status);
            }

            if ($status === 429) {
                if ($attempt === 3) {
                    return ResolverResult::failure(
                        ResolverFailure::RateLimited,
                        $status,
                    );
                }

                ($this->sleep)($this->retryDelay($headers, $attempt));
                continue;
            }

            if ($status >= 500) {
                if ($attempt === 3) {
                    return ResolverResult::failure(
                        ResolverFailure::Unavailable,
                        $status,
                    );
                }

                ($this->sleep)($attempt * 100_000);
                continue;
            }

            return ResolverResult::failure(ResolverFailure::Rejected, $status);
        }

        return ResolverResult::failure(ResolverFailure::Unavailable);
    }

    private function isPublicProfileUrl(string $url): bool
    {
        if (strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
            return false;
        }

        return in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true);
    }

    private function retryDelay(array $headers, int $attempt): int
    {
        $value = $headers['retry-after'][0] ?? null;

        if (is_string($value) && ctype_digit($value)) {
            return min((int) $value, 2) * 1_000_000;
        }

        return $attempt * 100_000;
    }
}

The submitted URL is deliberately absent from logs. Public profiles may not be secret, but handles and URLs are still user-associated data and seldom help routine operational analysis.

Persist the normalized identity

Add a JSON column to the existing member entity, then generate and run the normal Doctrine migration. The application-owned envelope records the selected platform and resolution time; resolver contains the response unchanged.

<?php
// Relevant addition to src/Entity/DirectoryMember.php
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Column(type: Types::JSON)]
private array $socialIdentities = [];

public function putSocialIdentity(
    string $platform,
    array $resolverIdentity,
): void {
    $this->socialIdentities[$platform] = [
        'platform' => $platform,
        'resolved_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
        'resolver' => $resolverIdentity,
    ];
}

public function socialIdentities(): array
{
    return $this->socialIdentities;
}
php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction

The controller validates JSON, rejects unsupported platforms and mismatched hosts, resolves the URL, and persists only a successful result. Host checks reduce accidental submissions and prevent the directory from becoming a generic URL relay.

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

use App\Entity\DirectoryMember;
use App\Identity\IdentityResolverClient;
use App\Identity\ResolverFailure;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class DirectorySocialLinkController extends AbstractController
{
    #[Route(
        '/directory/members/{id}/social-links',
        name: 'directory_social_link_put',
        methods: ['PUT'],
    )]
    public function __invoke(
        int $id,
        Request $request,
        IdentityResolverClient $resolver,
        EntityManagerInterface $entityManager,
    ): JsonResponse {
        try {
            $input = json_decode(
                $request->getContent(),
                true,
                512,
                JSON_THROW_ON_ERROR,
            );
        } catch (JsonException) {
            return $this->json(['error' => 'invalid_json'], 400);
        }

        if (!is_array($input)) {
            return $this->json(['error' => 'invalid_body'], 400);
        }

        $platform = strtolower(trim((string) ($input['platform'] ?? '')));
        $url = trim((string) ($input['url'] ?? ''));

        if (!$this->hostMatchesPlatform($platform, $url)) {
            return $this->json(['error' => 'invalid_social_link'], 422);
        }

        $member = $entityManager->find(DirectoryMember::class, $id);

        if (!$member instanceof DirectoryMember) {
            return $this->json(['error' => 'member_not_found'], 404);
        }

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

        if (!$result->succeeded) {
            $status = match ($result->failure) {
                ResolverFailure::InvalidInput,
                ResolverFailure::Rejected => 422,
                ResolverFailure::RateLimited => 429,
                default => 503,
            };

            return $this->json([
                'error' => $result->failure?->value,
            ], $status);
        }

        $member->putSocialIdentity($platform, $result->identity);
        $entityManager->flush();

        return $this->json([
            'member_id' => $id,
            'identity' => $member->socialIdentities()[$platform],
        ]);
    }

    private function hostMatchesPlatform(string $platform, string $url): bool
    {
        $roots = [
            'facebook' => 'facebook.com',
            'instagram' => 'instagram.com',
            'linkedin' => 'linkedin.com',
        ];

        $host = strtolower((string) parse_url($url, PHP_URL_HOST));
        $root = $roots[$platform] ?? null;

        return $root !== null
            && ($host === $root || str_ends_with($host, '.'.$root));
    }
}

Real applications must also authorize modification of the selected member. Add the project’s voter or access-control rule before resolving anything; knowing a database identifier must never grant edit permission.

Test without calling the public service

MockHttpClient gives the client deterministic responses and lets tests verify the outgoing contract. The fixture below is intentionally schema-neutral: the assertion proves that an arbitrary JSON object is preserved, not that an undocumented field exists.

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

use App\Identity\IdentityResolverClient;
use App\Identity\ResolverFailure;
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 testItSendsTheDocumentedRequestAndMapsJson(): 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('instagram', $query['platform']);
            self::assertSame(
                'https://www.instagram.com/example/',
                $query['url'],
            );

            return new MockResponse('{"example":"preserved"}', [
                'http_code' => 200,
                'response_headers' => ['content-type: application/json'],
            ]);
        });

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

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

        self::assertTrue($result->succeeded);
        self::assertSame(['example' => 'preserved'], $result->identity);
    }

    public function testItStopsAfterBoundedTemporaryFailures(): void
    {
        $calls = 0;
        $http = new MockHttpClient(function () use (&$calls): MockResponse {
            $calls++;

            return new MockResponse('temporarily unavailable', [
                'http_code' => 503,
            ]);
        });

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

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

        self::assertFalse($result->succeeded);
        self::assertSame(ResolverFailure::Unavailable, $result->failure);
        self::assertSame(3, $calls);
    }
}
php bin/phpunit
php bin/console lint:container
php bin/console debug:router directory_social_link_put

Production failure modes and operations

A useful integration distinguishes failures that users can fix from failures operators must handle:

  • Invalid JSON, platform, URL, or host: return 400 or 422 without contacting the resolver.
  • Resolver rejection: return 422; do not retry a client error automatically.
  • Rate limiting: honor a numeric Retry-After value within a strict two-second cap, then return 429 after the retry budget is exhausted.
  • Timeout or server error: retry briefly and return 503 without changing stored member data.
  • Malformed success payload: treat it as an upstream failure. Never save HTML, scalar JSON, or partially decoded content as an identity.

Monitor counts by outcome, platform, HTTP status, and latency. Alert on sustained unavailable or malformed-response rates rather than individual failures. Avoid high-cardinality labels containing member IDs, profile URLs, or exception messages.

At the edge, apply request-size limits and per-user rate limiting. Keep Symfony and CA certificates current, never disable TLS verification, and ensure production logs cannot capture complete request bodies. The fixed base URI prevents callers from controlling the upstream destination, while the URL length and scheme checks bound potentially hostile input.

Deploy and verify safely

Deploy the code and migration together using the project’s normal release process. Configure IDENTITY_RESOLVER_BASE_URI in every environment, warm the production cache, run migrations once, and confirm outbound HTTPS access to the documented host. No secret injection step is necessary because the endpoint currently uses no token.

Finish with this verification checklist:

  • The documentation and service page have been reviewed for current contract details.
  • A minimal GET request succeeds without an authentication header.
  • Facebook, Instagram, and LinkedIn test links reach the exact resolver endpoint.
  • Successful JSON is stored under the correct directory member and platform.
  • Invalid hosts are rejected before any external request.
  • Client errors are not retried, while temporary failures have a bounded retry budget.
  • Malformed responses and exhausted retries leave existing identities unchanged.
  • Logs contain status, attempt, duration, and platform, but no submitted profile URL.
  • Authorization prevents one member from editing another member’s directory entry.

Normalization is most valuable when it happens at the boundary. Once every social link enters the directory as a resolved identity rather than an arbitrary string, the rest of the application becomes quieter: persistence is consistent, failure behavior is explicit, and future features can build on one dependable representation instead of years of accumulated URL variations.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.