Tutorials

Symfony: Streamline User Onboarding by Resolving Social Profiles with Manual Review

Symfony: Streamline User Onboarding by Resolving Social Profiles with Manual Review

Social-profile import looks simple until it becomes part of onboarding. A user pastes a LinkedIn URL, an Instagram handle, or a Facebook identifier; your application resolves it; and suddenly external public data is sitting beside trusted account information.

The safe design is not automatic acceptance. It is a short pipeline: resolve the public reference, preserve the normalized result, mark it as pending, and let an authorized reviewer decide whether it belongs on the account.

This tutorial builds that pipeline in Symfony on PHP 8.3. The implementation uses Symfony HttpClient, Doctrine DBAL, defensive response mapping, bounded retries, structured logs, and an explicit manual-review endpoint.

Get access before writing integration code

Start with the Identity Resolver service and plan page. It describes the service that normalizes public Facebook, Instagram, and LinkedIn references into a stable identity object.

Next, open the official Identity Resolver documentation and confirm the current request contract before deploying. The endpoint used here is public and currently requires no account token or API key.

That changes the usual onboarding sequence:

  1. Review the service on the registration and plan page. Registration is not required for the current public endpoint.
  2. Open the login and documentation page. You do not need to log in before making the first request.
  3. Do not search for or copy a token: there is currently no token field, API key, or bearer credential to configure.
  4. Confirm that the request uses GET, supplies platform, and supplies one supported reference parameter: username, id, identifier, profile, or url.

If authentication is introduced later, follow the official documentation and place the issued value in Symfony secrets or an environment variable such as IDENTITY_RESOLVER_TOKEN=YOUR_SERVICE_TOKEN. Do not invent or send that header today.

The exact endpoint is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Make a minimal test with a public profile URL that you are permitted to process:

curl --get \
  --data-urlencode "platform=linkedin" \
  --data-urlencode "url=YOUR_PUBLIC_PROFILE_URL" \
  --max-time 10 \
  "https://ai.mihajlo.mk/api/identity-resolver/v1/resolve"

No authorization header is present. Because there is no credential to store, configure only the base URI in .env.local, which Symfony excludes from normal source control:

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

Choose a deliberately small architecture

The feature remains synchronous because onboarding benefits from immediate feedback and the remote request has strict time limits. Messenger would add operational machinery without removing the need to tell the user whether resolution succeeded.

The components have narrow responsibilities:

  • IdentityResolver owns HTTP behavior, retries, response limits, and boundary validation.
  • ResolvedIdentity carries the normalized response without assuming undocumented fields.
  • ProfileImportRepository stores pending imports.
  • OnboardingProfileController validates user input and creates the review item.
  • ProfileReviewController lets an authorized reviewer approve an unchanged snapshot.

We intentionally retain the normalized response as JSON. The service promises a stable identity object, but the supplied contract does not enumerate its fields. Guessing names such as full_name or avatar would couple the application to an invented schema. Downstream code should promote fields into dedicated columns only after the official response definition has been verified.

Create the Symfony project foundation

Use PHP 8.3 or later, Composer, a supported database, and an existing Symfony application with Security configured. Install only the first-party components this feature needs:

composer require symfony/http-client symfony/orm-pack symfony/validator
composer require --dev symfony/test-pack
php bin/console doctrine:database:create
php bin/console make:migration
php bin/console doctrine:migrations:migrate

The relevant files will be:

src/
  Identity/IdentityResolutionException.php
  Identity/ResolvedIdentity.php
  Identity/IdentityResolver.php
  Onboarding/ProfileImportRepository.php
  Controller/OnboardingProfileController.php
  Controller/ProfileReviewController.php
migrations/
tests/Identity/IdentityResolverTest.php
config/services.yaml
.env.local

Bind the base URI through Symfony’s dependency-injection configuration:

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

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

Build a defensive API boundary

The exception exposes a finite failure kind to application code. The DTO accepts only a non-empty JSON object and computes a digest for audit comparison.

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

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

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

final readonly class ResolvedIdentity
{
    private function __construct(
        public array $data,
        public string $digest,
    ) {}

    public static function fromApi(array $data): self
    {
        if ($data === [] || array_is_list($data)) {
            throw new IdentityResolutionException(
                'invalid_response',
                'The resolver returned no identity object.'
            );
        }

        $canonical = json_encode(
            $data,
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
        );

        return new self($data, hash('sha256', $canonical));
    }
}

