Tutorials

Laravel: Unify Social Links with AI Identity Resolver for Community Directories

Laravel: Unify Social Links with AI Identity Resolver for Community Directories

A community directory rarely receives clean social data. One member pastes a full Facebook URL, another submits an Instagram handle disguised as a link, and a third copies a LinkedIn profile URL with tracking parameters. If those values go straight into your database, duplicate detection, profile rendering, and future migrations all become harder.

This tutorial builds a production Laravel feature that accepts Facebook, Instagram, and LinkedIn profile links, sends each reference to the Identity Resolver, and stores the resulting public identity in a stable application-level structure. Resolution runs in a queue, failures remain visible instead of disappearing, and tests never contact the real service.

Get access and make the first request

Start with the official Identity Resolver documentation. The endpoint is currently public: it requires no account, bearer token, or API key.

  1. Review the service and plan page to confirm that Identity Resolver supports the platforms your directory accepts.
  2. Check the registration requirements. Registration is not part of the current public-endpoint flow.
  3. Check the same official documentation for login requirements. You do not need to log in before testing.
  4. Do not look for a token-copy screen: there is currently no token or API key to copy. If authentication is introduced later, follow the documentation rather than guessing an authorization header.

The exact integration is an HTTP GET request to https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Send platform together with a supported username, id, identifier, profile, or url parameter. This project uses url because that is what directory members submit.

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

Run that request before writing application code. A successful response should be JSON representing the normalized public identity. The documented contract does not justify coupling the application to speculative field names, so the boundary below validates the response as a JSON object and preserves its contents.

There is no credential to put in .env. Store the endpoint and operational settings there instead; adding a fictional token would create a misleading security dependency.

IDENTITY_RESOLVER_URL=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
IDENTITY_RESOLVER_CONNECT_TIMEOUT=3
IDENTITY_RESOLVER_TIMEOUT=8
IDENTITY_RESOLVER_ATTEMPTS=3
IDENTITY_RESOLVER_RETRY_BASE_MS=500

Prerequisites and project shape

You need PHP 8.3 or newer, Composer, a supported Laravel application, a configured database, and a queue backend. A database queue is sufficient for a small directory; Redis is useful when throughput or queue isolation warrants it.

composer create-project laravel/laravel community-directory
cd community-directory
php artisan make:model DirectoryEntry -m
php artisan make:request StoreDirectoryEntryRequest
php artisan make:controller DirectoryEntryController
php artisan make:job ResolveDirectoryIdentities
php artisan make:test IdentityResolverTest
php artisan queue:table
php artisan migrate

The relevant application structure is deliberately small:

  • app/Services/IdentityResolver.php owns the remote HTTP boundary.
  • app/Data/ResolvedIdentity.php defines the directory’s stable domain representation.
  • app/Jobs/ResolveDirectoryIdentities.php performs potentially slow resolution.
  • app/Http/Requests/StoreDirectoryEntryRequest.php constrains user input.
  • app/Http/Controllers/DirectoryEntryController.php creates entries and dispatches work.

Asynchronous resolution keeps three external calls out of the form submission’s latency budget. The trade-off is eventual consistency: a newly created entry begins as pending. The interface should display that state rather than pretending normalization is instantaneous.

Configure Laravel without inventing authentication

Add the service to config/services.php. Laravel configuration becomes the only place that reads environment variables.

'identity_resolver' => [
    'url' => env(
        'IDENTITY_RESOLVER_URL',
        'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'
    ),
    'connect_timeout' => (int) env('IDENTITY_RESOLVER_CONNECT_TIMEOUT', 3),
    'timeout' => (int) env('IDENTITY_RESOLVER_TIMEOUT', 8),
    'attempts' => (int) env('IDENTITY_RESOLVER_ATTEMPTS', 3),
    'retry_base_ms' => (int) env('IDENTITY_RESOLVER_RETRY_BASE_MS', 500),
],

Do not add an empty authorization header. “No token required” is an authentication contract, not an invitation to send placeholders.

Persist inputs, results, and structured failures

