Туториали

Laravel Onboarding: Public Social Profile Verification with Identity Resolver

Laravel Воведување: Потврда на јавен профил на социјалните мрежи со разрешувач на идентитет

A social-profile field looks deceptively simple until onboarding depends on it. People paste usernames, numeric IDs, full URLs, and profile references in inconsistent formats. Your application then has to normalize that input without silently attaching the wrong person to an account.

This tutorial builds a production-oriented Laravel workflow around Identity Resolver. It accepts a public Facebook, Instagram, or LinkedIn reference, retrieves a normalized identity object, stores it as a pending import, and requires the user to approve or reject the result. That final review is not ceremony: normalization establishes a likely identity, while a human confirms intent.

Get access before writing integration code

Start with the official Identity Resolver documentation. The current public endpoint requires no account token and no API key, so there is no credential to copy into Laravel before your first request.

  1. Open the service and plan page to review the service.
  2. Read the official documentation for supported reference formats.
  3. If you want an account for other services or account features, use the site’s registration page or login page. Neither is required for this public endpoint.
  4. Do not invent an API-key header or add an empty bearer token. There is currently no token-copy step for Identity Resolver.

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

Make a minimal request before changing the application:

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

Use a public reference you are entitled to process. The command deliberately contains no authorization header.

Choose a review-first architecture

The browser submits a platform, reference type, and reference value. A controller validates this input, then a dedicated service calls the fixed upstream endpoint. The returned JSON crosses an application boundary where it is checked structurally and wrapped in a domain object. Laravel stores the object with pending_review status before displaying it.

Approval copies the reviewed object into the user’s imported profiles; rejection retains the audit record without activating it. The API call remains synchronous because the user needs an immediate preview. A queue would add state and latency without helping this short, interactive operation.

The relevant project structure is:

app/
  Data/IdentityLookup.php
  Data/NormalizedIdentity.php
  Exceptions/ResolverFailure.php
  Http/Controllers/OnboardingSocialProfileController.php
  Models/SocialProfileImport.php
  Services/IdentityResolver.php
database/migrations/
resources/views/onboarding/
  social.blade.php
  social-review.blade.php
routes/web.php
tests/Feature/OnboardingSocialProfileTest.php

Prerequisites are PHP 8.3 or later, a supported Laravel application, a configured database, and outbound HTTPS access. No additional HTTP package is needed because Laravel’s built-in client provides timeouts, retries, fakes, and response inspection.

Configure the service boundary

There is no credential to store. Put only the endpoint in .env, keeping deployment-specific configuration out of source code:

IDENTITY_RESOLVER_URL=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve

Add this entry to config/services.php:

'identity_resolver' => [
    'url' => env(
        'IDENTITY_RESOLVER_URL',
        'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'
    ),
],

If authentication is introduced in a future documented contract, add its credential to environment-backed configuration then. Do not anticipate that change by sending undocumented headers today.

Persist a pending import

Create the supporting classes and migration:

php artisan make:model SocialProfileImport -m
php artisan make:controller OnboardingSocialProfileController
php artisan make:test OnboardingSocialProfileTest

In the generated migration, create the review table and a destination column on users:

public function up(): void
{
    Schema::create('social_profile_imports', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained()->cascadeOnDelete();
        $table->string('platform', 32);
        $table->string('reference_type', 32);
        $table->text('reference_value');
        $table->json('normalized_identity');
        $table->string('status', 32)->default('pending_review');
        $table->timestamp('reviewed_at')->nullable();
        $table->timestamps();

        $table->index(['user_id', 'status']);
    });

    Schema::table('users', function (Blueprint $table) {
        $table->json('public_social_profiles')->nullable();
    });
}

public function down(): void
{
    Schema::dropIfExists('social_profile_imports');

    Schema::table('users', function (Blueprint $table) {
        $table->dropColumn('public_social_profiles');
    });
}

Configure SocialProfileImport with fillable input fields and casts for normalized_identity and reviewed_at. Add an array cast for public_social_profiles to the existing casts on User. Keeping the original reference alongside the normalized response makes review and incident diagnosis possible.

Map the domain without assuming undocumented fields

The contract promises a normalized public identity response, but application code should not guess field names. These small objects preserve the response while keeping transport details out of the controller:

<?php
// app/Data/IdentityLookup.php

namespace App\Data;

final readonly class IdentityLookup
{
    public function __construct(
        public string $platform,
        public string $type,
        public string $value,
    ) {}
}

// app/Data/NormalizedIdentity.php

namespace App\Data;

final readonly class NormalizedIdentity
{
    public function __construct(
        public string $platform,
        public array $payload,
    ) {}
}

Create a structured exception in app/Exceptions/ResolverFailure.php:

<?php

namespace App\Exceptions;

use RuntimeException;
use Throwable;

final class ResolverFailure extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status = null,
        public readonly ?int $retryAfter = null,
        public readonly ?string $requestId = null,
        ?Throwable $previous = null,
    ) {
        parent::__construct($kind, 0, $previous);
    }
}

Build a bounded, selective HTTP client

