Туториали

Laravel Identity Resolver: Normalize Social Links for Your Community Directory

Laravel Identity Resolver: Нормализирајте ги социјалните врски за вашиот директориум на заедницата

A community directory looks tidy until members paste the same social identity in half a dozen forms: mobile Facebook links, Instagram URLs with query strings, or regional LinkedIn addresses. Comparing those strings directly produces duplicates, brittle search, and awkward moderation.

The right boundary is not another pile of regular expressions. This tutorial builds a Laravel integration that accepts Facebook, Instagram, and LinkedIn profile links, sends them to the Identity Resolver, and stores the resulting normalized public identity as a stable JSON object. Submissions enter through a fast controller; normalization runs on Laravel’s queue with bounded timeouts, deliberate retries, structured failures, and deterministic tests.

Prerequisites

You need PHP 8.3 or newer, Composer, a Laravel application with authentication, a supported database, and a working queue backend. The example uses Laravel’s database queue to keep the project self-contained, but Redis can replace it without changing the domain code.

The application will store submitted URLs because directory members need to review what they entered. Treat those URLs as personal data: limit access, define retention rules, and never include them in operational logs.

Get access before writing integration code

Start at the Identity Resolver service and plan page, then read the official service documentation. The documentation is also the authoritative registration checkpoint and login checkpoint for this integration.

The current public endpoint requires no account token or API key. Consequently, there is no credential screen, no token to copy, and no authentication header to send before the first request. Do not invent an empty bearer token: some proxies treat an empty Authorization header differently from an absent one.

The exact operation is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. It accepts platform plus a supported username, id, identifier, profile, or url parameter. Our directory accepts links, so it consistently sends platform and url.

Make a minimal test with a public profile URL you are permitted to process:

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

A successful call returns the normalized public identity response. The public contract given here does not guarantee individual JSON field names, so the application will validate that it received a non-empty JSON object and preserve that object without guessing its schema.

There is no credential to put in .env. Store the endpoint itself in environment-backed configuration, and keep authentication absent:

IDENTITY_RESOLVER_ENDPOINT=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
QUEUE_CONNECTION=database

Add the following entry to config/services.php:

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

Architecture and trade-offs

The request path should not wait on an external service. The controller validates the platform and host, creates a pending record, dispatches a job, and returns HTTP 202. The job calls a dedicated API client and changes the record to either resolved or failed.

This introduces eventual consistency: a newly submitted profile is briefly pending. In exchange, a slow resolver cannot consume all PHP workers or make the form feel broken. The database record also becomes an explicit state machine instead of leaving partially completed requests invisible.

The relevant project structure is:

  • app/Domain/Identity/ResolvedIdentity.php
  • app/Services/IdentityResolverClient.php
  • app/Jobs/ResolveSocialProfile.php
  • app/Http/Controllers/StoreCommunityProfileController.php
  • app/Models/SocialProfile.php
  • database/migrations/..._create_social_profiles_table.php
  • tests/Unit/IdentityResolverClientTest.php
  • tests/Feature/StoreCommunityProfileTest.php

Persist the normalization lifecycle

Create the model, migration, controller, and job with Artisan. If the application does not already have the database queue tables, generate those too:

php artisan make:model SocialProfile -m
php artisan make:controller StoreCommunityProfileController
php artisan make:job ResolveSocialProfile
php artisan make:queue-table
php artisan migrate

Define the profile table with app-owned fields only. The resolver response remains JSON because its undocumented internals must not leak into columns or business rules.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('social_profiles', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('platform', 20);
            $table->text('submitted_url');
            $table->json('normalized_identity')->nullable();
            $table->string('status', 20)->default('pending');
            $table->string('failure_kind', 40)->nullable();
            $table->timestamps();

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

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

In SocialProfile, allow only the fields written by this workflow and cast the identity object:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class SocialProfile extends Model
{
    protected $fillable = [
        'user_id',
        'platform',
        'submitted_url',
        'normalized_identity',
        'status',
        'failure_kind',
    ];

    protected $casts = [
        'normalized_identity' => 'array',
    ];
}

Build a defensive API boundary

The domain mapping deliberately promises very little: a known platform and the complete normalized public identity object. That avoids coupling the rest of the application to response fields not established by the service contract.

<?php

namespace App\Domain\Identity;

final readonly class ResolvedIdentity
{
    public function __construct(
        public string $platform,
        public array $publicIdentity,
    ) {}
}

Create app/Services/IdentityResolverException.php and the client. The client attempts a request at most twice. It retries connection failures, HTTP 429, and server errors, but never blindly retries other 4xx responses. Retry-After is honored when it contains seconds, with a cap to keep workers bounded.

<?php

namespace App\Services;

use App\Domain\Identity\ResolvedIdentity;
use Closure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;

final class IdentityResolverException extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status = null,
        public readonly ?int $retryAfter = null,
    ) {
        parent::__construct("Identity resolution failed: {$kind}");
    }
}