The directory should retain the submitted links for auditing and reprocessing, while normalized identities and failures live in separate JSON columns. Create the migration as follows:

<?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('directory_entries', function (Blueprint $table): void {
            $table->id();
            $table->string('name');
            $table->json('social_links');
            $table->json('resolved_identities')->nullable();
            $table->json('resolution_failures')->nullable();
            $table->string('resolution_status')->default('pending');
            $table->timestamps();
        });
    }

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

In DirectoryEntry, make these attributes fillable and cast the three JSON columns to array. Keeping the provider response intact avoids silently discarding future documented fields.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class DirectoryEntry extends Model
{
    protected $fillable = [
        'name',
        'social_links',
        'resolved_identities',
        'resolution_failures',
        'resolution_status',
    ];

    protected function casts(): array
    {
        return [
            'social_links' => 'array',
            'resolved_identities' => 'array',
            'resolution_failures' => 'array',
        ];
    }
}

Validate platforms and hosts before resolution

An allowlist prevents typos, unexpected platforms, non-HTTPS URLs, and misleading hostnames. It also protects the integrity of the directory even though Laravel itself is not fetching the submitted URL.

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;

final class StoreDirectoryEntryRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // Replace with the directory's real authorization policy.
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:120'],
            'profiles' => ['required', 'array', 'min:1', 'max:3'],
            'profiles.*.platform' => [
                'required',
                'distinct',
                Rule::in(['facebook', 'instagram', 'linkedin']),
            ],
            'profiles.*.url' => ['required', 'url:https', 'max:2048'],
        ];
    }

    public function after(): array
    {
        return [
            function (Validator $validator): void {
                $domains = [
                    'facebook' => 'facebook.com',
                    'instagram' => 'instagram.com',
                    'linkedin' => 'linkedin.com',
                ];

                foreach ($this->input('profiles', []) as $index => $profile) {
                    $platform = $profile['platform'] ?? '';
                    $host = Str::lower(parse_url($profile['url'] ?? '', PHP_URL_HOST) ?: '');
                    $domain = $domains[$platform] ?? null;

                    if ($domain === null ||
                        ($host !== $domain && ! Str::endsWith($host, '.'.$domain))) {
                        $validator->errors()->add(
                            "profiles.$index.url",
                            'The URL host does not match the selected platform.'
                        );
                    }
                }
            },
        ];
    }
}

Build a defensive API boundary

The domain object adds fields owned by our application while treating the service payload as opaque public identity data.

<?php

namespace App\Data;

use Carbon\CarbonImmutable;

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

    public function toArray(): array
    {
        return [
            'platform' => $this->platform,
            'public_identity' => $this->publicIdentity,
            'resolved_at' => $this->resolvedAt->toIso8601String(),
        ];
    }
}

The service retries only connection failures, HTTP 429 responses, and server errors. Validation and authentication failures are terminal because repeating the same request will not repair them. Response bodies and submitted URLs are deliberately excluded from logs.

<?php

namespace App\Services;

use App\Data\ResolvedIdentity;
use Carbon\CarbonImmutable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use JsonException;
use RuntimeException;

final class IdentityResolutionException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly ?int $statusCode = null,
        public readonly bool $retryable = false,
    ) {
        parent::__construct($message);
    }
}

