Laravel Creator Manager: Auto-Generate Profile Cards from Social Links with AI Identity Resolver
A creator contact manager quickly becomes messy when the same person arrives as an Instagram URL, a LinkedIn profile reference, and a Facebook identifier. If each form is stored literally, profile cards become inconsistent, duplicate detection becomes unreliable, and every interface needs platform-specific parsing rules.
The better boundary is a normalized identity. In this tutorial, we will build a Laravel application that accepts supported social references, resolves them through the Identity Resolver service, and persists a stable, presentation-ready profile card. Resolution runs in the queue, failures remain visible, and uncertain upstream data is validated before it reaches the domain model.
Get access before writing integration code
Start with the Identity Resolver service page, then read the official documentation. The current public endpoint requires no account token or API key.
- Review the supported platforms and reference formats on the service page.
- Open the official documentation and confirm the current public-access contract.
- Because the endpoint is public, registration instructions and login requirements do not add an authentication step.
- Do not invent or copy a bearer token. There is currently no credential field to populate.
- If authentication is introduced later, follow the documentation then and keep the resulting credential in environment-backed configuration, never in PHP source or logs.
The exact request is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Send platform plus one supported username, id, identifier, profile, or url parameter.
Make a minimal test with a public reference you are authorized to process:
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"
Inspect the actual response against the official documentation rather than assuming optional fields always exist. Before building the feature, store the endpoint base URL in .env. There is deliberately no token variable:
IDENTITY_RESOLVER_BASE_URL=https://ai.mihajlo.mk/api/identity-resolver
QUEUE_CONNECTION=database
Architecture and trade-offs
The application accepts a platform, a reference type, and its value. It immediately creates a pending contact record, then dispatches a queue job. The job calls the resolver, maps the response into a small domain object, and updates the card.
Background execution is worthwhile here because social resolution is not required to acknowledge a contact submission. It also prevents a slow upstream response or backoff delay from occupying a web request. The trade-off is eventual consistency: clients must display a pending state and refresh the card later.
The relevant project structure is intentionally small:
app/
Domain/Identity/ResolvedIdentity.php
Exceptions/IdentityResolutionException.php
Http/Controllers/CreatorProfileController.php
Jobs/ResolveCreatorIdentity.php
Models/CreatorProfile.php
Services/IdentityResolver.php
config/services.php
database/migrations/..._create_creator_profiles_table.php
routes/api.php
tests/Feature/CreatorProfileTest.php
tests/Unit/IdentityResolverTest.php
Create the Laravel project components
Use PHP 8.3 or newer, a supported Laravel installation, a configured database, and a queue backend appropriate for your deployment. Inside an existing Laravel application, generate the main components:
php artisan make:model CreatorProfile -m
php artisan make:controller CreatorProfileController
php artisan make:job ResolveCreatorIdentity
php artisan make:test CreatorProfileTest
php artisan make:test IdentityResolverTest --unit
php artisan queue:table
php artisan migrate
Add the service URL to config/services.php so configuration can be cached safely:
<?php
return [
// Existing services...
'identity_resolver' => [
'base_url' => env(
'IDENTITY_RESOLVER_BASE_URL',
'https://ai.mihajlo.mk/api/identity-resolver'
),
],
];
The profile table stores both the submitted reference and the normalized card. It also exposes explicit operational states instead of treating every missing identity as the same failure.
<?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('creator_profiles', function (Blueprint $table): void {
$table->id();
$table->string('platform', 32);
$table->string('reference_type', 32);
$table->text('reference');
$table->string('resolution_state', 32)->default('pending');
$table->string('stable_identifier')->nullable();
$table->string('display_name')->nullable();
$table->text('profile_url')->nullable();
$table->text('avatar_url')->nullable();
$table->text('failure_message')->nullable();
$table->timestamp('resolved_at')->nullable();
$table->timestamps();
$table->index(['platform', 'stable_identifier']);
});
}
public function down(): void
{
Schema::dropIfExists('creator_profiles');
}
};
Configure the model with deliberate mass-assignment and date casting:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class CreatorProfile extends Model
{
protected $fillable = [
'platform',
'reference_type',
'reference',
'resolution_state',
'stable_identifier',
'display_name',
'profile_url',
'avatar_url',
'failure_message',
'resolved_at',
];
protected function casts(): array
{
return ['resolved_at' => 'immutable_datetime'];
}
}
Map the API response at the boundary
An integration should not scatter array lookups through controllers and jobs. The response mapper below requires an object-shaped JSON response and defensively selects normalized values. Candidate names are boundary fallbacks, not claims that every response contains every field.
<?php
namespace App\Domain\Identity;
use UnexpectedValueException;
final readonly class ResolvedIdentity
{
public function __construct(
public string $platform,
public string $stableIdentifier,
public ?string $displayName,
public ?string $profileUrl,
public ?string $avatarUrl,
) {}
public static function fromResponse(
array $payload,
string $requestedPlatform
): self {
$data = isset($payload['data']) && is_array($payload['data'])
? $payload['data']
: $payload;
$identifier = self::firstString(
$data,
['stable_identifier', 'identifier', 'id', 'username']
);
if ($identifier === null) {
throw new UnexpectedValueException(
'Resolver response has no usable identity identifier.'
);
}
return new self(
platform: self::firstString($data, ['platform'])
?? $requestedPlatform,
stableIdentifier: $identifier,
displayName: self::firstString(
$data,
['display_name', 'name', 'username']
),
profileUrl: self::firstString(
$data,
['profile_url', 'url']
),
avatarUrl: self::firstString(
$data,
['avatar_url', 'picture_url', 'image_url']
),
);
}
private static function firstString(
array $data,
array $keys
): ?string {
foreach ($keys as $key) {
$value = $data[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
return null;
}
}
Build a bounded, retry-aware HTTP client
The service class uses Laravel’s built-in HTTP client with separate connection and total-response timeouts. It retries only connection failures, HTTP 408, HTTP 429, and server errors. Validation and other client errors fail immediately because repeating the same request cannot repair them.
<?php
namespace App\Exceptions;
use RuntimeException;
final class IdentityResolutionException extends RuntimeException
{
public function __construct(
string $message,
public readonly bool $retryable,
public readonly ?int $status = null,
) {
parent::__construct($message);
}
}
<?php
namespace App\Services;
use App\Domain\Identity\ResolvedIdentity;
use App\Exceptions\IdentityResolutionException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
use UnexpectedValueException;
final class IdentityResolver
{
private const TYPES = [
'username', 'id', 'identifier', 'profile', 'url',
];
public function resolve(
string $platform,
string $type,
string $reference
): ResolvedIdentity {
if (!in_array($type, self::TYPES, true)) {
throw new IdentityResolutionException(
'Unsupported reference type.',
false
);
}
$endpoint = rtrim(
(string) config('services.identity_resolver.base_url'),
'/'
).'/v1/resolve';
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::acceptJson()
->connectTimeout(2)
->timeout(8)
->get($endpoint, [
'platform' => $platform,
$type => $reference,
]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new IdentityResolutionException(
'Resolver connection failed.',
true,
previous: $exception
);
}
usleep(250000 * $attempt);
continue;
}
if ($response->successful()) {
try {
$payload = $response->json();
if (!is_array($payload)) {
throw new UnexpectedValueException(
'Response is not a JSON object.'
);
}
return ResolvedIdentity::fromResponse(
$payload,
$platform
);
} catch (Throwable $exception) {
throw new IdentityResolutionException(
'Resolver returned an unusable response.',
false,
$response->status()
);
}
}
$retryable = $response->status() === 408
|| $response->status() === 429
|| $response->serverError();
Log::warning('Identity resolution request failed', [
'platform' => $platform,
'reference_type' => $type,
'status' => $response->status(),
'attempt' => $attempt,
]);
if (!$retryable || $attempt === 3) {
throw new IdentityResolutionException(
'Resolver rejected or could not complete the request.',
$retryable,
$response->status()
);
}
$retryAfter = (int) $response->header('Retry-After', 0);
$delayMs = $response->status() === 429
? min(max($retryAfter, 1), 5) * 1000
: 250 * $attempt;
usleep($delayMs * 1000);
}
throw new IdentityResolutionException(
'Resolver attempts exhausted.',
true
);
}
}
The log deliberately excludes the submitted reference, response body, and personal profile fields. Status, platform, type, and attempt number are sufficient for most operational diagnosis.
Resolve identities in a queue job
The job has queue-level backoff in addition to short request-level retries. This covers a longer outage without keeping a worker blocked. Permanent failures become a rejected state; transient failures are rethrown so the queue can retry them.
<?php
namespace App\Jobs;
use App\Exceptions\IdentityResolutionException;
use App\Models\CreatorProfile;
use App\Services\IdentityResolver;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
final class ResolveCreatorIdentity implements ShouldQueue
{
use Queueable;
public int $tries = 4;
public int $timeout = 15;
public function __construct(public readonly int $profileId) {}
public function backoff(): array
{
return [10, 60, 300];
}
public function handle(IdentityResolver $resolver): void
{
$profile = CreatorProfile::find($this->profileId);
if ($profile === null || $profile->resolution_state === 'resolved') {
return;
}
try {
$identity = $resolver->resolve(
$profile->platform,
$profile->reference_type,
$profile->reference
);
$profile->update([
'resolution_state' => 'resolved',
'stable_identifier' => $identity->stableIdentifier,
'display_name' => $identity->displayName,
'profile_url' => $identity->profileUrl,
'avatar_url' => $identity->avatarUrl,
'failure_message' => null,
'resolved_at' => now(),
]);
} catch (IdentityResolutionException $exception) {
if ($exception->retryable) {
throw $exception;
}
$profile->update([
'resolution_state' => 'rejected',
'failure_message' => $exception->getMessage(),
]);
}
}
}
Expose consistent profile-card endpoints
The controller validates platforms and parameter names before dispatch. A URL is passed to the resolver as data; this application never fetches that user-supplied URL itself, avoiding an unnecessary server-side request-forgery surface.
<?php
namespace App\Http\Controllers;
use App\Jobs\ResolveCreatorIdentity;
use App\Models\CreatorProfile;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
final class CreatorProfileController extends Controller
{
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'platform' => [
'required',
Rule::in(['facebook', 'instagram', 'linkedin']),
],
'reference_type' => [
'required',
Rule::in([
'username', 'id', 'identifier', 'profile', 'url',
]),
],
'reference' => ['required', 'string', 'max:2048'],
]);
$profile = CreatorProfile::create([
...$data,
'resolution_state' => 'pending',
]);
ResolveCreatorIdentity::dispatch($profile->id);
return response()->json([
'id' => $profile->id,
'state' => 'pending',
], 202);
}
public function show(CreatorProfile $creatorProfile): JsonResponse
{
return response()->json([
'id' => $creatorProfile->id,
'state' => $creatorProfile->resolution_state,
'card' => [
'platform' => $creatorProfile->platform,
'identifier' => $creatorProfile->stable_identifier,
'display_name' => $creatorProfile->display_name,
'profile_url' => $creatorProfile->profile_url,
'avatar_url' => $creatorProfile->avatar_url,
],
'failure' => $creatorProfile->failure_message,
]);
}
}
<?php
use App\Http\Controllers\CreatorProfileController;
use Illuminate\Support\Facades\Route;
Route::post('/creator-profiles', [
CreatorProfileController::class,
'store',
]);
Route::get('/creator-profiles/{creatorProfile}', [
CreatorProfileController::class,
'show',
]);
Test without contacting the real service
Http::fake() makes response mapping and outgoing query assertions deterministic. The feature test separately verifies that the web request creates a pending record and dispatches work.
<?php
namespace Tests\Unit;
use App\Services\IdentityResolver;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class IdentityResolverTest extends TestCase
{
public function test_it_maps_a_normalized_identity(): void
{
Http::fake([
'*/v1/resolve*' => Http::response([
'data' => [
'platform' => 'instagram',
'identifier' => 'creator-42',
'display_name' => 'Example Creator',
'profile_url' => 'https://www.instagram.com/example/',
'avatar_url' => 'https://cdn.example.test/avatar.jpg',
],
]),
]);
$identity = app(IdentityResolver::class)->resolve(
'instagram',
'username',
'example'
);
$this->assertSame('creator-42', $identity->stableIdentifier);
$this->assertSame('Example Creator', $identity->displayName);
Http::assertSent(fn ($request) =>
$request['platform'] === 'instagram'
&& $request['username'] === 'example'
&& $request->hasHeader('Accept', 'application/json')
);
}
}
<?php
namespace Tests\Feature;
use App\Jobs\ResolveCreatorIdentity;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
final class CreatorProfileTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_a_pending_profile_card(): void
{
Queue::fake();
$response = $this->postJson('/api/creator-profiles', [
'platform' => 'linkedin',
'reference_type' => 'url',
'reference' => 'https://www.linkedin.com/in/example/',
]);
$response->assertAccepted()
->assertJsonPath('state', 'pending');
$this->assertDatabaseHas('creator_profiles', [
'platform' => 'linkedin',
'resolution_state' => 'pending',
]);
Queue::assertPushed(ResolveCreatorIdentity::class);
}
}
Fixtures use reserved example domains and synthetic identities. Add tests for malformed successful responses, HTTP 400, HTTP 429, server errors, and connection failures. For retry tests, keep assertions deterministic by arranging fake response sequences and avoiding dependence on wall-clock timing.
Security, observability, and deployment
Protect the application endpoints with the authentication and authorization rules appropriate to the contact manager. Add per-user rate limiting so your endpoint cannot become an unbounded proxy. Treat names, profile URLs, and avatar URLs as untrusted output: escape text in HTML and allow only expected URL schemes when rendering links or images.
Do not log full references or upstream bodies. Record a local profile ID, status code, duration, attempt, and final state. Monitor pending-record age, rejected records, queue failures, HTTP 429 responses, and resolver latency. Those signals distinguish bad input from an outage or insufficient worker capacity.
During deployment, set IDENTITY_RESOLVER_BASE_URL, configure the production queue, run php artisan migrate --force, then rebuild cached configuration with php artisan config:cache. Restart long-running queue workers after releasing code so they load the new classes and configuration. Run workers under a process supervisor and give their process-level timeout enough headroom beyond the job’s 15-second timeout.
Common failure modes
- HTTP 400 or 422: verify the platform, reference type, and submitted value. Do not retry unchanged validation failures.
- HTTP 429: honor a bounded
Retry-After, retain queue backoff, and reduce unnecessary duplicate resolution. - HTTP 500 or connection failure: allow bounded retries, then let the queue retry later.
- Successful but unfamiliar JSON: reject it at the mapper boundary and compare the real payload with the official documentation.
- Profiles remain pending: verify that a queue worker is running and inspect failed jobs and structured logs.
- Configuration appears stale: clear or rebuild Laravel’s configuration cache after changing environment values.
Final verification checklist
- The public documentation has been reviewed, and no nonexistent API key was configured.
- The application sends an exact GET request to
/api/identity-resolver/v1/resolvewithplatformand one supported reference parameter. - Connection and response timeouts are bounded.
- Only transient failures and rate limits are retried.
- Queue jobs expose pending, resolved, and rejected states.
- The API response is mapped defensively into a stable local card.
- Tests use
Http::fake()and never call the live service. - Logs omit social references, response bodies, and credentials.
- The production queue worker is supervised and restarted on deployment.
The durable lesson is not merely how to call an identity API. It is where to put uncertainty. Social platforms, input formats, and optional fields can vary, but the rest of the application should not have to care. By containing that uncertainty inside one resolver, one mapper, and one observable queue workflow, the contact manager gains consistent profile cards without becoming coupled to every shape a social link can take.