final class IdentityResolverClient
{
    private Closure $sleep;

    public function __construct(?Closure $sleep = null)
    {
        $this->sleep = $sleep ?? static fn (int $seconds) => sleep($seconds);
    }

    public function resolve(string $platform, string $url): ResolvedIdentity
    {
        $endpoint = config('services.identity_resolver.endpoint');

        for ($attempt = 1; $attempt <= 2; $attempt++) {
            try {
                $response = Http::acceptJson()
                    ->connectTimeout(2)
                    ->timeout(6)
                    ->get($endpoint, [
                        'platform' => $platform,
                        'url' => $url,
                    ]);
            } catch (ConnectionException) {
                if ($attempt < 2) {
                    ($this->sleep)(1);
                    continue;
                }

                throw new IdentityResolverException('connection');
            }

            if ($response->successful()) {
                $data = $response->json();

                if (! is_array($data) || $data === [] || array_is_list($data)) {
                    throw new IdentityResolverException(
                        'invalid_response',
                        $response->status()
                    );
                }

                return new ResolvedIdentity($platform, $data);
            }

            $status = $response->status();
            $retryAfter = $this->retryAfter($response);
            $retryable = $status === 429 || $status >= 500;

            if ($retryable && $attempt < 2) {
                ($this->sleep)(min($retryAfter ?? 1, 5));
                continue;
            }

            $kind = match (true) {
                $status === 429 => 'rate_limited',
                $status >= 500 => 'upstream',
                default => 'rejected',
            };

            throw new IdentityResolverException($kind, $status, $retryAfter);
        }

        throw new IdentityResolverException('unexpected');
    }

    private function retryAfter(Response $response): ?int
    {
        $value = $response->header('Retry-After');

        return is_string($value) && ctype_digit($value)
            ? max(1, min(300, (int) $value))
            : null;
    }
}

Validate submissions and dispatch the job

Syntax-level URL validation is insufficient. Match the URL host to the selected platform before sending it outside your system. The suffix check accepts ordinary subdomains while rejecting deceptive hosts such as instagram.com.attacker.example.

<?php

namespace App\Http\Controllers;

use App\Jobs\ResolveSocialProfile;
use App\Models\SocialProfile;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;

final class StoreCommunityProfileController extends Controller
{
    public function __invoke(Request $request): JsonResponse
    {
        $data = $request->validate([
            'platform' => ['required', Rule::in([
                'facebook', 'instagram', 'linkedin',
            ])],
            'url' => ['required', 'url:http,https', 'max:2048'],
        ]);

        $expected = [
            'facebook' => 'facebook.com',
            'instagram' => 'instagram.com',
            'linkedin' => 'linkedin.com',
        ][$data['platform']];

        $host = strtolower(parse_url($data['url'], PHP_URL_HOST) ?? '');
        $matches = $host === $expected
            || str_ends_with($host, ".{$expected}");

        if (! $matches) {
            throw ValidationException::withMessages([
                'url' => 'The URL host does not match the selected platform.',
            ]);
        }

        $profile = SocialProfile::create([
            'user_id' => $request->user()->id,
            'platform' => $data['platform'],
            'submitted_url' => $data['url'],
            'status' => 'pending',
        ]);

        ResolveSocialProfile::dispatch($profile);

        return response()->json([
            'id' => $profile->id,
            'status' => $profile->status,
        ], 202);
    }
}

Register the authenticated, rate-limited route in routes/web.php. Keeping it in the web middleware group also provides Laravel’s normal CSRF protection:

use App\Http\Controllers\StoreCommunityProfileController;
use Illuminate\Support\Facades\Route;

Route::post('/directory/profiles', StoreCommunityProfileController::class)
    ->middleware(['auth', 'throttle:20,1']);

The job applies longer queue-level backoff after the client’s small immediate retry window. It logs identifiers and failure categories, not submitted URLs or response bodies.

<?php

namespace App\Jobs;

use App\Models\SocialProfile;
use App\Services\IdentityResolverClient;
use App\Services\IdentityResolverException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;

final class ResolveSocialProfile implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public bool $deleteWhenMissingModels = true;

    public function __construct(public SocialProfile $profile) {}

    public function handle(IdentityResolverClient $client): void
    {
        try {
            $identity = $client->resolve(
                $this->profile->platform,
                $this->profile->submitted_url,
            );

            $this->profile->update([
                'normalized_identity' => $identity->publicIdentity,
                'status' => 'resolved',
                'failure_kind' => null,
            ]);
        } catch (IdentityResolverException $exception) {
            Log::warning('identity_resolver.failure', [
                'profile_id' => $this->profile->id,
                'platform' => $this->profile->platform,
                'kind' => $exception->kind,
                'status' => $exception->status,
                'attempt' => $this->attempts(),
            ]);

            $retryable = in_array(
                $exception->kind,
                ['connection', 'rate_limited', 'upstream'],
                true
            );

            if ($retryable && $this->attempts() < $this->tries) {
                $this->release($exception->retryAfter ?? 60);
                return;
            }

            $this->profile->update([
                'status' => 'failed',
                'failure_kind' => $exception->kind,
            ]);
        }
    }
}