final class IdentityResolver
{
    public function resolve(string $platform, string $url): ResolvedIdentity
    {
        if (! in_array($platform, ['facebook', 'instagram', 'linkedin'], true)) {
            throw new IdentityResolutionException('Unsupported platform.');
        }

        $attempts = max(1, (int) config('services.identity_resolver.attempts'));

        for ($attempt = 1; $attempt <= $attempts; $attempt++) {
            try {
                $response = Http::acceptJson()
                    ->connectTimeout(config('services.identity_resolver.connect_timeout'))
                    ->timeout(config('services.identity_resolver.timeout'))
                    ->get(config('services.identity_resolver.url'), [
                        'platform' => $platform,
                        'url' => $url,
                    ]);
            } catch (ConnectionException $exception) {
                $failure = new IdentityResolutionException(
                    'Identity Resolver connection failed.',
                    null,
                    true
                );

                if ($attempt === $attempts) {
                    throw $failure;
                }

                $this->pause($attempt, null);
                continue;
            }

            if ($response->successful()) {
                try {
                    $payload = json_decode(
                        $response->body(),
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );
                } catch (JsonException $exception) {
                    throw new IdentityResolutionException(
                        'Identity Resolver returned invalid JSON.'
                    );
                }

                if (! is_array($payload) || array_is_list($payload)) {
                    throw new IdentityResolutionException(
                        'Identity Resolver returned an unexpected JSON shape.'
                    );
                }

                return new ResolvedIdentity(
                    $platform,
                    $payload,
                    CarbonImmutable::now()
                );
            }

            $status = $response->status();
            $retryable = $status === 429 || $status >= 500;
            $failure = new IdentityResolutionException(
                'Identity Resolver request failed.',
                $status,
                $retryable
            );

            if (! $retryable || $attempt === $attempts) {
                throw $failure;
            }

            Log::warning('Identity resolution will be retried.', [
                'platform' => $platform,
                'status' => $status,
                'attempt' => $attempt,
            ]);

            $this->pause($attempt, $response->header('Retry-After'));
        }

        throw new IdentityResolutionException('Identity resolution failed.');
    }

    private function pause(int $attempt, ?string $retryAfter): void
    {
        $milliseconds = ctype_digit((string) $retryAfter)
            ? min(5000, (int) $retryAfter * 1000)
            : min(
                5000,
                (int) config('services.identity_resolver.retry_base_ms')
                    * (2 ** ($attempt - 1))
            );

        usleep($milliseconds * 1000);
    }
}

Resolve profiles in a queue job

Each platform receives an independent outcome. One unavailable profile should produce partial, not erase successful identities from the other platforms.

<?php

namespace App\Jobs;

use App\Models\DirectoryEntry;
use App\Services\IdentityResolutionException;
use App\Services\IdentityResolver;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;

final class ResolveDirectoryIdentities implements ShouldQueue
{
    use Queueable;

    public int $tries = 1;
    public int $timeout = 120;

    public function __construct(public readonly int $entryId) {}

    public function handle(IdentityResolver $resolver): void
    {
        $entry = DirectoryEntry::findOrFail($this->entryId);
        $resolved = [];
        $failures = [];

        foreach ($entry->social_links as $platform => $url) {
            try {
                $resolved[$platform] = $resolver
                    ->resolve($platform, $url)
                    ->toArray();
            } catch (IdentityResolutionException $exception) {
                $failures[$platform] = [
                    'status_code' => $exception->statusCode,
                    'retryable' => $exception->retryable,
                ];
            }
        }

        $status = $failures === []
            ? 'complete'
            : ($resolved === [] ? 'failed' : 'partial');

        $entry->update([
            'resolved_identities' => $resolved ?: null,
            'resolution_failures' => $failures ?: null,
            'resolution_status' => $status,
        ]);

        Log::info('Directory identity resolution finished.', [
            'entry_id' => $entry->id,
            'status' => $status,
            'resolved_count' => count($resolved),
            'failure_count' => count($failures),
        ]);
    }
}

The controller converts the validated profile list into a platform-keyed map and returns 202 Accepted because processing continues asynchronously.

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreDirectoryEntryRequest;
use App\Jobs\ResolveDirectoryIdentities;
use App\Models\DirectoryEntry;
use Illuminate\Http\JsonResponse;

final class DirectoryEntryController extends Controller
{
    public function store(StoreDirectoryEntryRequest $request): JsonResponse
    {
        $profiles = collect($request->validated('profiles'))
            ->mapWithKeys(fn (array $profile): array => [
                $profile['platform'] => $profile['url'],
            ])
            ->all();

        $entry = DirectoryEntry::create([
            'name' => $request->validated('name'),
            'social_links' => $profiles,
            'resolution_status' => 'pending',
        ]);

        ResolveDirectoryIdentities::dispatch($entry->id);

        return response()->json([
            'id' => $entry->id,
            'resolution_status' => $entry->resolution_status,
        ], 202);
    }
}
use App\Http\Controllers\DirectoryEntryController;
use Illuminate\Support\Facades\Route;

