Izvorni PHP 8.3: Generirajte objedinjene kartice profila autora iz poveznica na društvenim mrežama
A creator contact manager quickly becomes messy when one person arrives as an Instagram URL, another as a LinkedIn identifier, and a third as a Facebook username. Comparing those strings directly creates duplicate contacts, inconsistent displays, and brittle platform-specific code.
The better boundary is a normalized identity object. In this tutorial, we will build a Native PHP 8.3 application that sends public social references to the Identity Resolver, validates its response defensively, and maps the result into a stable profile-card schema. The implementation includes bounded timeouts, selective retries, deterministic tests, structured failures, safe logging, and practical deployment guidance.
Get access before writing integration code
Start with the official Identity Resolver documentation. The current public endpoint requires no account token or API key, so there is no mandatory registration, login, plan activation, or credential-copying step before the first request.
- Review the supported inputs in the official documentation.
- Check the service and plan page for the current service status and plan information.
- No registration step or login step is currently required for this public endpoint. If the onboarding model changes, follow the links exposed by the official documentation rather than guessing portal URLs.
- There is no token to copy. Do not invent an API key, add an empty bearer header, or store a placeholder credential that the service does not require.
The exact request is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Send platform together with one supported username, id, identifier, profile, or url parameter.
Make a minimal test request before building the application:
curl --get \
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
--data-urlencode 'platform=instagram' \
--data-urlencode 'url=https://www.instagram.com/example/' \
--header 'Accept: application/json'
Replace the example URL with a supported public reference that you are permitted to process. A successful response should be JSON containing the normalized public identity object. We will deliberately avoid assuming undocumented field names.
Although there is no credential, keep operational configuration outside source control. Create a local .env file and exclude it from Git:
IDENTITY_RESOLVER_URL=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
IDENTITY_CONNECT_TIMEOUT_MS=1500
IDENTITY_RESPONSE_TIMEOUT_MS=5000
set -a
. ./.env
set +a
In production, inject these variables through the process manager or hosting platform. If authentication is introduced later, add the documented credential as an environment secret only after confirming the new contract.
Architecture and trade-offs
The application has four boundaries: an HTTP transport, an Identity Resolver client, a domain mapper, and a small HTTP controller. The controller never interprets provider-specific responses. The transport knows cURL but nothing about creators. The resolver owns retries and response validation. The mapper turns the accepted identity object into a versioned card shape.
Resolution remains synchronous because creating one contact requires one small lookup and the user needs immediate feedback. A queue would add operational machinery without improving this ordinary contact-manager workflow. If bulk imports are added later, the same resolver can sit behind a worker.
Use PHP 8.3 with the cURL and JSON extensions, Composer, and PHPUnit 11 for tests:
mkdir creator-cards
cd creator-cards
composer init --name=app/creator-cards --require=php:^8.3 --no-interaction
composer require --dev phpunit/phpunit:^11.0
mkdir -p public src/Http src/Identity tests
composer config autoload.psr-4 'App\\' src/
composer config autoload-dev.psr-4 'Tests\\' tests/
composer dump-autoload
The resulting project is intentionally small:
creator-cards/
public/index.php
src/Http/Transport.php
src/Http/CurlTransport.php
src/Identity/IdentityResolver.php
src/Identity/ProfileCardMapper.php
tests/IdentityResolverTest.php
composer.json
.env
Build an isolated native cURL transport
An interface makes the external boundary replaceable in tests. The production implementation records response headers because Retry-After can influence rate-limit handling.
<?php
// src/Http/Transport.php
namespace App\Http;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
public array $headers
) {}
}
interface Transport
{
public function get(
string $url,
array $query,
int $connectTimeoutMs,
int $responseTimeoutMs
): HttpResponse;
}
final class TransportException extends \RuntimeException {}
<?php
// src/Http/CurlTransport.php
namespace App\Http;
final class CurlTransport implements Transport
{
public function get(
string $url,
array $query,
int $connectTimeoutMs,
int $responseTimeoutMs
): HttpResponse {
$headers = [];
$handle = curl_init($url . '?' . http_build_query(
$query,
'',
'&',
PHP_QUERY_RFC3986
));
if ($handle === false) {
throw new TransportException('Unable to initialize cURL');
}
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
CURLOPT_TIMEOUT_MS => $responseTimeoutMs,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'User-Agent: CreatorCards/1.0',
],
CURLOPT_HEADERFUNCTION => static function (
\CurlHandle $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($handle);
if ($body === false) {
$message = curl_error($handle);
$number = curl_errno($handle);
throw new TransportException(
"cURL failure {$number}: {$message}"
);
}
return new HttpResponse(
curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
$body,
$headers
);
}
}
The connection timeout limits DNS and connection establishment. The total timeout bounds the complete request. Neither should be left at an unlimited default in a user-facing PHP process.
Resolve identities with controlled retries
The resolver accepts only the documented platforms and reference parameter names. It retries transport failures, HTTP 429, and server-side 5xx responses. Validation failures and other 4xx responses fail immediately because repeating the same request will not repair it.
<?php
// src/Identity/IdentityResolver.php
namespace App\Identity;
use App\Http\Transport;
use App\Http\TransportException;
final class ResolverFailure extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly ?int $status = null
) {
parent::__construct($message);
}
}
final class IdentityResolver
{
private const PLATFORMS = ['facebook', 'instagram', 'linkedin'];
private const REFERENCES = [
'username', 'id', 'identifier', 'profile', 'url'
];
public function __construct(
private Transport $transport,
private string $endpoint,
private int $connectTimeoutMs,
private int $responseTimeoutMs,
private \Closure $sleep,
private \Closure $log
) {}
public function resolve(
string $platform,
string $referenceType,
string $reference
): array {
$platform = strtolower(trim($platform));
$referenceType = strtolower(trim($referenceType));
$reference = trim($reference);
if (!in_array($platform, self::PLATFORMS, true) ||
!in_array($referenceType, self::REFERENCES, true) ||
$reference === '') {
throw new ResolverFailure(
'invalid_input',
'Unsupported platform or reference'
);
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->get(
$this->endpoint,
['platform' => $platform, $referenceType => $reference],
$this->connectTimeoutMs,
$this->responseTimeoutMs
);
} catch (TransportException $exception) {
($this->log)([
'event' => 'identity_resolver_transport_failure',
'platform' => $platform,
'attempt' => $attempt,
]);
if ($attempt === 3) {
throw new ResolverFailure(
'unavailable',
'Identity service is unavailable'
);
}
($this->sleep)(200 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
try {
$identity = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException) {
throw new ResolverFailure(
'invalid_response',
'Identity service returned invalid JSON',
$response->status
);
}
if (!is_array($identity) || array_is_list($identity)) {
throw new ResolverFailure(
'invalid_response',
'Expected a normalized identity object',
$response->status
);
}
return $identity;
}
$retryable = $response->status === 429 ||
$response->status >= 500;
if (!$retryable || $attempt === 3) {
$kind = $response->status === 429
? 'rate_limited'
: ($response->status >= 500
? 'unavailable'
: 'rejected');
throw new ResolverFailure(
$kind,
'Identity resolution failed',
$response->status
);
}
$delay = 200 * (2 ** ($attempt - 1));
$retryAfter = $response->headers['retry-after'] ?? null;
if (is_string($retryAfter) && ctype_digit($retryAfter)) {
$delay = min(2000, ((int) $retryAfter) * 1000);
}
($this->log)([
'event' => 'identity_resolver_retry',
'platform' => $platform,
'status' => $response->status,
'attempt' => $attempt,
'delay_ms' => $delay,
]);
($this->sleep)($delay);
}
throw new ResolverFailure('unavailable', 'Resolution failed');
}
}
The retry cap protects the PHP worker and upstream service. Only integer Retry-After values are honored, and the delay is capped at two seconds. The reference itself is intentionally absent from logs.
Map the response into a stable profile card
The supplied contract promises a normalized public identity object, but it does not justify hard-coding particular response fields here. Preserve that object, attach locally known metadata, and derive a stable fingerprint from canonical JSON. This keeps card storage consistent without pretending that an undocumented name or avatar field must exist.
<?php
// src/Identity/ProfileCardMapper.php
namespace App\Identity;
final class ProfileCardMapper
{
public function map(
string $platform,
string $referenceType,
string $reference,
array $identity,
\DateTimeImmutable $resolvedAt
): array {
$canonical = $this->sortRecursively($identity);
return [
'schema' => 'creator-profile-card/v1',
'id' => hash(
'sha256',
$platform . "\n" .
json_encode($canonical, JSON_THROW_ON_ERROR)
),
'platform' => $platform,
'source' => [
'type' => $referenceType,
'value' => $reference,
],
'identity' => $canonical,
'resolved_at' => $resolvedAt->format(DATE_ATOM),
];
}
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 hash is an application fingerprint, not a claim that the service exposes a specific identifier. Store the complete card as JSON or map it into your contact database. When rendering identity values into HTML, always escape them with htmlspecialchars; public data is still untrusted input.
Expose the contact-manager endpoint
The controller accepts a neutral input shape and translates it into the exact query expected by the resolver.
<?php
// public/index.php
require dirname(__DIR__) . '/vendor/autoload.php';
use App\Http\CurlTransport;
use App\Identity\IdentityResolver;
use App\Identity\ProfileCardMapper;
use App\Identity\ResolverFailure;
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' ||
parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) !== '/api/profile-cards') {
http_response_code(404);
echo json_encode(['error' => 'not_found']);
exit;
}
try {
$input = json_decode(
file_get_contents('php://input'),
true,
32,
JSON_THROW_ON_ERROR
);
if (!is_array($input)) {
throw new \InvalidArgumentException();
}
$resolver = new IdentityResolver(
new CurlTransport(),
getenv('IDENTITY_RESOLVER_URL') ?: '',
(int) (getenv('IDENTITY_CONNECT_TIMEOUT_MS') ?: 1500),
(int) (getenv('IDENTITY_RESPONSE_TIMEOUT_MS') ?: 5000),
static fn (int $milliseconds) =>
usleep($milliseconds * 1000),
static fn (array $event) =>
error_log(json_encode($event, JSON_THROW_ON_ERROR))
);
$platform = (string) ($input['platform'] ?? '');
$type = (string) ($input['reference_type'] ?? '');
$reference = (string) ($input['reference'] ?? '');
$identity = $resolver->resolve($platform, $type, $reference);
$card = (new ProfileCardMapper())->map(
strtolower(trim($platform)),
strtolower(trim($type)),
trim($reference),
$identity,
new \DateTimeImmutable('now', new \DateTimeZone('UTC'))
);
http_response_code(201);
echo json_encode(['card' => $card], JSON_THROW_ON_ERROR);
} catch (ResolverFailure $failure) {
$status = match ($failure->kind) {
'invalid_input' => 422,
'rate_limited' => 429,
'rejected' => 422,
default => 503,
};
http_response_code($status);
echo json_encode([
'error' => $failure->kind,
'message' => $failure->getMessage(),
], JSON_THROW_ON_ERROR);
} catch (\JsonException | \InvalidArgumentException) {
http_response_code(400);
echo json_encode(['error' => 'invalid_json']);
}
Production applications should also cap request-body size at the web server and enforce reasonable reference lengths before making the outbound call.
Test retries without calling the service
A fake transport makes failure paths fast and deterministic. No network, account, or fixture credential is needed.
<?php
// tests/IdentityResolverTest.php
namespace Tests;
use App\Http\HttpResponse;
use App\Http\Transport;
use App\Identity\IdentityResolver;
use App\Identity\ResolverFailure;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public array $calls = [];
public function __construct(private array $responses) {}
public function get(
string $url,
array $query,
int $connectTimeoutMs,
int $responseTimeoutMs
): HttpResponse {
$this->calls[] = compact('url', 'query');
return array_shift($this->responses);
}
}
final class IdentityResolverTest extends TestCase
{
public function testReturnsNormalizedObject(): void
{
$fake = new FakeTransport([
new HttpResponse(200, '{"public":"identity"}', []),
]);
$resolver = $this->resolver($fake);
$result = $resolver->resolve(
'instagram',
'username',
'example'
);
self::assertSame(['public' => 'identity'], $result);
self::assertSame(
['platform' => 'instagram', 'username' => 'example'],
$fake->calls[0]['query']
);
}
public function testRetriesRateLimitThenSucceeds(): void
{
$fake = new FakeTransport([
new HttpResponse(429, '{}', ['retry-after' => '1']),
new HttpResponse(200, '{"resolved":true}', []),
]);
$delays = [];
$resolver = $this->resolver($fake, $delays);
self::assertSame(
['resolved' => true],
$resolver->resolve('linkedin', 'id', '123')
);
self::assertCount(2, $fake->calls);
self::assertSame([1000], $delays);
}
public function testDoesNotRetryRejectedInput(): void
{
$fake = new FakeTransport([
new HttpResponse(400, '{}', []),
]);
$this->expectException(ResolverFailure::class);
try {
$this->resolver($fake)->resolve(
'facebook',
'profile',
'bad-reference'
);
} finally {
self::assertCount(1, $fake->calls);
}
}
private function resolver(
FakeTransport $fake,
array &$delays = []
): IdentityResolver {
return new IdentityResolver(
$fake,
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
1500,
5000,
static function (int $ms) use (&$delays): void {
$delays[] = $ms;
},
static function (array $event): void {}
);
}
}
vendor/bin/phpunit --testdox tests
php -S 127.0.0.1:8080 -t public
curl --request POST 'http://127.0.0.1:8080/api/profile-cards' \
--header 'Content-Type: application/json' \
--data '{"platform":"instagram","reference_type":"url","reference":"https://www.instagram.com/example/"}'
Security, observability, and deployment
Accept only the documented platform and parameter allowlists. Because the destination URL is fixed in environment configuration, callers cannot turn the application into an arbitrary HTTP proxy. Do not let request data override the resolver hostname.
Log event names, platform, status, attempt, delay, duration, and a request correlation ID if your application already has one. Avoid logging submitted usernames, URLs, raw bodies, or complete normalized identities. Track counts of successes, rejected inputs, rate limits, upstream failures, malformed responses, and latency.
For deployment, point the web server’s document root at public, run PHP-FPM as an unprivileged user, require HTTPS at the public edge, and inject the three environment variables into the PHP-FPM process. Install optimized production dependencies with composer install --no-dev --classmap-authoritative. Verify that cURL trusts the operating system CA bundle; never solve certificate failures by disabling TLS verification.
Common failures
- HTTP 400 or 422: Check the platform and ensure exactly one supported reference parameter is sent. Do not retry unchanged input.
- HTTP 429: Respect bounded backoff, return a structured rate-limit state after the retry budget, and let the user try later.
- HTTP 401 or 403: Do not add a guessed token. Recheck the official documentation because the current public contract requires none.
- HTTP 5xx or cURL timeout: Retry only within the small budget, then return
503without exposing transport internals. - Valid JSON with an unexpected shape: Treat it as an upstream contract failure. Do not silently create a partial card.
- Duplicate-looking cards: Compare the canonical fingerprint and normalized identity, not the original formatting of the submitted social link.
Final verification checklist
- The application calls only
GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. - Every request contains
platformand exactly one supported reference parameter. - No token, API key, or authorization header is fabricated.
- Connection and total response timeouts are bounded.
- Only transport failures, 429 responses, and 5xx responses are retried.
- Malformed JSON and non-object responses produce structured failures.
- Tests use a deterministic fake transport and never call the live service.
- Logs exclude social references and normalized identity bodies.
- Profile cards use the same versioned schema regardless of the source platform.
- The production web root exposes only the
publicdirectory.
The valuable result is not merely another API call. It is a disciplined boundary between unpredictable user-entered social links and the stable model your contact manager needs. Once every Facebook, Instagram, and LinkedIn reference passes through that boundary, profile cards become consistent, failures become explainable, and future changes stay isolated where they belong.