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.
- Review the service and plan page to confirm that Identity Resolver supports the platforms your directory accepts.
- Check the registration requirements. Registration is not part of the current public-endpoint flow.
- Check the same official documentation for login requirements. You do not need to log in before testing.
- 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.phpowns the remote HTTP boundary.app/Data/ResolvedIdentity.phpdefines the directory’s stable domain representation.app/Jobs/ResolveDirectoryIdentities.phpperforms potentially slow resolution.app/Http/Requests/StoreDirectoryEntryRequest.phpconstrains user input.app/Http/Controllers/DirectoryEntryController.phpcreates 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-Aftervalue 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
GETand 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.