Laravel Creator Manager: Automatsko popunjavanje kartica profila razrješavanjem identiteta putem društvenih poveznica
A creator contact manager becomes messy surprisingly quickly. One person arrives as an Instagram URL, another as a LinkedIn identifier, and a third as a Facebook username copied from a spreadsheet. If those references remain unstructured strings, duplicate detection, search, and profile-card rendering all become fragile.
This tutorial builds a Laravel application that sends those references to the Identity Resolver, stores the normalized public identity, and renders every result through the same profile-card model. The important production boundary is deliberate: because the service documentation does not guarantee individual response fields here, the integration validates the response as an object without assuming undocumented names.
Get access before writing integration code
Start with the official Identity Resolver documentation. The current public endpoint requires no account token or API key. There is therefore no credential to copy, no authorization header to construct, and no secret to place in your environment before the first request.
- Read the official documentation and confirm the supported platforms and identifier form you intend to submit.
- Review the service and plan page for current service information.
- No integration registration is currently required. This registration guidance points to the authoritative documentation rather than inventing a registration URL.
- No integration login is currently required either. Use the login and access guidance to verify that the public-access contract has not changed before deployment.
- Do not create an empty bearer token or placeholder authorization header. Sending unnecessary authentication data only creates another failure mode.
The exact call is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Send platform plus exactly one supported username, id, identifier, profile, or url parameter.
Make a minimal test request with a supported test value:
curl --get \
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
--data-urlencode 'platform=instagram' \
--data-urlencode 'username=YOUR_SUPPORTED_USERNAME' \
--header 'Accept: application/json'
There is no credential to store. We will nevertheless keep the endpoint base URL in Laravel’s environment-backed configuration so staging, testing, and production do not depend on a hard-coded host.
Architecture and trade-offs
The request remains synchronous because a user creating one contact reasonably expects an immediate card. A queue would add operational complexity without improving this small interaction. For bulk imports, the same resolver service can later sit behind a queued job.
The application has four boundaries: a form request validates untrusted input, a dedicated client owns HTTP behavior, a DTO validates the remote payload, and the controller persists a card. The database retains both the submitted reference and the normalized object, allowing the UI to evolve without another network call.
A compact project structure looks like this:
app/
Data/ResolvedIdentity.php
Exceptions/IdentityResolutionException.php
Http/Controllers/ProfileCardController.php
Http/Requests/StoreProfileCardRequest.php
Models/ProfileCard.php
Services/IdentityResolver.php
config/services.php
database/migrations/..._create_profile_cards_table.php
resources/views/profile-cards/show.blade.php
routes/web.php
tests/Feature/IdentityResolverTest.php
Create and configure the Laravel project
You need PHP 8.3 or newer, Composer, a database supported by Laravel, and a current Laravel release providing the built-in HTTP client.
composer create-project laravel/laravel creator-manager
cd creator-manager
php artisan make:model ProfileCard -m
php artisan make:controller ProfileCardController
php artisan make:request StoreProfileCardRequest
php artisan make:test IdentityResolverTest
php artisan migrate
Add the base URL to .env. Do not add an API key variable while the endpoint is public:
IDENTITY_RESOLVER_URL=https://ai.mihajlo.mk/api/identity-resolver
Expose it through config/services.php, which remains safe when Laravel caches configuration during deployment:
'identity_resolver' => [
'url' => env(
'IDENTITY_RESOLVER_URL',
'https://ai.mihajlo.mk/api/identity-resolver'
),
],
Persist a stable profile-card record
The migration stores the public response as JSON instead of guessing at undocumented properties. A hash gives us a stable local fingerprint for change detection; it is not presented as an identifier supplied by the service.
<?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('profile_cards', function (Blueprint $table): void {
$table->id();
$table->string('creator_name', 120);
$table->string('platform', 20);
$table->string('reference_type', 20);
$table->text('reference_value');
$table->json('resolved_identity');
$table->char('identity_hash', 64)->index();
$table->timestamps();
$table->unique(
['platform', 'reference_type', 'identity_hash'],
'profile_cards_identity_unique'
);
});
}
public function down(): void
{
Schema::dropIfExists('profile_cards');
}
};
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class ProfileCard extends Model
{
protected $fillable = [
'creator_name',
'platform',
'reference_type',
'reference_value',
'resolved_identity',
'identity_hash',
];
protected function casts(): array
{
return ['resolved_identity' => 'array'];
}
}
Validate the domain response without inventing fields
A successful HTTP status is insufficient. Proxies and upstream failures can return HTML, JSON lists, or empty bodies. The DTO accepts only a nonempty JSON object, recursively sorts its keys, and hashes the canonical representation.
<?php
namespace App\Data;
use App\Exceptions\IdentityResolutionException;
final readonly class ResolvedIdentity
{
private function __construct(public array $attributes) {}
public static function fromResponse(mixed $payload): self
{
if (! is_array($payload) || $payload === [] || array_is_list($payload)) {
throw new IdentityResolutionException(
'invalid_response',
'Resolver returned no usable identity object.'
);
}
return new self($payload);
}
public function hash(): string
{
$canonical = $this->sortRecursively($this->attributes);
return hash(
'sha256',
json_encode($canonical, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
);
}
private function sortRecursively(array $value): array
{
if (! array_is_list($value)) {
ksort($value);
}
foreach ($value as $key => $item) {
if (is_array($item)) {
$value[$key] = $this->sortRecursively($item);
}
}
return $value;
}
}
The domain exception carries a machine-readable failure kind without exposing response bodies:
<?php
namespace App\Exceptions;
use RuntimeException;
final class IdentityResolutionException extends RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly ?int $status = null,
public readonly ?int $retryAfter = null,
) {
parent::__construct($message);
}
}
Build the bounded HTTP client
The client uses a two-second connection timeout and a six-second total timeout. It retries connection failures, rate limits, and selected transient server errors with two bounded delays. Validation failures are never retried. If the final response is rate-limited, the exception preserves a numeric Retry-After value for callers while capping it to prevent unreasonable scheduling decisions.
<?php
namespace App\Services;
use App\Data\ResolvedIdentity;
use App\Exceptions\IdentityResolutionException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class IdentityResolver
{
public function resolve(
string $platform,
string $referenceType,
string $referenceValue,
): ResolvedIdentity {
$query = [
'platform' => $platform,
$referenceType => $referenceValue,
];
try {
$response = Http::baseUrl(config('services.identity_resolver.url'))
->acceptJson()
->connectTimeout(2)
->timeout(6)
->retry(
[200, 500],
when: function (
Throwable $exception,
PendingRequest $request
): bool {
if ($exception instanceof ConnectionException) {
return true;
}
return $exception instanceof RequestException
&& in_array(
$exception->response->status(),
[429, 500, 502, 503, 504],
true
);
},
throw: false
)
->get('/v1/resolve', $query);
} catch (ConnectionException $exception) {
Log::warning('identity_resolver.connection_failed', [
'platform' => $platform,
'reference_hash' => hash('sha256', $referenceValue),
]);
throw new IdentityResolutionException(
'unavailable',
'Identity service is temporarily unavailable.'
);
}
if ($response->status() === 429) {
$header = $response->header('Retry-After');
$retryAfter = ctype_digit((string) $header)
? min((int) $header, 3600)
: null;
throw new IdentityResolutionException(
'rate_limited',
'Identity service rate limit reached.',
429,
$retryAfter
);
}
if ($response->clientError()) {
throw new IdentityResolutionException(
'rejected',
'The supplied social reference was rejected.',
$response->status()
);
}
if ($response->serverError()) {
throw new IdentityResolutionException(
'unavailable',
'Identity service is temporarily unavailable.',
$response->status()
);
}
$payload = $response->json();
return ResolvedIdentity::fromResponse($payload);
}
}
Accept exactly one social reference
The form request constrains the platform and ensures exactly one supported reference field is populated. Length limits reduce accidental abuse. URL validation is useful, but application-side validation should not claim more restrictive host support than the official service contract specifies.
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
final class StoreProfileCardRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'creator_name' => ['required', 'string', 'max:120'],
'platform' => ['required', 'in:facebook,instagram,linkedin'],
'username' => ['nullable', 'string', 'max:255'],
'id' => ['nullable', 'string', 'max:255'],
'identifier' => ['nullable', 'string', 'max:255'],
'profile' => ['nullable', 'string', 'max:500'],
'url' => ['nullable', 'url:http,https', 'max:2048'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$present = collect(
['username', 'id', 'identifier', 'profile', 'url']
)->filter(fn (string $key): bool =>
filled($this->input($key))
);
if ($present->count() !== 1) {
$validator->errors()->add(
'reference',
'Provide exactly one supported social reference.'
);
}
});
}
}
Resolve, save, and render the card
The controller catches expected integration failures and returns an actionable form error. It never logs the raw social reference or remote body.
<?php
namespace App\Http\Controllers;
use App\Exceptions\IdentityResolutionException;
use App\Http\Requests\StoreProfileCardRequest;
use App\Models\ProfileCard;
use App\Services\IdentityResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
final class ProfileCardController extends Controller
{
public function store(
StoreProfileCardRequest $request,
IdentityResolver $resolver
): RedirectResponse {
$data = $request->validated();
$types = ['username', 'id', 'identifier', 'profile', 'url'];
$type = collect($types)
->first(fn (string $key): bool => filled($data[$key] ?? null));
$value = $data[$type];
try {
$identity = $resolver->resolve($data['platform'], $type, $value);
} catch (IdentityResolutionException $exception) {
report($exception);
return back()->withInput()->withErrors([
'reference' => match ($exception->kind) {
'rate_limited' => 'Resolution is busy. Try again later.',
'rejected' => 'That social reference could not be resolved.',
default => 'Resolution is temporarily unavailable.',
},
]);
}
$card = ProfileCard::firstOrCreate(
[
'platform' => $data['platform'],
'reference_type' => $type,
'identity_hash' => $identity->hash(),
],
[
'creator_name' => $data['creator_name'],
'reference_value' => $value,
'resolved_identity' => $identity->attributes,
]
);
return redirect()->route('profile-cards.show', $card);
}
public function show(ProfileCard $profileCard): View
{
return view('profile-cards.show', ['card' => $profileCard]);
}
}
use App\Http\Controllers\ProfileCardController;
use Illuminate\Support\Facades\Route;
Route::post('/profile-cards', [ProfileCardController::class, 'store'])
->middleware('throttle:profile-card-creation')
->name('profile-cards.store');
Route::get('/profile-cards/{profileCard}', [
ProfileCardController::class,
'show',
])->name('profile-cards.show');
The Blade view can iterate over scalar response properties without asserting that any particular key exists. Blade’s escaped output protects the page from markup contained in remote values.
<article>
<h2>{{ $card->creator_name }}</h2>
<p>{{ ucfirst($card->platform) }}</p>
@foreach ($card->resolved_identity as $key => $value)
@if (is_scalar($value) && $value !== '')
<p>
<strong>{{ str($key)->headline() }}:</strong>
{{ $value }}
</p>
@endif
@endforeach
</article>
Test success, retries, and malformed responses
Http::fake() makes the suite deterministic and verifies the outbound contract without reaching the public service.
<?php
namespace Tests\Feature;
use App\Exceptions\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_supported_reference(): void
{
Http::fake([
'*/v1/resolve*' => Http::response([
'public_label' => 'Example Creator',
'canonical_reference' => 'example_creator',
]),
]);
$identity = app(IdentityResolver::class)->resolve(
'instagram',
'username',
'example_creator'
);
$this->assertSame(
'Example Creator',
$identity->attributes['public_label']
);
Http::assertSent(fn (Request $request): bool =>
$request->method() === 'GET'
&& $request['platform'] === 'instagram'
&& $request['username'] === 'example_creator'
&& ! $request->hasHeader('Authorization')
);
}
public function test_it_rejects_a_non_object_response(): void
{
Http::fake([
'*/v1/resolve*' => Http::response([], 200),
]);
$this->expectException(IdentityResolutionException::class);
app(IdentityResolver::class)->resolve(
'linkedin',
'identifier',
'example'
);
}
public function test_it_exposes_a_final_rate_limit_as_domain_failure(): void
{
Http::fake([
'*/v1/resolve*' => Http::response(
['message' => 'limited'],
429,
['Retry-After' => '30']
),
]);
try {
app(IdentityResolver::class)->resolve(
'facebook',
'id',
'12345'
);
$this->fail('Expected a rate-limit failure.');
} catch (IdentityResolutionException $exception) {
$this->assertSame('rate_limited', $exception->kind);
$this->assertSame(30, $exception->retryAfter);
}
}
}
Security, observability, and deployment
Treat social references as personal contact data even when profiles are public. Authorize card creation and viewing for the correct workspace, add CSRF protection through Laravel’s web middleware, define the named rate limiter, and apply your retention policy. Never write complete request values or response bodies to logs.
Useful structured metrics include request count, latency, terminal status, retry count, and failure kind. Hashing a reference for correlation is safer than logging it, although stable hashes can still be sensitive and should have restricted retention.
During deployment, provide IDENTITY_RESOLVER_URL, run php artisan migrate --force, then rebuild configuration with php artisan config:cache. Confirm that outbound HTTPS traffic and certificate verification work from the production runtime. Do not disable TLS verification to solve a certificate problem.
Common failures have distinct meanings: a validation error indicates multiple or unsupported inputs; a rejected response indicates the submitted reference needs correction; a rate limit calls for waiting rather than aggressive retries; a timeout or server error should produce a temporary failure; and a successful but malformed payload should be treated as an upstream contract problem, not saved as a blank card.
Final verification checklist
- The access documentation still says the endpoint is public and requires no token.
- The application sends a GET request to the exact
/v1/resolveendpoint. - Every request contains
platformand exactly one supported reference parameter. - Timeouts and retries are bounded, while validation failures are not retried.
- Unknown response fields remain data rather than becoming brittle application assumptions.
- Logs exclude raw references, response bodies, and future credentials.
- Automated tests cover success, malformed JSON structures, and rate limiting.
- A resolved identity persists and renders as a consistent, escaped profile card.
The durable lesson is larger than this one integration: normalized data is useful only when its boundary is trustworthy. By validating what is documented, preserving what is not, and making failure states explicit, a pile of inconsistent social links becomes a dependable creator directory without pretending the network is perfect.