Route::post('/directory-entries', [DirectoryEntryController::class, 'store'])
    ->middleware('throttle:directory-submissions');

Test without touching the public endpoint

Http::fake() makes success, rate limiting, and terminal failure deterministic. The fixture shape is intentionally application-controlled; the test verifies that the boundary preserves JSON rather than relying on undocumented provider fields.

<?php

namespace Tests\Feature;

use App\Services\IdentityResolutionException;
use App\Services\IdentityResolver;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class IdentityResolverTest extends TestCase
{
    public function test_it_sends_the_documented_query_and_maps_json(): void
    {
        $fixture = ['fixture_identity' => ['value' => 'stable-reference']];

        Http::fake([
            config('services.identity_resolver.url').'*' =>
                Http::response($fixture, 200),
        ]);

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

        $this->assertSame($fixture, $result->publicIdentity);

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

    public function test_it_retries_a_rate_limit_then_succeeds(): void
    {
        config(['services.identity_resolver.retry_base_ms' => 0]);

        Http::fakeSequence()
            ->push([], 429)
            ->push(['fixture_identity' => []], 200);

        app(IdentityResolver::class)->resolve(
            'facebook',
            'https://www.facebook.com/example'
        );

        Http::assertSentCount(2);
    }

    public function test_it_does_not_retry_validation_failures(): void
    {
        Http::fake(fn () => Http::response([], 422));

        try {
            app(IdentityResolver::class)->resolve(
                'linkedin',
                'https://www.linkedin.com/in/example'
            );
            $this->fail('Expected identity resolution to fail.');
        } catch (IdentityResolutionException $exception) {
            $this->assertFalse($exception->retryable);
            $this->assertSame(422, $exception->statusCode);
        }

        Http::assertSentCount(1);
    }
}
php artisan test --filter=IdentityResolverTest

Security, observability, and deployment

Protect the submission route with your application’s real authorization policy, CSRF protection where applicable, and a named rate limiter. Render stored values with normal escaping. Treat public profiles as personal data nonetheless: restrict database access, define retention rules, and never log submitted URLs or response bodies.

Monitor counts of complete, partial, and failed entries, plus remote status codes and queue age. Alerts should focus on sustained failure patterns rather than a single invalid profile.

During deployment, run migrations before workers process the new job, cache configuration only after the environment values are present, and restart workers so they load the new code:

php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --timeout=125 --tries=1

Set the queue connection’s retry_after above the worker timeout, such as 150 seconds, so another worker does not pick up the same job while it is still running.

Common failures

  • HTTP 400 or 422: the platform or submitted reference is invalid. Fix the input; do not retry it.
  • HTTP 401 or 403: stop retrying and re-check the official documentation. Do not assume a token format.
  • HTTP 429: respect a numeric Retry-After value within a bounded delay and reduce submission pressure.
  • HTTP 5xx or connection failure: retry briefly with backoff, then preserve a structured failure for later reprocessing.
  • Invalid JSON: treat the response as a contract failure and retain the original submitted link.
  • Entries remain pending: confirm that a queue worker is running and watching the configured connection.

Final verification checklist

  • The test request uses GET and the exact documented endpoint.
  • No account token, API key, or authorization header is configured.
  • Only HTTPS Facebook, Instagram, and LinkedIn hosts pass validation.
  • Connection and response timeouts are bounded.
  • Only connection failures, rate limits, and server failures are retried.
  • Provider JSON is validated at the boundary without assumed fields.
  • Partial success is stored instead of discarded.
  • Tests use Http::fake() and make no external requests.
  • Logs contain operational metadata, not submitted links or response bodies.
  • The queue timeout remains below the connection’s retry_after.

The important result is not merely prettier social links. The directory now has an explicit boundary between messy human input, a normalized public identity, and the data your application owns. That boundary is where reliable integrations earn their keep: it absorbs change, makes failure inspectable, and lets the rest of the product work with identities instead of URL-shaped guesses.

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.