The service below uses a three-second connection timeout and an eight-second total timeout. It retries connection failures and server errors with bounded backoff. It does not retry validation failures, authentication failures, or rate limits. A 429 becomes an explicit state so the application can ask the user to try later instead of increasing upstream pressure.

<?php

namespace App\Services;

use App\Data\IdentityLookup;
use App\Data\NormalizedIdentity;
use App\Exceptions\ResolverFailure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;

final class IdentityResolver
{
    public function resolve(IdentityLookup $lookup): NormalizedIdentity
    {
        $requestId = (string) Str::uuid();
        $started = hrtime(true);

        try {
            $response = Http::acceptJson()
                ->connectTimeout(3)
                ->timeout(8)
                ->withHeaders(['X-Request-ID' => $requestId])
                ->retry(
                    [200, 500],
                    when: static function (
                        Throwable $exception,
                        PendingRequest $request
                    ): bool {
                        if ($exception instanceof ConnectionException) {
                            return true;
                        }

                        return $exception instanceof RequestException
                            && $exception->response->serverError();
                    },
                    throw: false,
                )
                ->get(config('services.identity_resolver.url'), [
                    'platform' => $lookup->platform,
                    $lookup->type => $lookup->value,
                ]);
        } catch (ConnectionException $exception) {
            throw new ResolverFailure(
                'unavailable',
                requestId: $requestId,
                previous: $exception,
            );
        }

        Log::info('identity_resolver_response', [
            'request_id' => $requestId,
            'status' => $response->status(),
            'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000),
        ]);

        if ($response->status() === 429) {
            $header = $response->header('Retry-After');

            throw new ResolverFailure(
                'rate_limited',
                429,
                is_numeric($header) ? (int) $header : null,
                $requestId,
            );
        }

        if (in_array($response->status(), [400, 404, 422], true)) {
            throw new ResolverFailure(
                'invalid_reference',
                $response->status(),
                requestId: $requestId,
            );
        }

        if (in_array($response->status(), [401, 403], true)) {
            throw new ResolverFailure(
                'configuration_error',
                $response->status(),
                requestId: $requestId,
            );
        }

        if ($response->failed()) {
            throw new ResolverFailure(
                'upstream_error',
                $response->status(),
                requestId: $requestId,
            );
        }

        $payload = $response->json();

        if (! is_array($payload) || $payload === [] || array_is_list($payload)) {
            throw new ResolverFailure(
                'malformed_response',
                $response->status(),
                requestId: $requestId,
            );
        }

        return new NormalizedIdentity($lookup->platform, $payload);
    }
}

The endpoint is never derived from user input, which closes the obvious server-side request-forgery path. Query values are supplied as parameters rather than concatenated into a URL.

Resolve, review, and commit the decision

The controller validates the supported vocabulary, records only successful resolutions, and locks the pending row during review. That lock prevents two tabs from approving and rejecting the same import concurrently.

public function resolve(Request $request, IdentityResolver $resolver)
{
    $data = $request->validate([
        'platform' => ['required', Rule::in(['facebook', 'instagram', 'linkedin'])],
        'reference_type' => [
            'required',
            Rule::in(['username', 'id', 'identifier', 'profile', 'url']),
        ],
        'reference' => ['required', 'string', 'max:500'],
    ]);

    try {
        $identity = $resolver->resolve(new IdentityLookup(
            $data['platform'],
            $data['reference_type'],
            $data['reference'],
        ));
    } catch (ResolverFailure $failure) {
        Log::warning('identity_resolver_failed', [
            'kind' => $failure->kind,
            'status' => $failure->status,
            'request_id' => $failure->requestId,
            'retry_after' => $failure->retryAfter,
        ]);

        $message = match ($failure->kind) {
            'invalid_reference' => 'That public profile could not be resolved.',
            'rate_limited' => 'Profile lookup is busy. Please try again later.',
            default => 'Profile verification is temporarily unavailable.',
        };

        return back()->withErrors(['social_profile' => $message])->withInput();
    }

    $import = SocialProfileImport::create([
        'user_id' => $request->user()->id,
        'platform' => $data['platform'],
        'reference_type' => $data['reference_type'],
        'reference_value' => $data['reference'],
        'normalized_identity' => $identity->payload,
        'status' => 'pending_review',
    ]);

    return redirect()->route('onboarding.social.review', $import);
}

public function review(Request $request, SocialProfileImport $import)
{
    abort_unless($import->user_id === $request->user()->id, 404);

    return view('onboarding.social-review', compact('import'));
}

public function decide(Request $request, SocialProfileImport $import)
{
    $data = $request->validate([
        'decision' => ['required', Rule::in(['approve', 'reject'])],
    ]);

    DB::transaction(function () use ($request, $import, $data) {
        $locked = SocialProfileImport::query()
            ->whereKey($import->id)
            ->where('user_id', $request->user()->id)
            ->lockForUpdate()
            ->firstOrFail();

        abort_unless($locked->status === 'pending_review', 409);

        if ($data['decision'] === 'approve') {
            $user = $request->user();
            $profiles = $user->public_social_profiles ?? [];
            $profiles[] = [
                'platform' => $locked->platform,
                'identity' => $locked->normalized_identity,
            ];
            $user->public_social_profiles = $profiles;
            $user->save();
        }

        $locked->status = $data['decision'] === 'approve'
            ? 'approved'
            : 'rejected';
        $locked->reviewed_at = now();
        $locked->save();
    });

    return redirect()->route('dashboard');
}