The HTTP client retries transport errors, 429, and server failures. It does not retry invalid parameters or authentication failures. Delays are bounded, so an excessive Retry-After value cannot stall a PHP worker indefinitely.

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

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

final class IdentityResolver
{
    private \Closure $sleep;

    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $baseUri,
        ?callable $sleep = null,
    ) {
        $this->sleep = \Closure::fromCallable($sleep ?? 'usleep');
    }

    public function resolve(
        string $platform,
        string $referenceType,
        string $reference,
    ): ResolvedIdentity {
        $allowed = ['username', 'id', 'identifier', 'profile', 'url'];

        if ($platform === '' || $reference === ''
            || !in_array($referenceType, $allowed, true)) {
            throw new IdentityResolutionException(
                'invalid_input',
                'Platform and a supported reference are required.'
            );
        }

        $fingerprint = substr(hash(
            'sha256',
            strtolower($platform)."\0".$referenceType."\0".$reference
        ), 0, 16);

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('GET', $this->baseUri.'/v1/resolve', [
                    'query' => [
                        'platform' => $platform,
                        $referenceType => $reference,
                    ],
                    'timeout' => 3.0,
                    'max_duration' => 8.0,
                    'headers' => ['Accept' => 'application/json'],
                ]);

                $status = $response->getStatusCode();

                if (($status === 429 || $status >= 500) && $attempt < 3) {
                    $headers = $response->getHeaders(false);
                    $seconds = min(
                        2,
                        max(0, (int) ($headers['retry-after'][0] ?? 0))
                    );
                    $delay = $seconds > 0
                        ? $seconds * 1_000_000
                        : [1 => 100_000, 2 => 300_000][$attempt];

                    $this->logger->warning('Identity resolution retry', [
                        'fingerprint' => $fingerprint,
                        'attempt' => $attempt,
                        'status' => $status,
                    ]);
                    ($this->sleep)($delay);
                    continue;
                }

                if ($status === 429) {
                    throw new IdentityResolutionException(
                        'rate_limited',
                        'The identity service is temporarily rate limited.'
                    );
                }

                if ($status === 400 || $status === 422) {
                    throw new IdentityResolutionException(
                        'invalid_input',
                        'The public profile reference was rejected.'
                    );
                }

                if ($status === 401 || $status === 403) {
                    throw new IdentityResolutionException(
                        'upstream_access',
                        'The public endpoint unexpectedly denied access.'
                    );
                }

                if ($status < 200 || $status >= 300) {
                    throw new IdentityResolutionException(
                        'unavailable',
                        'The identity service is unavailable.'
                    );
                }

                $body = $response->getContent(false);
                if (strlen($body) > 262_144) {
                    throw new IdentityResolutionException(
                        'invalid_response',
                        'The identity response exceeded the accepted size.'
                    );
                }

                try {
                    $data = json_decode($body, true, 64, JSON_THROW_ON_ERROR);
                } catch (\JsonException $error) {
                    throw new IdentityResolutionException(
                        'invalid_response',
                        'The identity response was not valid JSON.',
                        $error
                    );
                }

                if (!is_array($data)) {
                    throw new IdentityResolutionException(
                        'invalid_response',
                        'The identity response was not an object.'
                    );
                }

                $this->logger->info('Identity resolution succeeded', [
                    'fingerprint' => $fingerprint,
                    'attempt' => $attempt,
                ]);

                return ResolvedIdentity::fromApi($data);
            } catch (TransportExceptionInterface $error) {
                if ($attempt === 3) {
                    throw new IdentityResolutionException(
                        'unavailable',
                        'The identity service could not be reached.',
                        $error
                    );
                }

                ($this->sleep)([1 => 100_000, 2 => 300_000][$attempt]);
            }
        }

        throw new IdentityResolutionException('unavailable', 'Resolution failed.');
    }
}

Persist an immutable review candidate

Create a table whose state defaults to pending. The source reference and normalized result are snapshots; approval should not silently perform another lookup and change what the reviewer saw.

