Вклучување во Symfony: Потврдете ги јавните профили на социјалните мрежи со Identity Resolver
Social-profile onboarding looks simple until unreliable input reaches production. A person may paste a full LinkedIn URL, type an Instagram username, or provide a Facebook identifier. Storing those strings directly creates inconsistent records and pushes ambiguity into every downstream feature.
This Symfony implementation resolves that input into a normalized public identity, stores the original reference alongside the response, and deliberately stops at pending_review. A human reviewer—not an external API—makes the final acceptance decision.
Get access before writing integration code
Start with the official Identity Resolver documentation, then review the service and plan page.
The current public endpoint requires no account token and no API key. There is therefore no credential to copy before your first request. Registration and login are not prerequisites for this endpoint; if you want an account for other services, use the registration or login entry points available through the official service site rather than guessing direct account URLs.
- Open the documentation and confirm the supported platforms and reference types.
- Review the service page for current availability and plan information.
- Do not create a placeholder authorization header. The endpoint currently accepts public requests without one.
- Make a small test request before integrating it into onboarding.
The exact API call is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Send platform plus one supported reference parameter: username, id, identifier, profile, or url.
curl --get \
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
--data-urlencode 'platform=instagram' \
--data-urlencode 'username=example_public_profile'
Do not put a fabricated token in Symfony configuration. Store only the endpoint base URI. If authentication is introduced in a future documented contract, add its real credential then through an environment-backed secret.
# .env
IDENTITY_RESOLVER_BASE_URI=https://ai.mihajlo.mk/api/identity-resolver/v1
Architecture: resolve, persist, then review
The ordinary use case is an onboarding form asking a freelancer, creator, or small-business representative for one public social profile. The request travels through four explicit boundaries:
- The controller validates the platform and reference shape.
- A dedicated client calls Identity Resolver and validates that the response is a JSON object.
- The application stores both the submitted reference and untouched normalized response.
- An authorized reviewer inspects the record and accepts or rejects it.
The integration remains synchronous because onboarding benefits from immediate feedback and the API call is bounded by short timeouts. Symfony Messenger would add operational machinery without improving this small workflow. If traffic later requires asynchronous imports, the same client and entity can sit behind a message handler.
A compact project structure is sufficient:
src/
Controller/OnboardingProfileController.php
Entity/OnboardingProfile.php
Identity/IdentityResolverClient.php
Identity/IdentityResolverFailure.php
Identity/ResolvedPublicIdentity.php
tests/
Identity/IdentityResolverClientTest.php
config/
services.yaml
Install and configure the Symfony components
This tutorial assumes a Symfony application running PHP 8.3 or later with its normal security system already configured. Add the first-party HTTP client, Doctrine integration, validation support, and test tools:
composer require symfony/http-client symfony/orm-pack symfony/validator
composer require --dev symfony/test-pack
Bind the environment value by argument name. No authorization header or credential parameter belongs here.
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Identity\IdentityResolverClient:
arguments:
$identityResolverBaseUri: '%env(string:IDENTITY_RESOLVER_BASE_URI)%'
Build a defensive API boundary
The service normalizes public Facebook, Instagram, and LinkedIn references into a stable identity object. The application should not guess undocumented response fields. Instead, it validates the top-level JSON shape and preserves the normalized object as received.
The local fingerprint below is an application audit aid, not a claimed service field. Sorting object keys before hashing prevents harmless JSON key ordering from producing a different fingerprint.
<?php
// src/Identity/ResolvedPublicIdentity.php
namespace App\Identity;
final readonly class ResolvedPublicIdentity
{
public function __construct(
public string $platform,
public string $referenceType,
public string $referenceValue,
public array $normalized,
) {}
public function fingerprint(): string
{
$value = $this->normalized;
self::sortRecursively($value);
return hash('sha256', json_encode(
$value,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
));
}
private static function sortRecursively(array &$value): void
{
if (!array_is_list($value)) {
ksort($value);
}
foreach ($value as &$child) {
if (is_array($child)) {
self::sortRecursively($child);
}
}
}
}
// src/Identity/IdentityResolverFailure.php
namespace App\Identity;
final class IdentityResolverFailure extends \RuntimeException
{
public function __construct(
public readonly string $reason,
public readonly ?int $upstreamStatus,
public readonly bool $retryable,
) {
parent::__construct($reason);
}
}
The HTTP client accepts only documented platforms and reference parameters. It retries transport failures, rate limiting, and server failures at most twice after the initial request. It does not retry other 4xx responses because changing neither the input nor authentication state makes those retries wasteful.
<?php
// src/Identity/IdentityResolverClient.php
namespace App\Identity;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class IdentityResolverClient
{
private const PLATFORMS = ['facebook', 'instagram', 'linkedin'];
private const REFERENCES = ['username', 'id', 'identifier', 'profile', 'url'];
public function __construct(
private HttpClientInterface $http,
private LoggerInterface $logger,
private string $identityResolverBaseUri,
) {}
public function resolve(
string $platform,
string $referenceType,
string $referenceValue,
): ResolvedPublicIdentity {
$platform = strtolower(trim($platform));
$referenceType = strtolower(trim($referenceType));
$referenceValue = trim($referenceValue);
if (!in_array($platform, self::PLATFORMS, true)) {
throw new \InvalidArgumentException('Unsupported platform.');
}
if (!in_array($referenceType, self::REFERENCES, true)) {
throw new \InvalidArgumentException('Unsupported reference type.');
}
if ($referenceValue === '') {
throw new \InvalidArgumentException('The profile reference is required.');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('GET',
rtrim($this->identityResolverBaseUri, '/').'/resolve',
[
'query' => [
'platform' => $platform,
$referenceType => $referenceValue,
],
'timeout' => 8.0,
'max_duration' => 12.0,
]
);
$status = $response->getStatusCode();
$body = $response->getContent(false);
} catch (TransportExceptionInterface $exception) {
if ($attempt < 3) {
$this->warn($platform, $attempt, null, 'transport');
usleep(200_000 * $attempt);
continue;
}
throw new IdentityResolverFailure('transport_failure', null, true);
}
if (($status === 429 || $status >= 500) && $attempt < 3) {
$this->warn($platform, $attempt, $status, 'retryable_response');
$headers = $response->getHeaders(false);
$retryAfter = (int) ($headers['retry-after'][0] ?? 0);
$delay = $retryAfter > 0
? min($retryAfter, 2) * 1_000_000
: 200_000 * $attempt;
usleep($delay);
continue;
}
if ($status >= 400) {
throw new IdentityResolverFailure(
'upstream_rejected_request',
$status,
$status === 429 || $status >= 500,
);
}
try {
$payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new IdentityResolverFailure('invalid_json_response', $status, false);
}
if (!is_array($payload) || array_is_list($payload)) {
throw new IdentityResolverFailure('invalid_identity_shape', $status, false);
}
return new ResolvedPublicIdentity(
$platform,
$referenceType,
$referenceValue,
$payload,
);
}
throw new IdentityResolverFailure('retry_exhausted', null, true);
}
private function warn(
string $platform,
int $attempt,
?int $status,
string $reason,
): void {
$this->logger->warning('Identity resolution attempt failed.', [
'platform' => $platform,
'attempt' => $attempt,
'upstream_status' => $status,
'reason' => $reason,
]);
}
}
Notice what the log omits: the submitted username, URL, response body, and query string. Public information still deserves data minimization, particularly once attached to a user account.
Persist an explicit review state
The database record separates provider data from the application’s decision. The normalized payload is evidence for review; it is not authorization to claim or control the external profile.
<?php
// src/Entity/OnboardingProfile.php
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class OnboardingProfile
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 20)]
private string $platform;
#[ORM\Column(length: 20)]
private string $referenceType;
#[ORM\Column(length: 2048)]
private string $referenceValue;
#[ORM\Column(type: Types::JSON)]
private array $normalized;
#[ORM\Column(length: 64)]
private string $fingerprint;
#[ORM\Column(length: 20)]
private string $status = 'pending_review';
#[ORM\Column(length: 180)]
private string $submittedBy;
#[ORM\Column(length: 180, nullable: true)]
private ?string $reviewedBy = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $reviewedAt = null;
public function __construct(
string $platform,
string $referenceType,
string $referenceValue,
array $normalized,
string $fingerprint,
string $submittedBy,
) {
$this->platform = $platform;
$this->referenceType = $referenceType;
$this->referenceValue = $referenceValue;
$this->normalized = $normalized;
$this->fingerprint = $fingerprint;
$this->submittedBy = $submittedBy;
}
public function getId(): ?int { return $this->id; }
public function getStatus(): string { return $this->status; }
public function getNormalized(): array { return $this->normalized; }
public function review(string $decision, string $reviewer): void
{
if ($this->status !== 'pending_review') {
throw new \LogicException('This profile has already been reviewed.');
}
if (!in_array($decision, ['accepted', 'rejected'], true)) {
throw new \InvalidArgumentException('Invalid review decision.');
}
$this->status = $decision;
$this->reviewedBy = $reviewer;
$this->reviewedAt = new \DateTimeImmutable();
}
}
Expose import and reviewer-only endpoints
The import endpoint returns only the local record identifier and state. Reviewers can inspect the normalized object through a separate protected route, then submit an explicit decision.
<?php
// src/Controller/OnboardingProfileController.php
namespace App\Controller;
use App\Entity\OnboardingProfile;
use App\Identity\IdentityResolverClient;
use App\Identity\IdentityResolverFailure;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class OnboardingProfileController extends AbstractController
{
#[Route('/onboarding/social-profile', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function import(
Request $request,
IdentityResolverClient $resolver,
EntityManagerInterface $em,
): JsonResponse {
try {
$input = $request->toArray();
$identity = $resolver->resolve(
(string) ($input['platform'] ?? ''),
(string) ($input['reference_type'] ?? ''),
(string) ($input['reference_value'] ?? ''),
);
} catch (\JsonException|\InvalidArgumentException $exception) {
return $this->json(['error' => 'invalid_request'], 422);
} catch (IdentityResolverFailure $exception) {
return $this->json(
['error' => $exception->reason],
$exception->retryable ? 503 : 422,
);
}
$record = new OnboardingProfile(
$identity->platform,
$identity->referenceType,
$identity->referenceValue,
$identity->normalized,
$identity->fingerprint(),
$this->getUser()->getUserIdentifier(),
);
$em->persist($record);
$em->flush();
return $this->json([
'id' => $record->getId(),
'status' => $record->getStatus(),
], 201);
}
#[Route('/review/social-profile/{id}', methods: ['GET'])]
#[IsGranted('ROLE_PROFILE_REVIEWER')]
public function show(int $id, EntityManagerInterface $em): JsonResponse
{
$record = $em->find(OnboardingProfile::class, $id);
if (!$record) {
throw $this->createNotFoundException();
}
return $this->json([
'id' => $record->getId(),
'status' => $record->getStatus(),
'normalized' => $record->getNormalized(),
]);
}
#[Route('/review/social-profile/{id}', methods: ['POST'])]
#[IsGranted('ROLE_PROFILE_REVIEWER')]
public function review(
int $id,
Request $request,
EntityManagerInterface $em,
): JsonResponse {
$record = $em->find(OnboardingProfile::class, $id);
if (!$record) {
throw $this->createNotFoundException();
}
try {
$record->review(
(string) ($request->toArray()['decision'] ?? ''),
$this->getUser()->getUserIdentifier(),
);
$em->flush();
} catch (\JsonException|\InvalidArgumentException|\LogicException) {
return $this->json(['error' => 'invalid_review'], 409);
}
return $this->json(['status' => $record->getStatus()]);
}
}
For browser sessions, apply CSRF protection to both state-changing routes. Also enforce tenant or ownership boundaries in addition to roles; a reviewer must not gain access to unrelated organizations merely by changing an identifier in the URL.
Test the transport deterministically
MockHttpClient exercises request construction without a network dependency. The second test proves that a validation-style upstream failure is not blindly retried.
<?php
// tests/Identity/IdentityResolverClientTest.php
namespace App\Tests\Identity;
use App\Identity\IdentityResolverClient;
use App\Identity\IdentityResolverFailure;
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 testItMapsAResolvedIdentity(): void
{
$http = new MockHttpClient(
function (string $method, string $url): MockResponse {
self::assertSame('GET', $method);
self::assertStringContainsString(
'platform=instagram',
$url
);
self::assertStringContainsString(
'username=example_public_profile',
$url
);
return new MockResponse(
'{"identity":{"display":"Example"}}',
['http_code' => 200]
);
}
);
$client = new IdentityResolverClient(
$http,
new NullLogger(),
'https://ai.mihajlo.mk/api/identity-resolver/v1'
);
$result = $client->resolve(
'instagram',
'username',
'example_public_profile'
);
self::assertSame('instagram', $result->platform);
self::assertSame(
['identity' => ['display' => 'Example']],
$result->normalized
);
}
public function testItDoesNotRetryARejectedRequest(): void
{
$calls = 0;
$http = new MockHttpClient(
function () use (&$calls): MockResponse {
$calls++;
return new MockResponse(
'{"error":"invalid"}',
['http_code' => 400]
);
}
);
$client = new IdentityResolverClient(
$http,
new NullLogger(),
'https://ai.mihajlo.mk/api/identity-resolver/v1'
);
try {
$client->resolve('linkedin', 'url', 'not-a-valid-url');
self::fail('Expected an IdentityResolverFailure.');
} catch (IdentityResolverFailure $failure) {
self::assertSame('upstream_rejected_request', $failure->reason);
self::assertSame(1, $calls);
}
}
}
Add functional controller tests for authentication, reviewer authorization, malformed JSON, repeated review attempts, and database persistence. Those tests should replace the client with a deterministic test service so an external outage cannot make the suite fail.
Deploy with restrained observability
Generate the migration locally, inspect its SQL, run the test suite, and apply the reviewed migration during deployment:
php bin/console doctrine:migrations:diff
php bin/phpunit
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console cache:clear
Track request counts, latency, retry counts, failure reasons, upstream status classes, and the age of pending reviews. Alert on sustained transport failures or a growing review backlog. Never attach raw responses, submitted profile URLs, or query strings to logs and traces.
Common production failures are predictable: unsupported platform spelling should fail locally; malformed references should return a reviewable 422 response; 429 and 5xx responses may receive bounded retries before becoming 503; invalid JSON must never be stored; and a duplicate review must return a conflict instead of silently replacing the original decision.
Final verification checklist
- The application calls the documented endpoint with
GET. - Exactly one supported reference parameter accompanies
platform. - No token, API key, or invented authorization header is sent.
- Connection and total response time are bounded.
- Only transient transport, 429, and 5xx failures are retried.
- The response is validated as a JSON object without assuming undocumented fields.
- Every imported profile begins in
pending_review. - Only authorized reviewers can inspect and decide records.
- Tests use
MockHttpClientrather than the live service. - Logs and metrics exclude profile values and normalized payloads.
A normalized identity is useful evidence, but it is not proof of ownership and should not become an automatic trust decision. The strongest onboarding flow combines a narrow API boundary, an auditable local state machine, and a visible human checkpoint. That small pause turns a convenient profile import into a system people can operate responsibly.