Laravel: Изградете динамични профили на креатори со социјални врски разрешени со ВИ
A creator contact manager often starts with a deceptively simple field: “social link.” Then the real data arrives. One person supplies an Instagram username, another pastes a Facebook URL, and a third enters a LinkedIn identifier. If each form reaches your database unchanged, every profile card needs platform-specific parsing and display logic.
A cleaner boundary is to resolve those references before presenting them. In this tutorial, we will build a Laravel contact manager that accepts several supported reference types, sends them to the Identity Resolver, stores the normalized public identity object, and renders consistent profile cards without assuming an undocumented response schema.
Get access before writing integration code
Start with the official Identity Resolver service page, then read the official documentation. The current public endpoint requires no account token or API key.
That changes the usual onboarding sequence:
- Open the documentation and confirm the current request contract.
- Registration is not required for this public endpoint. The platform’s registration page is therefore not part of this integration.
- Likewise, you do not need to use the login page before making the first request.
- There is no token or API key to copy. Do not invent an authorization header or add an empty secret to the application.
The exact request is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. It accepts platform plus one supported username, id, identifier, profile, or url parameter.
Make a minimal test request before building the feature. Replace the placeholder with a supported public reference:
curl --get \
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
--data-urlencode 'platform=instagram' \
--data-urlencode 'username=SUPPORTED_USERNAME' \
--fail-with-body
No Authorization header belongs in this command. Treat the returned JSON as the authoritative normalized identity object; do not build around response fields that are absent from the official contract.
Architecture and trade-offs
The application will create a contact immediately with a pending status, then resolve it in a queued job. Background execution is useful here because an external timeout should not keep a person waiting on a form submission.
The design has four boundaries:
- A validated HTTP request accepts the platform, reference type, and reference.
- A queue job owns the contact’s transition from
pendingtoresolvedorfailed. - A dedicated client handles timeouts, retries, status codes, and JSON validation.
- The view renders scalar values from the normalized object defensively instead of depending on speculative provider fields.
This costs more moving parts than resolving inside the controller, but it provides predictable request latency and a clean place for retry, monitoring, and operational controls.
Prerequisites and project structure
You need PHP 8.3 or later, Composer, a Laravel application compatible with that PHP version, a database, and a running queue worker. The relevant files will be:
app/
Data/ResolvedIdentity.php
Exceptions/IdentityResolutionFailed.php
Http/Controllers/CreatorContactController.php
Jobs/ResolveCreatorIdentity.php
Models/CreatorContact.php
Services/IdentityResolverClient.php
config/services.php
database/migrations/..._create_creator_contacts_table.php
resources/views/creator-contacts/index.blade.php
routes/web.php
tests/Feature/IdentityResolverClientTest.php
Configure the environment
Store the service base URL in .env. There is deliberately no token variable because the public endpoint does not require one.
IDENTITY_RESOLVER_BASE_URL=https://ai.mihajlo.mk/api/identity-resolver
Add an environment-backed entry to config/services.php:
'identity_resolver' => [
'base_url' => env(
'IDENTITY_RESOLVER_BASE_URL',
'https://ai.mihajlo.mk/api/identity-resolver'
),
'connect_timeout' => 2,
'timeout' => 6,
],
Configuration gives tests and deployments one stable seam. If authentication is introduced later, add a secret only after the official documentation defines its contract.
Create the contact record
Generate the model and migration with php artisan make:model CreatorContact -m. The migration stores application-owned input separately from the opaque normalized response:
<?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_contacts', function (Blueprint $table): void {
$table->id();
$table->string('platform', 32);
$table->string('reference_type', 32);
$table->text('reference');
$table->string('status', 20)->default('pending');
$table->json('identity')->nullable();
$table->string('failure_code', 64)->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('creator_contacts');
}
};
The model needs guarded assignment and a JSON cast:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class CreatorContact extends Model
{
protected $fillable = [
'platform',
'reference_type',
'reference',
'status',
'identity',
'failure_code',
];
protected function casts(): array
{
return ['identity' => 'array'];
}
}
Run php artisan migrate after reviewing the generated migration in your deployment environment.
Build a defensive API boundary
The response contract promises a normalized public identity, but the supplied contract does not enumerate its fields. The responsible approach is to require a JSON object, preserve it, and keep display adaptation outside the transport layer.
<?php
namespace App\Data;
use InvalidArgumentException;
final readonly class ResolvedIdentity
{
public function __construct(public array $payload)
{
if ($payload === [] || array_is_list($payload)) {
throw new InvalidArgumentException(
'The resolver response must be a non-empty JSON object.'
);
}
}
}
Create a small structured exception:
<?php
namespace App\Exceptions;
use RuntimeException;
final class IdentityResolutionFailed extends RuntimeException
{
public function __construct(
public readonly string $failureCode,
public readonly bool $retryable,
string $message
) {
parent::__construct($message);
}
}
The client uses Laravel’s built-in HTTP client. It retries connection failures, rate limits, and server failures, but never retries ordinary validation failures. Backoff is bounded so an unexpected Retry-After value cannot stall a worker indefinitely.
<?php
namespace App\Services;
use App\Data\ResolvedIdentity;
use App\Exceptions\IdentityResolutionFailed;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
final class IdentityResolverClient
{
public function resolve(
string $platform,
string $referenceType,
string $reference
): ResolvedIdentity {
$query = ['platform' => $platform, $referenceType => $reference];
$started = hrtime(true);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::acceptJson()
->connectTimeout(config('services.identity_resolver.connect_timeout'))
->timeout(config('services.identity_resolver.timeout'))
->get(
rtrim(config('services.identity_resolver.base_url'), '/')
. '/v1/resolve',
$query
);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new IdentityResolutionFailed(
'connection_failed',
true,
'Identity Resolver could not be reached.'
);
}
usleep($attempt * 200_000);
continue;
}
Log::info('identity_resolver.completed', [
'platform' => $platform,
'attempt' => $attempt,
'status' => $response->status(),
'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000),
]);
if (($response->status() === 429 || $response->serverError())
&& $attempt < 3) {
$retryAfter = filter_var(
$response->header('Retry-After'),
FILTER_VALIDATE_INT
);
$delayMs = $retryAfter === false
? $attempt * 250
: min($retryAfter, 2) * 1000;
usleep($delayMs * 1000);
continue;
}
if ($response->status() === 429) {
throw new IdentityResolutionFailed(
'rate_limited',
true,
'Identity Resolver rate limit reached.'
);
}
if ($response->clientError()) {
throw new IdentityResolutionFailed(
'invalid_reference',
false,
'The platform or reference was rejected.'
);
}
if ($response->serverError()) {
throw new IdentityResolutionFailed(
'upstream_failure',
true,
'Identity Resolver returned a server error.'
);
}
try {
$payload = $response->json();
if (! is_array($payload)) {
throw new InvalidArgumentException();
}
return new ResolvedIdentity($payload);
} catch (InvalidArgumentException) {
throw new IdentityResolutionFailed(
'invalid_response',
false,
'Identity Resolver returned an invalid JSON object.'
);
}
}
throw new IdentityResolutionFailed(
'unexpected_failure',
false,
'Identity resolution ended unexpectedly.'
);
}
}
Notice what is absent from logs: the submitted URL, username, identifier, response body, and credentials. Public identity data still deserves deliberate handling.
Resolve contacts in the queue
Create the job with php artisan make:job ResolveCreatorIdentity. One job attempt is intentional: the client already performs three bounded attempts, so queue-level retries would multiply upstream traffic.
<?php
namespace App\Jobs;
use App\Exceptions\IdentityResolutionFailed;
use App\Models\CreatorContact;
use App\Services\IdentityResolverClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
final class ResolveCreatorIdentity implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 30;
public function __construct(public readonly int $contactId) {}
public function handle(IdentityResolverClient $resolver): void
{
$contact = CreatorContact::query()->findOrFail($this->contactId);
try {
$identity = $resolver->resolve(
$contact->platform,
$contact->reference_type,
$contact->reference
);
$contact->update([
'identity' => $identity->payload,
'status' => 'resolved',
'failure_code' => null,
]);
} catch (IdentityResolutionFailed $exception) {
$contact->update([
'status' => 'failed',
'failure_code' => $exception->failureCode,
]);
Log::warning('identity_resolver.failed', [
'contact_id' => $contact->id,
'platform' => $contact->platform,
'failure_code' => $exception->failureCode,
'retryable' => $exception->retryable,
]);
}
}
}
Validate input and expose the feature
The controller uses allowlists for both dynamic query-key inputs. That prevents an attacker from injecting arbitrary query parameters.
<?php
namespace App\Http\Controllers;
use App\Jobs\ResolveCreatorIdentity;
use App\Models\CreatorContact;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Illuminate\Validation\Rule;
final class CreatorContactController
{
public function index(): View
{
return view('creator-contacts.index', [
'contacts' => CreatorContact::query()->latest()->get(),
]);
}
public function store(Request $request): RedirectResponse
{
$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'],
]);
if ($data['reference_type'] === 'url') {
validator(
['reference' => $data['reference']],
['reference' => ['url:http,https']]
)->validate();
}
$contact = CreatorContact::query()->create($data);
ResolveCreatorIdentity::dispatch($contact->id);
return back()->with('status', 'Creator queued for resolution.');
}
}
Register the routes:
use App\Http\Controllers\CreatorContactController;
use Illuminate\Support\Facades\Route;
Route::get('/creator-contacts', [CreatorContactController::class, 'index'])
->name('creator-contacts.index');
Route::post('/creator-contacts', [CreatorContactController::class, 'store'])
->middleware('throttle:30,1')
->name('creator-contacts.store');
The Blade view can iterate through scalar top-level values without claiming specific response fields. Nested structures remain stored for a future adapter based on documented fields.
<form method="POST" action="{{ route('creator-contacts.store') }}">
@csrf
<select name="platform" required>
@foreach (['facebook', 'instagram', 'linkedin'] as $platform)
<option value="{{ $platform }}">{{ ucfirst($platform) }}</option>
@endforeach
</select>
<select name="reference_type" required>
@foreach (['username', 'id', 'identifier', 'profile', 'url'] as $type)
<option value="{{ $type }}">{{ ucfirst($type) }}</option>
@endforeach
</select>
<input name="reference" maxlength="2048" required>
<button type="submit">Add creator</button>
</form>
@foreach ($contacts as $contact)
<article>
<h2>{{ ucfirst($contact->platform) }} creator</h2>
<p>Status: {{ $contact->status }}</p>
@if ($contact->status === 'resolved')
<dl>
@foreach ($contact->identity as $key => $value)
@if (is_scalar($value) || $value === null)
<dt>{{ str($key)->headline() }}</dt>
<dd>{{ $value ?? 'Not provided' }}</dd>
@endif
@endforeach
</dl>
@elseif ($contact->status === 'failed')
<p>Resolution failed: {{ $contact->failure_code }}</p>
@endif
</article>
@endforeach
Blade escapes output by default. Keep the escaped {{ }} syntax even for apparently safe public data.
Test success, retries, and permanent failures
Laravel’s Http::fake() makes the boundary deterministic. These tests verify the exact method, endpoint, query shape, response mapping, retry count, and non-retry behavior.
<?php
namespace Tests\Feature;
use App\Exceptions\IdentityResolutionFailed;
use App\Services\IdentityResolverClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class IdentityResolverClientTest extends TestCase
{
public function test_it_resolves_and_preserves_the_identity_object(): void
{
Http::fake([
'*/v1/resolve*' => Http::response([
'public_identity' => ['value' => 'normalized'],
], 200),
]);
$result = app(IdentityResolverClient::class)->resolve(
'instagram',
'username',
'creator'
);
$this->assertSame(
['public_identity' => ['value' => 'normalized']],
$result->payload
);
Http::assertSent(fn (Request $request): bool =>
$request->method() === 'GET'
&& str_starts_with(
$request->url(),
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'
)
&& $request['platform'] === 'instagram'
&& $request['username'] === 'creator'
&& ! $request->hasHeader('Authorization')
);
}
public function test_it_retries_server_errors_with_a_bound(): void
{
Http::fakeSequence()
->push([], 503)
->push([], 503)
->push(['identity' => 'normalized'], 200);
app(IdentityResolverClient::class)->resolve(
'linkedin',
'identifier',
'creator-id'
);
Http::assertSentCount(3);
}
public function test_it_does_not_retry_validation_errors(): void
{
Http::fake(['*' => Http::response([], 422)]);
try {
app(IdentityResolverClient::class)->resolve(
'facebook',
'profile',
'unsupported'
);
$this->fail('Expected resolution to fail.');
} catch (IdentityResolutionFailed $exception) {
$this->assertSame('invalid_reference', $exception->failureCode);
}
Http::assertSentCount(1);
}
}
Run the suite with php artisan test. Add a job test with Queue::fake() if dispatch behavior is part of your controller’s acceptance criteria.
Security, operations, and deployment
Rate-limit the form, retain CSRF protection, and require authentication if contacts are private business data. Validate URL schemes so values such as javascript: never enter a future clickable-link renderer. If you later render returned URLs, allow only expected HTTP and HTTPS destinations at that boundary.
For observability, alert on sustained increases in identity_resolver.failed, grouped by failure code. Track queue age and failed-job volume separately. A healthy API cannot compensate for a stopped worker.
During deployment:
- Set
IDENTITY_RESOLVER_BASE_URLin the runtime environment. - Run
php artisan config:cacheandphp artisan migrate --force. - Restart long-running queue workers with
php artisan queue:restart. - Run workers under a process supervisor using
php artisan queue:work --sleep=1 --timeout=30. - Submit one supported reference and confirm that its state moves from
pendingtoresolved.
Common failure modes
- A contact remains pending when no queue worker is consuming the configured queue.
- A 4xx response usually indicates an unsupported platform, reference type, or value; retrying unchanged input only creates noise.
- A 429 response means the caller should reduce traffic. The client retries briefly, then records a structured failure.
- Repeated 5xx or connection failures indicate an upstream or network problem, not bad creator data.
- An invalid JSON object becomes
invalid_responseinstead of leaking malformed data deeper into the application. - After changing environment values, stale cached configuration can preserve the previous base URL until the configuration cache is rebuilt.
Final verification checklist
- The application sends an unauthenticated GET request to the exact
/v1/resolveendpoint. - Every request contains
platformand exactly one allowlisted reference parameter. - Connection and total timeouts are bounded.
- Only connection failures, 429 responses, and server errors are retried.
- Normalized JSON is validated at the boundary and stored separately from submitted input.
- Logs contain operational context but not raw references or response bodies.
- Tests use
Http::fake()and never call the live service. - The queue worker is supervised, monitored, and restarted during deployment.
The deeper lesson is not merely how to call an identity API. It is how to prevent platform-specific social data from spreading through an application. Once resolution, failure handling, and schema validation live at a deliberate boundary, the rest of the contact manager can think in terms of creators and consistent cards rather than a growing collection of Facebook, Instagram, and LinkedIn exceptions.