Build Creator Profiles: Native PHP Integrates Social Links with AI Identity Resolver
A creator contact manager usually starts with a harmless-looking field called “social link.” Before long, that field contains full URLs, usernames, copied profile paths, numeric identifiers, and several spellings of the same platform. The interface then leaks that inconsistency into search results, exports, and profile cards.
This tutorial builds a Native PHP 8.3 integration that sends those references to the Identity Resolver and converts the normalized response into one predictable profile-card model. The boundary remains deliberately strict: the resolver owns public identity normalization, while our application owns contact IDs, presentation, storage, and failure policy.
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.
Consequently, registration and login are not steps in this onboarding flow. Use the official documentation to verify the current registration status and login status instead of guessing undocumented account URLs. There is no credential screen and nothing to copy. If authentication is introduced later, treat the documentation as the source of truth before changing production configuration.
The exact request is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Send platform plus exactly one supported username, id, identifier, profile, or url parameter. The supported platforms are Facebook, Instagram, and LinkedIn.
Make the first request with a disposable test reference:
curl --fail-with-body --get \
--connect-timeout 3 \
--max-time 10 \
--data-urlencode "platform=instagram" \
--data-urlencode "username=YOUR_USERNAME" \
"https://ai.mihajlo.mk/api/identity-resolver/v1/resolve"
No Authorization header belongs in that request. Do not interpret “public” as “unlimited,” however: clients still need bounded timeouts, conservative retries, and explicit rate-limit handling.
Native PHP does not automatically load .env files. For local development we will source one through the shell; production should inject the same variables through its process manager or secret-management system. The empty token entry records the present authentication contract and is never transmitted:
# .env
IDENTITY_RESOLVER_URL=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
IDENTITY_RESOLVER_TOKEN=
APP_ENV=development
# Install the only third-party development dependency.
composer require --dev phpunit/phpunit:^11.0
# Load local variables, then start the application.
set -a
. ./.env
set +a
php -S 127.0.0.1:8080 -t public
Architecture: keep the uncertain data at the boundary
The service promises a normalized public identity object, but this tutorial does not assume undocumented field names such as a display name, avatar, or canonical URL. Instead, the boundary verifies that the response is a JSON object and preserves it under identity. A later presentation layer may map documented fields without coupling transport code to guesses.
The contact manager’s card contract is stable regardless of platform:
contact_idis the application-owned record key.platformandsourcedescribe the submitted reference.identitycontains the resolver’s normalized public object.resolved_atrecords freshness without pretending to be identity data.
A small synchronous request is appropriate when a user explicitly adds or refreshes one contact. Bulk imports should call the same service class from a worker so a slow provider cannot occupy every web process.
Use this structure:
creator-contacts/
├── composer.json
├── public/
│ └── index.php
├── src/
│ ├── HttpTransport.php
│ ├── CurlTransport.php
│ ├── IdentityResolverClient.php
│ └── ProfileCard.php
└── tests/
└── IdentityResolverClientTest.php
Configure Composer autoloading with "CreatorContacts\\": "src/" and "CreatorContacts\\Tests\\": "tests/", then run composer dump-autoload.
Build a bounded cURL transport and resolver client
The transport exposes responses rather than throwing on HTTP status codes. That lets the client distinguish a retryable provider failure from a permanent invalid request.
<?php
// src/HttpTransport.php
namespace CreatorContacts;
interface HttpTransport
{
public function get(string $url): TransportResponse;
}
final readonly class TransportResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
// src/CurlTransport.php
namespace CreatorContacts;
use RuntimeException;
final class CurlTransport implements HttpTransport
{
public function get(string $url): TransportResponse
{
$headers = [];
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 3000,
CURLOPT_TIMEOUT_MS => 10000,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$headers): int {
$length = strlen($line);
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return $length;
},
]);
$body = curl_exec($handle);
if ($body === false) {
$message = curl_error($handle);
curl_close($handle);
throw new RuntimeException('Identity Resolver transport error: ' . $message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new TransportResponse($status, $headers, $body);
}
}
The client validates locally, URL-encodes the query, retries only transient outcomes, and emits metadata rather than personal references or response bodies. Its sleeper is injectable, making retry tests instant and deterministic.
<?php
// src/IdentityResolverClient.php
namespace CreatorContacts;
use JsonException;
use RuntimeException;
final class ResolverException extends RuntimeException
{
public function __construct(public readonly string $category, string $message)
{
parent::__construct($message);
}
}
final class IdentityResolverClient
{
private const PLATFORMS = ['facebook', 'instagram', 'linkedin'];
private const REFERENCES = ['username', 'id', 'identifier', 'profile', 'url'];
public function __construct(
private readonly HttpTransport $transport,
private readonly string $endpoint,
private readonly mixed $sleeper = null,
private readonly mixed $logger = null,
) {}
public function resolve(string $platform, string $kind, string $value): array
{
$platform = strtolower(trim($platform));
$value = trim($value);
if (!in_array($platform, self::PLATFORMS, true)
|| !in_array($kind, self::REFERENCES, true)
|| $value === '') {
throw new ResolverException('validation', 'Unsupported or empty identity reference.');
}
if ($kind === 'url') {
$scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
if (!in_array($scheme, ['http', 'https'], true)) {
throw new ResolverException('validation', 'Profile URL must use HTTP or HTTPS.');
}
}
$url = $this->endpoint . '?' . http_build_query(
['platform' => $platform, $kind => $value],
'',
'&',
PHP_QUERY_RFC3986,
);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->get($url);
} catch (RuntimeException $error) {
$this->log($platform, $attempt, null, 'transport_failure');
if ($attempt === 3) {
throw new ResolverException('unavailable', 'Resolver transport failed.');
}
$this->pause($attempt, null);
continue;
}
$this->log($platform, $attempt, $response->status, 'response');
if ($response->status === 200) {
try {
$object = json_decode($response->body, false, 512, JSON_THROW_ON_ERROR);
$identity = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
throw new ResolverException('malformed_response', 'Resolver returned invalid JSON.');
}
if (!is_object($object) || !is_array($identity)) {
throw new ResolverException(
'malformed_response',
'Resolver response must be a JSON object.',
);
}
return $identity;
}
$retryable = $response->status === 429
|| in_array($response->status, [502, 503, 504], true);
if ($retryable && $attempt < 3) {
$this->pause($attempt, $response->headers['retry-after'] ?? null);
continue;
}
$category = match (true) {
$response->status === 429 => 'rate_limited',
$response->status >= 500 => 'unavailable',
default => 'rejected',
};
throw new ResolverException($category, 'Resolver request was not accepted.');
}
throw new ResolverException('unavailable', 'Resolver attempts exhausted.');
}
private function pause(int $attempt, ?string $retryAfter): void
{
$microseconds = min(2_000_000, 200_000 * (2 ** ($attempt - 1)));
if ($retryAfter !== null && ctype_digit($retryAfter)) {
$microseconds = min(2_000_000, (int) $retryAfter * 1_000_000);
}
($this->sleeper ?? usleep(...))($microseconds);
}
private function log(string $platform, int $attempt, ?int $status, string $event): void
{
($this->logger ?? error_log(...))(json_encode([
'event' => 'identity_resolver.' . $event,
'platform' => $platform,
'attempt' => $attempt,
'status' => $status,
], JSON_THROW_ON_ERROR));
}
}
A two-second cap on Retry-After is intentional for an interactive request. Longer quota windows should fail quickly as rate_limited; the application can schedule a later refresh instead of holding a PHP worker open. Validation failures, other 4xx responses, and malformed successful responses are never retried.
Map the identity into a profile card
The domain object separates resolver data from application metadata. Storage can serialize this view model as JSON or split it into database columns according to the contact manager’s needs.
<?php
// src/ProfileCard.php
namespace CreatorContacts;
use DateTimeImmutable;
final readonly class ProfileCard
{
public function __construct(
public string $contactId,
public string $platform,
public array $source,
public array $identity,
public string $resolvedAt,
) {}
public static function create(
string $contactId,
string $platform,
string $kind,
string $value,
array $identity,
): self {
return new self(
$contactId,
$platform,
[$kind => $value],
$identity,
(new DateTimeImmutable())->format(DATE_ATOM),
);
}
public function toArray(): array
{
return [
'contact_id' => $this->contactId,
'platform' => $this->platform,
'source' => $this->source,
'identity' => $this->identity,
'resolved_at' => $this->resolvedAt,
];
}
}
The front controller accepts a contact ID, platform, and exactly one supported reference. It turns expected failures into structured states without exposing upstream bodies or exception traces.
<?php
// public/index.php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use CreatorContacts\CurlTransport;
use CreatorContacts\IdentityResolverClient;
use CreatorContacts\ProfileCard;
use CreatorContacts\ResolverException;
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) !== '/profile-cards/resolve') {
http_response_code(404);
echo json_encode(['error' => ['code' => 'not_found']]);
exit;
}
try {
$input = json_decode(file_get_contents('php://input'), true, 512, JSON_THROW_ON_ERROR);
$contactId = $input['contact_id'] ?? '';
$platform = $input['platform'] ?? '';
$references = array_intersect_key(
$input,
array_fill_keys(['username', 'id', 'identifier', 'profile', 'url'], true),
);
if (!is_string($contactId)
|| preg_match('/^[A-Za-z0-9_-]{1,64}$/', $contactId) !== 1
|| count($references) !== 1) {
throw new ResolverException('validation', 'Invalid contact or reference.');
}
$kind = (string) array_key_first($references);
$value = $references[$kind];
if (!is_string($platform) || !is_string($value)) {
throw new ResolverException('validation', 'Platform and reference must be strings.');
}
$endpoint = getenv('IDENTITY_RESOLVER_URL');
if ($endpoint === false || $endpoint === '') {
throw new RuntimeException('IDENTITY_RESOLVER_URL is not configured.');
}
$client = new IdentityResolverClient(new CurlTransport(), $endpoint);
$identity = $client->resolve($platform, $kind, $value);
$card = ProfileCard::create($contactId, strtolower($platform), $kind, $value, $identity);
echo json_encode(['data' => $card->toArray()], JSON_THROW_ON_ERROR);
} catch (ResolverException $error) {
$status = match ($error->category) {
'validation' => 422,
'rate_limited' => 429,
'rejected' => 400,
default => 503,
};
http_response_code($status);
echo json_encode(['error' => [
'code' => $error->category,
'message' => $error->getMessage(),
]], JSON_THROW_ON_ERROR);
} catch (Throwable) {
http_response_code(500);
echo json_encode(['error' => ['code' => 'internal_error']]);
}
Test retries and boundary validation
A fake transport is preferable to live-network tests: it proves query construction and retry policy without consuming provider capacity or depending on changing public profiles.
<?php
// tests/IdentityResolverClientTest.php
namespace CreatorContacts\Tests;
use CreatorContacts\HttpTransport;
use CreatorContacts\IdentityResolverClient;
use CreatorContacts\ResolverException;
use CreatorContacts\TransportResponse;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements HttpTransport
{
public array $urls = [];
public function __construct(private array $responses) {}
public function get(string $url): TransportResponse
{
$this->urls[] = $url;
return array_shift($this->responses);
}
}
final class IdentityResolverClientTest extends TestCase
{
public function testItBuildsTheRequestAndPreservesTheIdentityObject(): void
{
$transport = new FakeTransport([
new TransportResponse(200, [], '{"public":{"label":"Creator"}}'),
]);
$client = new IdentityResolverClient(
$transport,
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
static fn (int $microseconds) => null,
static fn (string $message) => null,
);
$identity = $client->resolve('instagram', 'username', 'sample_creator');
self::assertSame(['public' => ['label' => 'Creator']], $identity);
self::assertStringContainsString('platform=instagram', $transport->urls[0]);
self::assertStringContainsString('username=sample_creator', $transport->urls[0]);
}
public function testItRetriesRateLimitingThenSucceeds(): void
{
$transport = new FakeTransport([
new TransportResponse(429, ['retry-after' => '1'], '{}'),
new TransportResponse(200, [], '{"normalized":true}'),
]);
$client = new IdentityResolverClient(
$transport,
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
static fn (int $microseconds) => null,
static fn (string $message) => null,
);
self::assertSame(['normalized' => true], $client->resolve(
'linkedin',
'url',
'https://www.linkedin.com/in/sample',
));
self::assertCount(2, $transport->urls);
}
public function testItRejectsUnsupportedInputBeforeTransport(): void
{
$transport = new FakeTransport([]);
$client = new IdentityResolverClient($transport, 'https://example.invalid');
$this->expectException(ResolverException::class);
$client->resolve('unknown', 'username', 'sample');
}
}
Run vendor/bin/phpunit tests. The response objects in these fixtures are deliberately synthetic boundary data, not claims about undocumented production fields.
Security, observability, and deployment
Place the controller behind the contact manager’s existing authentication and CSRF policy; the sample focuses on the resolver boundary rather than inventing an application login system. Allow only the five reference keys, cap request-body size at the web server, and escape every value when a browser renders the returned card. A public social profile can still contain hostile text.
Do not log submitted usernames, URLs, normalized payloads, or provider bodies. The structured events already expose the useful operational dimensions: event, platform, attempt, and status. Alert on sustained identity_resolver.unavailable rates and observe rate_limited separately, because those conditions require different responses.
Deploy with the cURL and JSON PHP extensions enabled. Run composer install --no-dev --classmap-authoritative, inject IDENTITY_RESOLVER_URL into the PHP-FPM or container environment, and restart workers so they inherit it. Keep outbound HTTPS verification enabled; this implementation never disables certificate checks. Health checks should verify the application itself, not repeatedly call the external service.
Common failures
- HTTP 422 locally: the platform, contact ID, URL scheme, or reference count failed validation.
- Rejected request: confirm the selected parameter is supported and consult the official documentation before changing the contract.
- HTTP 429: preserve the existing card, mark refresh as deferred, and retry later rather than creating a retry storm.
- HTTP 503: the provider timed out, returned a transient server status, or produced an unusable payload after bounded attempts.
- Empty environment value: export
IDENTITY_RESOLVER_URLin the actual PHP worker environment, not only in an interactive shell.
Final verification checklist
- Confirm the endpoint remains public and tokenless in the official documentation.
- Run the minimal cURL request without an authorization header.
- Run PHPUnit and verify the fake transport performs exactly two calls in the rate-limit test.
- Submit one reference at a time to
POST /profile-cards/resolve. - Verify Facebook, Instagram, and LinkedIn results share the same application-level card shape.
- Confirm logs contain status metadata but no public identity payload or submitted reference.
- Exercise 429, 5xx, malformed JSON, timeout, and validation paths before deployment.
The most durable part of this integration is not the cURL call. It is the boundary: a changing public reference enters on one side, a validated identity object leaves on the other, and every failure becomes an explicit state. That discipline keeps creator cards consistent today and gives the contact manager room to evolve without turning social-link cleanup into permanent application complexity.