CREATE TABLE onboarding_profile_import (
    id CHAR(32) NOT NULL PRIMARY KEY,
    account_id VARCHAR(180) NOT NULL,
    platform VARCHAR(32) NOT NULL,
    reference_type VARCHAR(16) NOT NULL,
    reference_value VARCHAR(2048) NOT NULL,
    normalized_identity JSON NOT NULL,
    identity_digest CHAR(64) NOT NULL,
    status VARCHAR(16) NOT NULL DEFAULT 'pending',
    reviewed_by VARCHAR(180) DEFAULT NULL,
    reviewed_at TIMESTAMP DEFAULT NULL,
    created_at TIMESTAMP NOT NULL
);

CREATE INDEX profile_import_review_queue
    ON onboarding_profile_import (status, created_at);

Place equivalent SQL in the generated Doctrine migration. The repository uses a transaction and a conditional update so two reviewers cannot both approve the same record.

<?php
// src/Onboarding/ProfileImportRepository.php
namespace App\Onboarding;

use App\Identity\ResolvedIdentity;
use Doctrine\DBAL\Connection;

final class ProfileImportRepository
{
    public function __construct(private Connection $db) {}

    public function create(
        string $accountId,
        string $platform,
        string $referenceType,
        string $reference,
        ResolvedIdentity $identity,
    ): string {
        $id = bin2hex(random_bytes(16));

        $this->db->insert('onboarding_profile_import', [
            'id' => $id,
            'account_id' => $accountId,
            'platform' => $platform,
            'reference_type' => $referenceType,
            'reference_value' => $reference,
            'normalized_identity' => json_encode(
                $identity->data,
                JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
            ),
            'identity_digest' => $identity->digest,
            'status' => 'pending',
            'created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
        ]);

        return $id;
    }

    public function approve(string $id, string $reviewer): bool
    {
        return $this->db->update(
            'onboarding_profile_import',
            [
                'status' => 'approved',
                'reviewed_by' => $reviewer,
                'reviewed_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
            ],
            ['id' => $id, 'status' => 'pending']
        ) === 1;
    }
}

Connect onboarding to manual review

The onboarding route accepts JSON from an authenticated user. It returns 202 Accepted because successful resolution creates a review candidate, not an approved profile.

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

use App\Identity\IdentityResolutionException;
use App\Identity\IdentityResolver;
use App\Onboarding\ProfileImportRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class OnboardingProfileController extends AbstractController
{
    #[Route('/onboarding/social-profile', methods: ['POST'])]
    public function import(
        Request $request,
        IdentityResolver $resolver,
        ProfileImportRepository $imports,
    ): JsonResponse {
        $this->denyAccessUnlessGranted('ROLE_USER');

        try {
            $input = $request->toArray();
            $platform = trim((string) ($input['platform'] ?? ''));
            $type = trim((string) ($input['reference_type'] ?? ''));
            $reference = trim((string) ($input['reference'] ?? ''));

            if (strlen($platform) > 32 || strlen($reference) > 2048) {
                throw new IdentityResolutionException(
                    'invalid_input',
                    'The submitted reference is too long.'
                );
            }

            $identity = $resolver->resolve($platform, $type, $reference);
            $id = $imports->create(
                $this->getUser()->getUserIdentifier(),
                $platform,
                $type,
                $reference,
                $identity
            );

            return $this->json([
                'import_id' => $id,
                'status' => 'pending_review',
            ], 202);
        } catch (IdentityResolutionException $error) {
            $status = match ($error->kind) {
                'invalid_input' => 422,
                'rate_limited' => 429,
                'unavailable' => 503,
                default => 502,
            };

            return $this->json([
                'error' => $error->kind,
                'message' => $error->getMessage(),
            ], $status);
        } catch (\JsonException) {
            return $this->json(['error' => 'invalid_json'], 400);
        }
    }
}

The approval route requires a dedicated reviewer role. A failed conditional update means the record is missing or has already left the pending state.

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

use App\Onboarding\ProfileImportRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class ProfileReviewController extends AbstractController
{
    #[Route('/review/profile-imports/{id}/approve', methods: ['POST'])]
    public function approve(
        string $id,
        ProfileImportRepository $imports,
    ): JsonResponse {
        $this->denyAccessUnlessGranted('ROLE_PROFILE_REVIEWER');

        $approved = $imports->approve(
            $id,
            $this->getUser()->getUserIdentifier()
        );

        return $approved
            ? $this->json(['status' => 'approved'])
            : $this->json(['error' => 'not_pending'], 409);
    }
}

