Symfony uvođenje: Uvoz društvenih profila uz AI alat za razlučivanje identiteta i ručnu provjeru
A social-profile field looks harmless until onboarding depends on it. People paste full URLs, bare usernames, mobile share links, and identifiers copied from different platforms. Saving that input verbatim leaves every downstream feature to rediscover what it means.
This tutorial builds a production-oriented Symfony workflow that submits a public Facebook, Instagram, or LinkedIn reference to an Identity Resolver, persists the normalized result as a review candidate, and requires a person to accept or reject it. The important boundary is deliberate: normalization is automated, but your application remains responsible for deciding whether the returned public identity belongs to the onboarding user.
Get access before writing integration code
Start 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 making the first request.
- Open the registration page if you want a portal account for future account-managed services.
- Existing users can open the login page.
- Review the service and plan page.
- Confirm the current contract in the official documentation: the public resolver presently needs no token.
There is therefore no token field, authorization header, or API-key environment variable in this implementation. Do not invent one. If authentication is introduced later, follow the documentation then and place the issued secret in a deployment secret store rather than source control.
The exact request is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. It accepts platform plus a supported username, id, identifier, profile, or url parameter. This project consistently uses profile, allowing the submitted value to be either a recognizable profile reference or URL.
curl --get \
--data-urlencode "platform=linkedin" \
--data-urlencode "profile=https://www.linkedin.com/in/EXAMPLE_PUBLIC_PROFILE" \
"https://ai.mihajlo.mk/api/identity-resolver/v1/resolve"
Replace the placeholder with a public profile you are authorized to process. A successful response is a normalized public identity object. We will not assume undocumented field names; the application validates that the response is a JSON object and stores it intact.
Because no credential exists, store only non-secret integration configuration in .env.local. Production should supply the same variable through its environment rather than committing that file.
# .env.local
IDENTITY_RESOLVER_BASE_URI=https://ai.mihajlo.mk/api/identity-resolver
Choose a review-first architecture
The workflow has four small parts: an onboarding controller receives the platform and profile reference, a dedicated client calls the resolver, a Doctrine entity persists the candidate and normalized payload, and a review route records acceptance or rejection.
The resolver call stays synchronous because onboarding needs an immediate candidate to review. Messenger would add queue latency, duplicate-delivery concerns, and another operational dependency without improving this short interaction. If traffic or service latency later makes synchronous onboarding unacceptable, the same client can sit behind a Messenger handler while the candidate begins in a queued state.
Crucially, a successful API response does not automatically attach the identity to an account. Public data can be ambiguous, stale, or entered incorrectly. The explicit review state protects the user and gives the application an auditable decision point.
Prerequisites and project layout
Use PHP 8.3 or newer, Composer, a supported Symfony release, and a Doctrine-compatible database. Create the application and install only the components this workflow needs:
composer create-project symfony/skeleton social-onboarding
cd social-onboarding
composer require symfony/http-client symfony/orm-pack symfony/twig-bundle \
symfony/security-csrf symfony/validator
composer require --dev symfony/maker-bundle symfony/test-pack
The relevant project structure is intentionally compact:
src/
Controller/SocialOnboardingController.php
Entity/SocialProfileCandidate.php
Enum/ReviewStatus.php
Identity/IdentityResolverClient.php
Identity/NormalizedIdentity.php
Identity/ResolverFailure.php
templates/onboarding/
social.html.twig
review.html.twig
tests/Identity/
IdentityResolverClientTest.php
config/services.yaml
Bind the base URI through dependency injection. Keeping the endpoint root configurable makes tests and controlled environment changes possible without scattering URLs through business code.
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Identity\IdentityResolverClient:
arguments:
$baseUri: '%env(resolve:IDENTITY_RESOLVER_BASE_URI)%'
Build a defensive API boundary
The client owns transport details, retry policy, JSON validation, and failure classification. Domain and controller code should never need to understand HTTP status codes.
This implementation allows three total attempts. It retries transport failures, HTTP 429, and the transient 502, 503, and 504 responses. It does not retry validation, authentication, permission, or not-found responses. Backoff is short and bounded because holding a web request for an unbounded retry sequence is worse than presenting a recoverable onboarding error.
<?php
// src/Identity/NormalizedIdentity.php
namespace App\Identity;
final readonly class NormalizedIdentity
{
public function __construct(public array $payload)
{
}
}
// src/Identity/ResolverFailure.php
namespace App\Identity;
final class ResolverFailure extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly ?int $status = null,
) {
parent::__construct($message);
}
}
// 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
{
public function __construct(
private HttpClientInterface $http,
private LoggerInterface $logger,
private string $baseUri,
) {
}
public function resolve(string $platform, string $profile): NormalizedIdentity
{
$retryableStatuses = [429, 502, 503, 504];
$delays = [150_000, 350_000];
for ($attempt = 1; $attempt <= 3; ++$attempt) {
try {
$response = $this->http->request('GET', $this->baseUri.'/v1/resolve', [
'query' => [
'platform' => $platform,
'profile' => $profile,
],
'timeout' => 3.0,
'max_duration' => 8.0,
'headers' => ['Accept' => 'application/json'],
]);
$status = $response->getStatusCode();
$body = $response->getContent(false);
if ($status >= 200 && $status < 300) {
try {
$payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new ResolverFailure(
'invalid_response',
'Resolver returned invalid JSON.',
$status,
);
}
if (!is_array($payload) || array_is_list($payload)) {
throw new ResolverFailure(
'invalid_response',
'Resolver response was not a JSON object.',
$status,
);
}
return new NormalizedIdentity($payload);
}
if (!in_array($status, $retryableStatuses, true)) {
throw new ResolverFailure(
'rejected_request',
'Resolver rejected the request.',
$status,
);
}
$this->logger->warning('Identity resolution will be retried.', [
'attempt' => $attempt,
'status' => $status,
'platform' => $platform,
]);
} catch (TransportExceptionInterface $e) {
$this->logger->warning('Identity resolver transport failure.', [
'attempt' => $attempt,
'platform' => $platform,
'exception_class' => $e::class,
]);
}
if ($attempt < 3) {
usleep($delays[$attempt - 1]);
}
}
throw new ResolverFailure(
'temporarily_unavailable',
'Resolver remained unavailable after bounded retries.',
);
}
}
Notice what is absent from the logs: the submitted profile value, response body, cookies, and credentials. The platform, attempt number, status, and exception class are enough to diagnose availability without turning application logs into a copy of user-submitted identity data.
Persist a candidate, not an approved identity
The entity stores the original reference for review, the complete normalized object, timestamps, and a constrained status. Use a JSON-capable Doctrine column so the integration remains compatible with response evolution without pretending undocumented keys are guaranteed.
<?php
// src/Enum/ReviewStatus.php
namespace App\Enum;
enum ReviewStatus: string
{
case Pending = 'pending';
case Accepted = 'accepted';
case Rejected = 'rejected';
}
// src/Entity/SocialProfileCandidate.php
namespace App\Entity;
use App\Enum\ReviewStatus;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
final class SocialProfileCandidate
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\Column(length: 36)]
private string $ownerKey;
#[ORM\Column(length: 20)]
private string $platform;
#[ORM\Column(length: 2048)]
private string $submittedReference;
#[ORM\Column(type: 'json')]
private array $normalizedIdentity;
#[ORM\Column(enumType: ReviewStatus::class)]
private ReviewStatus $status;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $reviewedAt = null;
public function __construct(
string $ownerKey,
string $platform,
string $submittedReference,
array $normalizedIdentity,
) {
$this->id = Uuid::v7();
$this->ownerKey = $ownerKey;
$this->platform = $platform;
$this->submittedReference = $submittedReference;
$this->normalizedIdentity = $normalizedIdentity;
$this->status = ReviewStatus::Pending;
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): Uuid { return $this->id; }
public function getOwnerKey(): string { return $this->ownerKey; }
public function getPlatform(): string { return $this->platform; }
public function getSubmittedReference(): string { return $this->submittedReference; }
public function getNormalizedIdentity(): array { return $this->normalizedIdentity; }
public function getStatus(): ReviewStatus { return $this->status; }
public function review(ReviewStatus $decision): void
{
if ($this->status !== ReviewStatus::Pending) {
throw new \LogicException('Candidate has already been reviewed.');
}
if ($decision === ReviewStatus::Pending) {
throw new \InvalidArgumentException('A review requires a final decision.');
}
$this->status = $decision;
$this->reviewedAt = new \DateTimeImmutable();
}
}
Create and apply the migration after configuring DATABASE_URL for the target environment:
php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction
Connect import and manual review
The controller validates platform and input length before calling the service, uses CSRF protection for both mutations, and scopes candidates to an opaque onboarding key held in the session. An authenticated application should instead scope records to its real user identifier.
<?php
// src/Controller/SocialOnboardingController.php
namespace App\Controller;
use App\Entity\SocialProfileCandidate;
use App\Enum\ReviewStatus;
use App\Identity\IdentityResolverClient;
use App\Identity\ResolverFailure;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Uid\Uuid;
final class SocialOnboardingController extends AbstractController
{
#[Route('/onboarding/social', name: 'social_form', methods: ['GET'])]
public function form(): Response
{
return $this->render('onboarding/social.html.twig');
}
#[Route('/onboarding/social', name: 'social_import', methods: ['POST'])]
public function import(
Request $request,
IdentityResolverClient $resolver,
EntityManagerInterface $em,
CsrfTokenManagerInterface $csrf,
LoggerInterface $logger,
): Response {
$token = new CsrfToken('social-import', (string) $request->request->get('_token'));
if (!$csrf->isTokenValid($token)) {
throw $this->createAccessDeniedException();
}
$platform = strtolower(trim((string) $request->request->get('platform')));
$profile = trim((string) $request->request->get('profile'));
if (!in_array($platform, ['facebook', 'instagram', 'linkedin'], true)
|| $profile === ''
|| mb_strlen($profile) > 2048) {
$this->addFlash('error', 'Choose a supported platform and enter a valid profile reference.');
return $this->redirectToRoute('social_form');
}
try {
$identity = $resolver->resolve($platform, $profile);
} catch (ResolverFailure $e) {
$logger->error('Social profile import failed.', [
'kind' => $e->kind,
'status' => $e->status,
'platform' => $platform,
]);
$this->addFlash('error', 'The profile could not be imported. Check it or try again later.');
return $this->redirectToRoute('social_form');
}
$ownerKey = $request->getSession()->get('onboarding_key');
if (!is_string($ownerKey)) {
$ownerKey = Uuid::v7()->toRfc4122();
$request->getSession()->set('onboarding_key', $ownerKey);
}
$candidate = new SocialProfileCandidate(
$ownerKey,
$platform,
$profile,
$identity->payload,
);
$em->persist($candidate);
$em->flush();
return $this->redirectToRoute('social_review', ['id' => $candidate->getId()]);
}
#[Route('/onboarding/social/{id}', name: 'social_review', methods: ['GET', 'POST'])]
public function review(
SocialProfileCandidate $candidate,
Request $request,
EntityManagerInterface $em,
CsrfTokenManagerInterface $csrf,
): Response {
$ownerKey = $request->getSession()->get('onboarding_key');
if (!is_string($ownerKey) || !hash_equals($candidate->getOwnerKey(), $ownerKey)) {
throw $this->createNotFoundException();
}
if ($request->isMethod('POST')) {
$token = new CsrfToken(
'social-review-'.$candidate->getId(),
(string) $request->request->get('_token'),
);
if (!$csrf->isTokenValid($token)) {
throw $this->createAccessDeniedException();
}
$decision = match ($request->request->get('decision')) {
'accept' => ReviewStatus::Accepted,
'reject' => ReviewStatus::Rejected,
default => throw $this->createNotFoundException(),
};
$candidate->review($decision);
$em->flush();
return $this->redirectToRoute('social_review', ['id' => $candidate->getId()]);
}
return $this->render('onboarding/review.html.twig', ['candidate' => $candidate]);
}
}
The import template needs a platform selector, profile field, and {{ csrf_token('social-import') }}. The review template should show the submitted reference and normalized object, then submit either decision=accept or decision=reject with {{ csrf_token('social-review-' ~ candidate.id) }}. Escape ordinary values; for a readable diagnostic view, render the payload through Twig’s JSON encoding and escaping rather than marking service content as raw HTML.
Test retries and boundary validation
MockHttpClient makes transport behavior deterministic and prevents tests from contacting the public service. Test at least success, malformed JSON, a non-retryable client response, successful recovery after 429, and exhaustion after transient failures.
<?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 testMapsAJsonObjectWithoutAssumingItsFields(): void
{
$http = new MockHttpClient([
new MockResponse('{"public":"value"}', ['http_code' => 200]),
]);
$client = new IdentityResolverClient($http, new NullLogger(), 'https://resolver.test');
$identity = $client->resolve('linkedin', 'example');
self::assertSame(['public' => 'value'], $identity->payload);
}
public function testRetriesRateLimitThenSucceeds(): void
{
$http = new MockHttpClient([
new MockResponse('limited', ['http_code' => 429]),
new MockResponse('{"normalized":true}', ['http_code' => 200]),
]);
$client = new IdentityResolverClient($http, new NullLogger(), 'https://resolver.test');
self::assertSame(
['normalized' => true],
$client->resolve('instagram', 'example')->payload,
);
self::assertSame(2, $http->getRequestsCount());
}
public function testDoesNotRetryValidationFailure(): void
{
$http = new MockHttpClient([
new MockResponse('invalid', ['http_code' => 422]),
]);
$client = new IdentityResolverClient($http, new NullLogger(), 'https://resolver.test');
try {
$client->resolve('facebook', '');
self::fail('Expected resolver failure.');
} catch (ResolverFailure $e) {
self::assertSame('rejected_request', $e->kind);
self::assertSame(422, $e->status);
self::assertSame(1, $http->getRequestsCount());
}
}
}
Security, observability, and deployment
Public does not mean consequence-free. Tell users why the profile is requested, process only references relevant to onboarding, restrict access to review records, and define retention for rejected candidates. Treat every response value as untrusted display content. Never use normalized strings as HTML, SQL, shell arguments, or authorization evidence without context-appropriate validation.
Monitor counts and latency by outcome: success, rejected request, rate limited, transient upstream failure, and invalid response. Alert on sustained changes rather than one failed request. Logs should carry a request correlation identifier if the wider application already has one, but they should not contain the submitted profile or normalized payload.
During deployment, inject IDENTITY_RESOLVER_BASE_URI, configure DATABASE_URL, warm Symfony’s production cache, run migrations once, and deploy application processes only after configuration validation. Ensure outbound HTTPS to ai.mihajlo.mk is permitted. Do not disable TLS verification to solve certificate or proxy problems.
Common failure modes
- HTTP 400 or 422: inspect platform selection and the submitted parameter. Correct the request; retrying unchanged input is wasteful.
- HTTP 401 or 403: do not add a guessed authorization header. Recheck the current official documentation and deployment network policy.
- HTTP 429: respect the service boundary, keep retries bounded, and let the user retry later when attempts are exhausted.
- Invalid JSON or a non-object response: classify it as an upstream contract failure. Do not persist partial or guessed identity fields.
- Duplicate submissions: prevent a second review decision and consider a database uniqueness policy appropriate to your account model.
- Review page not found: verify that the same authenticated user or onboarding session owns the candidate; never reveal another user’s record.
Final verification checklist
- The service documentation has been checked, and no token or API key is configured for the current public endpoint.
- The application sends only
GETrequests to/v1/resolvewithplatformandprofile. - Connection and total request duration are bounded.
- Only rate limits, selected transient statuses, and transport failures are retried.
- The normalized response is validated as a JSON object without relying on undocumented fields.
- Candidates remain pending until an authorized person accepts or rejects them.
- Both state-changing forms use CSRF protection.
- Logs exclude profile references, response payloads, session identifiers, and secrets.
- Mock transport tests cover success, retry, malformed response, and permanent failure paths.
- Production configuration, migrations, HTTPS egress, monitoring, and retention rules are ready.
A reliable identity import is not the cleverest possible parser. It is a narrow, observable boundary around an external capability, followed by a human decision your application can explain. Normalize confidently, review deliberately, and let acceptance—not mere API success—be the moment a public profile becomes part of someone’s account.