Test success, retries, and request dispatch

Http::fake() prevents tests from depending on the public service. The response fixture uses an explicit test sentinel rather than pretending it is an official response field.

<?php

namespace Tests\Unit;

use App\Services\IdentityResolverClient;
use App\Services\IdentityResolverException;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class IdentityResolverClientTest extends TestCase
{
    public function test_it_retries_a_server_error_then_maps_json(): void
    {
        config(['services.identity_resolver.endpoint' => 'https://resolver.test']);

        Http::preventStrayRequests();
        Http::fakeSequence()
            ->pushStatus(503)
            ->push(['test_sentinel' => true], 200);

        $client = new IdentityResolverClient(
            static function (int $seconds): void {}
        );

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

        $this->assertSame('instagram', $result->platform);
        $this->assertSame(
            ['test_sentinel' => true],
            $result->publicIdentity
        );
        Http::assertSentCount(2);
    }

    public function test_it_rejects_a_json_list_as_an_invalid_response(): void
    {
        Http::preventStrayRequests();
        Http::fake(['*' => Http::response(['unexpected'], 200)]);

        $this->expectException(IdentityResolverException::class);

        (new IdentityResolverClient())->resolve(
            'facebook',
            'https://www.facebook.com/example'
        );
    }
}

Add a feature test to prove that the HTTP request creates pending work without contacting the resolver:

<?php

namespace Tests\Feature;

use App\Jobs\ResolveSocialProfile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

final class StoreCommunityProfileTest extends TestCase
{
    use RefreshDatabase;

    public function test_an_authenticated_member_can_submit_a_profile(): void
    {
        Queue::fake();
        $user = User::factory()->create();

        $this->actingAs($user)->postJson('/directory/profiles', [
            'platform' => 'linkedin',
            'url' => 'https://www.linkedin.com/in/example',
        ])->assertStatus(202)
          ->assertJson(['status' => 'pending']);

        $this->assertDatabaseHas('social_profiles', [
            'user_id' => $user->id,
            'platform' => 'linkedin',
            'status' => 'pending',
        ]);

        Queue::assertPushed(ResolveSocialProfile::class);
    }
}

Security, observability, and deployment

Allow only authenticated submissions, retain CSRF protection, throttle per user, and authorize directory editing separately from viewing. Never render the normalized JSON as trusted HTML. If links are displayed, escape labels and validate schemes again at presentation time.

Monitor counts and latency by outcome: resolved, rejected, rate-limited, upstream failure, invalid response, and connection failure. Alert on sustained failure ratios or a growing pending queue. Avoid logging URLs, response bodies, cookies, or future credentials.

Deploy the migration and cached configuration before restarting workers:

php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --tries=3 --timeout=30 --max-time=3600

In production, run the worker under a process supervisor so it restarts after exits and deployments. Keep the worker timeout above the client’s bounded worst-case duration and below the queue connection’s retry-after interval.

Common failure modes

  • HTTP 422 or another rejection: verify the platform spelling and ensure the request sends one supported identifier parameter. This project always uses url.
  • HTTP 429: preserve the record as pending while bounded retries remain, honor numeric Retry-After, and avoid adding more immediate retry layers.
  • HTTP 401 or 403: do not retry. The public endpoint currently needs no token, so first check whether an unnecessary authorization header or gateway rule was introduced.
  • Pending records never change: confirm the queue worker is running with the same environment and cached configuration as the web process.
  • Invalid response failures: inspect service health privately, but do not weaken validation or log public identity payloads wholesale.

Final verification checklist

  1. Confirm the official documentation still states that the endpoint requires no token.
  2. Run the minimal GET request with each supported platform.
  3. Submit Facebook, Instagram, and LinkedIn URLs through the authenticated route.
  4. Verify each record progresses from pending to resolved.
  5. Verify malformed hosts are rejected before dispatch.
  6. Run the test suite with php artisan test.
  7. Simulate 429 and 5xx responses and confirm retries remain bounded.
  8. Check that logs contain profile IDs and failure categories, never submitted URLs or response bodies.

The lasting design lesson is simple: normalization belongs at a deliberate boundary. Once raw social links enter as untrusted submissions, pass through a narrowly defined resolver client, and emerge as validated identity objects with observable states, the directory stops being a collection of fragile strings and becomes dependable application data.

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

Mihajlo

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