Native PHP 8.3: Build a Creator Contact Manager with Social Link Identity Resolution
A creator contact list becomes unreliable surprisingly quickly. One person may arrive as an Instagram URL, another as a LinkedIn profile, and another as a bare Facebook identifier. If the application stores those strings directly, every import path produces a different shape and every profile card needs special-case rendering.
This project fixes that boundary problem in Native PHP 8.3. It sends public social references to the Identity Resolver, retains the normalized identity object without guessing its undocumented fields, and turns it into consistent, safely rendered creator cards. The result is a small application, but its timeouts, retries, validation, testing, logging, and deployment model are suitable foundations for production work.
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 and no API key. There is consequently no credential to copy into this project.
- Review the service page to confirm that Facebook, Instagram, or LinkedIn covers your intended input.
- Open the documentation and verify the current request contract before deployment.
- The platform also exposes registration and login pages for account features, but neither registration nor login is currently required for this public endpoint.
- Do not invent an API key or send an empty
Authorizationheader. If authentication is introduced later, follow the documentation and store the issued credential in environment-backed configuration.
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 the first test with a non-sensitive public reference:
curl --fail-with-body --get \
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
--data-urlencode 'platform=instagram' \
--data-urlencode 'username=example_creator'
Replace the placeholder with a real public reference you are permitted to process. A successful response is JSON containing the normalized public identity. We will validate it as an object instead of assuming response fields that are not part of the supplied contract.
There is no credential to place in .env. Store only deployable configuration:
IDENTITY_RESOLVER_URL=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
DATABASE_PATH=var/contacts.sqlite
Prerequisites and project shape
You need PHP 8.3 or newer, Composer, native cURL, JSON, PDO, and SQLite extensions. SQLite keeps the tutorial runnable on one machine; a multi-instance deployment should replace it with a shared database while retaining the same resolver boundary.
{
"name": "example/creator-contact-manager",
"type": "project",
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
composer install
mkdir -p src/Identity src/Infrastructure public tests var
cp .env.example .env
composer dump-autoload
php -S 127.0.0.1:8080 -t public
The important separation is small and deliberate:
CurlTransportowns network mechanics and bounded timeouts.IdentityResolverClientowns the remote request contract and retry policy.ResolvedIdentityvalidates and carries the opaque normalized object.public/index.phphandles input, persistence, logging, and presentation.
This design costs a few classes, but it prevents cURL details and uncertain remote data from spreading through the application.
Build the HTTP boundary
Create src/Infrastructure/Http.php. The transport permits HTTPS only, follows no redirects, has separate connection and total timeouts, and never adds authentication:
<?php
namespace App\Infrastructure;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body
) {}
}
interface HttpTransport
{
public function get(string $url, array $query): HttpResponse;
}
final class CurlTransport implements HttpTransport
{
public function get(string $url, array $query): HttpResponse
{
$headers = [];
$target = $url . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
$curl = curl_init($target);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT_MS => 2000,
CURLOPT_TIMEOUT_MS => 8000,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_USERAGENT => 'creator-contact-manager/1.0',
CURLOPT_HEADERFUNCTION => static function ($handle, string $line) use (&$headers): int {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
$body = curl_exec($curl);
if ($body === false) {
throw new \RuntimeException(
'Identity Resolver transport failure: ' . curl_error($curl)
);
}
return new HttpResponse(
(int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE),
$headers,
$body
);
}
}
Map the normalized identity defensively
The application must not silently depend on response properties that have not been guaranteed. The DTO therefore requires a JSON object, preserves it losslessly, and provides a bounded list of scalar leaves for the card. Every label and value will still be escaped at rendering time.
Create src/Identity/ResolvedIdentity.php:
<?php
namespace App\Identity;
final readonly class ResolvedIdentity
{
public function __construct(public array $payload)
{
if (array_is_list($payload)) {
throw new \InvalidArgumentException('Identity must be a JSON object.');
}
}
public function fingerprint(): string
{
return hash('sha256', json_encode(
$this->sorted($this->payload),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
));
}
public function fields(int $limit = 12): array
{
$output = [];
$walk = function (mixed $value, string $path = '') use (&$walk, &$output, $limit): void {
if (count($output) >= $limit) {
return;
}
if (is_array($value)) {
foreach ($value as $key => $child) {
$walk($child, ltrim($path . '.' . (string) $key, '.'));
}
} elseif (is_scalar($value) || $value === null) {
$output[$path ?: 'value'] = $value === null
? 'null'
: (is_bool($value) ? ($value ? 'true' : 'false') : (string) $value);
}
};
$walk($this->payload);
return $output;
}
private function sorted(array $value): array
{
if (!array_is_list($value)) {
ksort($value);
}
foreach ($value as $key => $child) {
if (is_array($child)) {
$value[$key] = $this->sorted($child);
}
}
return $value;
}
}
The fingerprint is a deterministic content fingerprint, not a claim about a particular remote identifier field. The database also keeps the original platform and reference so an existing contact can be updated when public profile data changes.
Add retries without creating a retry storm
Create src/Identity/IdentityResolverClient.php. It retries transport errors, HTTP 429, and temporary server failures. Validation, resolution, and authentication failures return immediately because repeating the same request cannot repair them.
<?php
namespace App\Identity;
use App\Infrastructure\HttpTransport;
final class ResolverException extends \RuntimeException
{
public function __construct(public readonly string $kind, 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 $http,
private readonly string $endpoint,
private readonly ?\Closure $sleep = null
) {}
public function resolve(string $platform, string $type, string $value): ResolvedIdentity
{
$platform = strtolower(trim($platform));
$value = trim($value);
if (!in_array($platform, self::PLATFORMS, true)
|| !in_array($type, self::REFERENCES, true)
|| $value === ''
|| strlen($value) > 2048) {
throw new ResolverException('validation', 'Unsupported or empty social reference.');
}
for ($attempt = 0; $attempt < 3; $attempt++) {
try {
$response = $this->http->get($this->endpoint, [
'platform' => $platform,
$type => $value,
]);
} catch (\RuntimeException $error) {
if ($attempt === 2) {
throw new ResolverException('transport', $error->getMessage());
}
$this->pause($attempt, null);
continue;
}
if ($response->status === 200) {
try {
$data = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new ResolverException('invalid_response', 'Resolver returned invalid JSON.');
}
if (!is_array($data) || array_is_list($data)) {
throw new ResolverException('invalid_response', 'Resolver returned an unexpected shape.');
}
return new ResolvedIdentity($data);
}
if ($response->status === 429 || in_array($response->status, [502, 503, 504], true)) {
if ($attempt < 2) {
$this->pause($attempt, $response->headers['retry-after'] ?? null);
continue;
}
throw new ResolverException(
$response->status === 429 ? 'rate_limited' : 'unavailable',
'Resolver is temporarily unavailable.'
);
}
$kind = match ($response->status) {
400, 404, 422 => 'unresolvable_reference',
401, 403 => 'authentication_contract_changed',
default => 'upstream_error',
};
throw new ResolverException($kind, 'Resolver rejected the request.');
}
throw new ResolverException('unavailable', 'Retry budget exhausted.');
}
private function pause(int $attempt, ?string $retryAfter): void
{
$milliseconds = ctype_digit((string) $retryAfter)
? min(5000, (int) $retryAfter * 1000)
: min(2000, 200 * (2 ** $attempt) + random_int(0, 100));
($this->sleep ?? static fn (int $ms) => usleep($ms * 1000))($milliseconds);
}
}
Turn resolved data into profile cards
The controller should validate CSRF tokens, resolve before writing, store raw normalized JSON, and log only operational metadata. Never log the submitted URL or returned identity object: public data can still be sensitive in aggregate.
In public/index.php, bootstrap the client, create a SQLite table, and handle the POST route:
<?php
use App\Identity\IdentityResolverClient;
use App\Identity\ResolverException;
use App\Infrastructure\CurlTransport;
use Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$escape = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$database = dirname(__DIR__) . '/' . ($_ENV['DATABASE_PATH'] ?? 'var/contacts.sqlite');
$pdo = new PDO('sqlite:' . $database, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec('CREATE TABLE IF NOT EXISTS creators (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL,
platform TEXT NOT NULL,
reference_type TEXT NOT NULL,
source_reference TEXT NOT NULL,
fingerprint TEXT NOT NULL,
identity_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(platform, reference_type, source_reference)
)');
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$requestId = bin2hex(random_bytes(8));
try {
if (!hash_equals($_SESSION['csrf'], (string) ($_POST['csrf'] ?? ''))) {
throw new ResolverException('csrf', 'The form expired. Reload and try again.');
}
$name = trim((string) ($_POST['display_name'] ?? ''));
if ($name === '' || strlen($name) > 120) {
throw new ResolverException('validation', 'Enter a valid display name.');
}
$client = new IdentityResolverClient(
new CurlTransport(),
$_ENV['IDENTITY_RESOLVER_URL']
);
$identity = $client->resolve(
(string) ($_POST['platform'] ?? ''),
(string) ($_POST['reference_type'] ?? ''),
(string) ($_POST['reference'] ?? '')
);
$statement = $pdo->prepare('INSERT INTO creators
(display_name, platform, reference_type, source_reference,
fingerprint, identity_json, updated_at)
VALUES (:name, :platform, :type, :reference, :fingerprint, :json, :updated)
ON CONFLICT(platform, reference_type, source_reference) DO UPDATE SET
display_name = excluded.display_name,
fingerprint = excluded.fingerprint,
identity_json = excluded.identity_json,
updated_at = excluded.updated_at');
$statement->execute([
'name' => $name,
'platform' => strtolower((string) $_POST['platform']),
'type' => (string) $_POST['reference_type'],
'reference' => trim((string) $_POST['reference']),
'fingerprint' => $identity->fingerprint(),
'json' => json_encode($identity->payload, JSON_THROW_ON_ERROR),
'updated' => gmdate(DATE_ATOM),
]);
header('Location: /', true, 303);
exit;
} catch (ResolverException $exception) {
$error = $exception->getMessage();
error_log(json_encode([
'event' => 'identity_resolution_failed',
'request_id' => $requestId,
'kind' => $exception->kind,
], JSON_THROW_ON_ERROR));
}
}
$cards = $pdo->query('SELECT * FROM creators ORDER BY updated_at DESC')->fetchAll(PDO::FETCH_ASSOC);
Render a normal HTML form with fields named display_name, platform, reference_type, reference, and hidden csrf. For each row, decode identity_json, construct ResolvedIdentity, and iterate over fields(). Escape both labels and values with the controller’s $escape closure. This produces one card layout regardless of which supported network supplied the reference.
Test retries and boundary behavior deterministically
A fake transport keeps tests fast and prevents accidental production calls. Place this representative suite in tests/IdentityResolverClientTest.php:
<?php
namespace Tests;
use App\Identity\IdentityResolverClient;
use App\Identity\ResolverException;
use App\Infrastructure\HttpResponse;
use App\Infrastructure\HttpTransport;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements HttpTransport
{
public array $requests = [];
public function __construct(private array $responses) {}
public function get(string $url, array $query): HttpResponse
{
$this->requests[] = [$url, $query];
return array_shift($this->responses);
}
}
final class IdentityResolverClientTest extends TestCase
{
public function testItPreservesTheNormalizedObject(): void
{
$fake = new FakeTransport([
new HttpResponse(200, [], '{"public":{"label":"Creator"}}'),
]);
$client = new IdentityResolverClient($fake, 'https://example.test/resolve');
$identity = $client->resolve('instagram', 'username', 'creator');
self::assertSame('Creator', $identity->payload['public']['label']);
self::assertSame(
['platform' => 'instagram', 'username' => 'creator'],
$fake->requests[0][1]
);
}
public function testItRetriesRateLimitingThenSucceeds(): void
{
$fake = new FakeTransport([
new HttpResponse(429, ['retry-after' => '1'], ''),
new HttpResponse(200, [], '{"resolved":true}'),
]);
$delays = [];
$client = new IdentityResolverClient(
$fake,
'https://example.test/resolve',
static function (int $ms) use (&$delays): void { $delays[] = $ms; }
);
self::assertTrue($client->resolve('linkedin', 'url', 'https://example.test/p')->payload['resolved']);
self::assertCount(2, $fake->requests);
self::assertSame([1000], $delays);
}
public function testItDoesNotRetryValidationFailures(): void
{
$fake = new FakeTransport([]);
$client = new IdentityResolverClient($fake, 'https://example.test/resolve');
$this->expectException(ResolverException::class);
try {
$client->resolve('unknown', 'username', 'creator');
} finally {
self::assertCount(0, $fake->requests);
}
}
}
vendor/bin/phpunit --testdox tests
php -l public/index.php
php -l src/Identity/IdentityResolverClient.php
php -l src/Identity/ResolvedIdentity.php
Security, observability, and deployment
Treat social references as untrusted input even though the resolver processes public identities. Keep the platform and parameter-name allowlists, cap input size, escape output, use prepared SQL, protect writes with CSRF, and apply an inbound rate limit at the web server or reverse proxy. Do not turn arbitrary user input into an unrestricted server-side URL fetch.
Logs should contain a correlation ID, failure category, attempt outcome, and latency, but not profile payloads or full submitted URLs. Track rates of rate_limited, transport, invalid_response, and authentication_contract_changed. A sudden 401 or 403 matters because the documented public-access contract may have changed.
In production, inject environment variables through the hosting platform, serve only public/, enable HTTPS, disable verbose error display, and ensure var/ is writable but not web-accessible. SQLite needs persistent storage and a single-writer-aware deployment. Multiple application replicas should use a shared transactional database.
Do not make the external service part of a liveness probe. A local health endpoint should confirm that PHP and the database work; upstream availability belongs in metrics and readiness policy. Cache recent successful resolutions where product requirements permit, and refresh deliberately rather than resolving on every page view.
Common failures worth designing for
- HTTP 400 or 422: the platform, parameter type, or supplied reference is invalid. Correct the input; do not retry.
- HTTP 404: the public identity may not be resolvable. Preserve the contact draft and invite correction.
- HTTP 429: honor a numeric
Retry-Afterwithin a safe bound, then stop after the retry budget. - HTTP 401 or 403: verify the official documentation. Do not fabricate authentication headers.
- Malformed JSON or an array root: classify it as an upstream contract failure and store nothing.
- Timeouts and temporary 5xx responses: retry briefly with backoff and jitter, then return a recoverable failure state.
Final verification checklist
- The application sends an exact GET request to the documented
/v1/resolveendpoint. - Each request contains one supported platform and exactly one supported reference parameter.
- No token, API key, or invented authorization header is present.
- Connection and response timeouts are bounded.
- Only transport, rate-limit, and temporary upstream failures are retried.
- The normalized JSON object crosses one validated application boundary and is escaped before display.
- Tests use a deterministic fake transport and never call the live service.
- Logs exclude social references and returned identity payloads.
- Successful resolution and database persistence complete before a profile card appears.
The durable lesson is larger than one contact manager: normalization belongs at the boundary. Once inconsistent social references become a validated identity object, the rest of the application can remain pleasantly ordinary. Cards render through one path, failures have useful names, retries are controlled, and future API changes remain confined to one small, testable client.