Place the routes behind Laravel’s session authentication middleware:

Route::middleware('auth')->group(function () {
    Route::post('/onboarding/social-profile', [OnboardingSocialProfileController::class, 'resolve'])
        ->name('onboarding.social.resolve');

    Route::get('/onboarding/social-profile/{import}/review', [OnboardingSocialProfileController::class, 'review'])
        ->name('onboarding.social.review');

    Route::post('/onboarding/social-profile/{import}/decision', [OnboardingSocialProfileController::class, 'decide'])
        ->name('onboarding.social.decide');
});

The onboarding form should offer the three platforms and five documented reference types. The review view must show the submitted reference, platform, and safely escaped normalized JSON, followed by separate approve and reject buttons. Both forms need Blade’s @csrf. Never approve automatically merely because the resolver returned HTTP 200.

Test the contract and failure paths

Laravel’s HTTP fake makes the integration deterministic and proves that no real request escapes the test suite:

public function test_user_reviews_and_approves_a_resolved_profile(): void
{
    Http::fake([
        'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve*'
            => Http::response(['fixture_only' => true], 200),
    ]);

    $user = User::factory()->create();

    $this->actingAs($user)->post(route('onboarding.social.resolve'), [
        'platform' => 'linkedin',
        'reference_type' => 'url',
        'reference' => 'https://www.linkedin.com/in/example',
    ])->assertRedirect();

    $import = SocialProfileImport::sole();
    $this->assertSame('pending_review', $import->status);

    Http::assertSent(function (Request $request): bool {
        return $request->method() === 'GET'
            && $request['platform'] === 'linkedin'
            && $request['url'] === 'https://www.linkedin.com/in/example'
            && ! $request->hasHeader('Authorization');
    });

    $this->actingAs($user)->post(
        route('onboarding.social.decide', $import),
        ['decision' => 'approve'],
    )->assertRedirect(route('dashboard'));

    $this->assertSame('approved', $import->fresh()->status);
    $this->assertCount(1, $user->fresh()->public_social_profiles);
}

public function test_rate_limit_is_not_retried(): void
{
    Http::fake([
        'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve*'
            => Http::response([], 429, ['Retry-After' => '60']),
    ]);

    $user = User::factory()->create();

    $this->actingAs($user)->post(route('onboarding.social.resolve'), [
        'platform' => 'facebook',
        'reference_type' => 'username',
        'reference' => 'example',
    ])->assertSessionHasErrors('social_profile');

    Http::assertSentCount(1);
}

fixture_only is intentionally synthetic and does not claim to be a real response field. The test demonstrates that the boundary accepts an undocumented object without coupling the application to imagined schema details. Add tests for malformed JSON shapes, upstream 500 retries, cross-user review attempts, duplicate decisions, and each validation rule.

Security, observability, and operations

Public data is still personal data. Define retention, deletion, and access rules for both the original reference and normalized payload. Restrict review routes to the owning user, escape every displayed value, keep CSRF protection enabled, and never log the reference or returned identity object. Logs need the failure category, status, duration, and generated request ID—not profile contents.

Alert on sustained increases in unavailable, upstream_error, malformed_response, and rate_limited. A health check should verify your application and database without repeatedly calling the external service. Track latency separately so a slow provider does not masquerade as slow controller or database code.

Before deployment, run:

php artisan test
php artisan migrate --force
php artisan config:cache

Confirm that production can make outbound TLS connections to the documented host and that its CA bundle is current. Deploy the code that tolerates a nullable public_social_profiles column before relying on it. No queue worker changes are required.

Common failures

  • Every request fails validation: verify that the selected reference type becomes the actual query-parameter name.
  • Unexpected 401 or 403 responses: do not add guessed credentials; check the current official documentation and deployed endpoint configuration.
  • Repeated 429 responses: confirm that no application layer retries them and guide users to retry later.
  • Configuration changes have no effect: rebuild Laravel’s configuration cache after changing .env.
  • Profiles attach twice: keep the row lock and reject any decision once the status is no longer pending_review.

Final verification checklist

  • The application sends an exact GET request to the documented resolver endpoint.
  • Only platform and one supported reference parameter are sent.
  • No token, API key, or authorization header is used.
  • Timeouts and selective, bounded retries are active.
  • Rate limits and malformed responses become structured failures.
  • The normalized object remains pending until an authenticated owner reviews it.
  • Approve and reject operations are transactional and cannot be repeated.
  • Tests fake every external call and cover both success and failure.
  • Logs contain operational metadata but no profile contents.

The durable lesson is broader than social onboarding: resolving an identity is not the same as authorizing a decision. A fixed API boundary, defensive mapping, and explicit human review turn a convenient lookup into a feature users can understand, operators can diagnose, and developers can safely evolve.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.