Test requests without touching the network

MockHttpClient makes the tests deterministic. These cases verify query construction, domain mapping, bounded rate-limit retry, and the absence of authentication headers.

<?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 testItMapsAResolvedIdentity(): void
    {
        $client = new MockHttpClient(
            function (string $method, string $url, array $options): MockResponse {
                self::assertSame('GET', $method);
                self::assertStringContainsString('platform=linkedin', $url);
                self::assertStringContainsString('url=', $url);
                self::assertArrayNotHasKey('authorization', $options['normalized_headers']);

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

        $resolver = new IdentityResolver(
            $client,
            new NullLogger(),
            'https://ai.mihajlo.mk/api/identity-resolver',
            static function (int $microseconds): void {}
        );

        $identity = $resolver->resolve(
            'linkedin',
            'url',
            'https://example.test/public-profile'
        );

        self::assertSame('public-value', $identity->data['stable']);
        self::assertSame(64, strlen($identity->digest));
    }

    public function testItRetriesRateLimitingThenSucceeds(): void
    {
        $client = new MockHttpClient([
            new MockResponse('{}', [
                'http_code' => 429,
                'response_headers' => ['retry-after: 1'],
            ]),
            new MockResponse('{"stable":"second-attempt"}', ['http_code' => 200]),
        ]);

        $resolver = new IdentityResolver(
            $client,
            new NullLogger(),
            'https://ai.mihajlo.mk/api/identity-resolver',
            static function (int $microseconds): void {}
        );

        self::assertSame(
            'second-attempt',
            $resolver->resolve('linkedin', 'url', 'https://example.test/p')->data['stable']
        );
    }
}

Run the suite and inspect the routes before deployment:

php bin/phpunit
php bin/console debug:router
php bin/console lint:container
php bin/console doctrine:migrations:status

Security and operational boundaries

A public profile is not permissionless data. Tell users what will be imported, retain only what onboarding needs, define a deletion policy, and restrict review records to appropriate staff. Never use resolution as proof that the submitting user owns the external account; ownership requires a separate verification mechanism.

Protect browser-based POST requests with Symfony CSRF tokens. For token-authenticated JSON clients, apply the project’s normal authentication and origin controls. Escape all imported values when rendering them, and never treat returned URLs or text as trusted HTML.

Logs should contain the correlation fingerprint, status, attempt, latency, and final failure kind—not the raw profile URL, username, response body, cookies, or future credentials. Track rates of invalid_input, rate_limited, unavailable, and invalid_response. An alert on sustained upstream failures is more useful than an alert for one rejected username.

Common failures worth rehearsing

  • A valid-looking reference receives 422: verify the platform and chosen parameter against the official documentation. Do not repeatedly retry validation failures.
  • Requests receive 429: honor bounded backoff, return a retryable application state, and avoid parallel duplicate submissions.
  • The endpoint returns HTML or malformed JSON: classify it as invalid_response; never persist an error page as an identity.
  • Production receives 401 or 403: the current public contract suggests an upstream or network-policy problem. Do not guess an authorization header.
  • Two reviewers act together: the conditional update permits only the first transition from pending.
  • The upstream schema evolves: the boundary accepts an object without depending on undocumented fields, while the stored digest preserves the reviewed snapshot.

Deploy and verify the complete path

Set IDENTITY_RESOLVER_BASE_URI in the production environment, run migrations before routing traffic to the new controllers, warm Symfony’s cache, and confirm outbound HTTPS access to the documented host. Deploy the database change before the application code if releases can overlap.

Use this final checklist:

  • The documentation still states that the endpoint requires no token.
  • A permitted Facebook, Instagram, or LinkedIn reference resolves through the exact GET endpoint.
  • An invalid reference returns a structured client error without retries.
  • A successful import is stored as pending, never automatically approved.
  • Ordinary users cannot call the review route.
  • One reviewer can approve the snapshot, while a second receives 409.
  • Logs contain operational metadata but no raw profile data.
  • Timeout, malformed-response, rate-limit, and upstream-failure tests pass.

The important feature is not merely that Symfony can turn a social URL into JSON. It is that your application knows where automated normalization ends and human judgment begins. A narrow API boundary, an immutable pending record, and an auditable approval transition make that distinction explicit—and turn a convenient onboarding shortcut into a feature you can operate responsibly.

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.