Tutorials

Native PHP 8.3: Unify Social Directory Links with AI Identity Resolver

Native PHP 8.3: Unify Social Directory Links with AI Identity Resolver

A community directory often begins with a few harmless text fields. Then contributors paste mobile Facebook URLs, Instagram profile links with tracking parameters, LinkedIn variants, and sometimes bare identifiers. If those values are stored unchanged, searching, deduplication, and profile rendering become increasingly unreliable.

This tutorial builds a Native PHP 8.3 endpoint that accepts Facebook, Instagram, and LinkedIn profile links, resolves them through the Identity Resolver, and stores a consistent domain object in SQLite. The integration uses native cURL, bounded retries, defensive response mapping, structured logs, and deterministic PHPUnit tests.

Get access before writing integration code

Start with the official Identity Resolver documentation. It defines the supported inputs and is also the authoritative place to check whether access requirements have changed.

The current public endpoint requires no account token or API key. Consequently, there is no credential to copy into PHP, no authorization header to construct, and no plan-selection step before your first request. The onboarding sequence is:

  1. Open the documentation and confirm that the endpoint is still public.
  2. Review the service and plan page for current service details.
  3. Because no account is currently required, the documentation serves as the official registration guidance: there is no registration action for this endpoint.
  4. Likewise, consult the login and account-status page, but do not wait for a token or invent an API key.

The exact call is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. It accepts platform plus a supported username, id, identifier, profile, or url parameter. Our directory already collects links, so it will consistently send platform and url.

Make a minimal test before building the feature:

curl --get \
  --header 'Accept: application/json' \
  --data-urlencode 'platform=instagram' \
  --data-urlencode 'url=https://www.instagram.com/example/' \
  'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'

No Authorization header is present. Inspect the live response against the documentation rather than assuming undocumented fields.

Choose a small, dependable architecture

The application has four boundaries: an HTTP entry point, local URL validation, an Identity Resolver client, and persistent storage. Resolution happens before the database transaction, keeping the SQLite write lock short while an external request is in flight.

The upstream response is deliberately preserved as an opaque JSON object. Our application adds its own fields—platform, source_url, identity_key, and public_identity—without claiming that those names exist in the service response. This boundary survives additive response changes and avoids coupling business code to fields that are not guaranteed by the supplied contract.

Use this project layout:

community-directory/
├── composer.json
├── .env.example
├── public/
│   └── index.php
├── src/
│   └── IdentityResolver.php
├── tests/
│   └── IdentityResolverTest.php
└── var/
    └── directory.sqlite

Create composer.json with PHP 8.3, the required extensions, classmap autoloading, and PHPUnit 11:

{
  "require": {
    "php": ">=8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "classmap": ["src/"]
  }
}
composer install
composer dump-autoload --classmap-authoritative

Configure the environment without inventing a service credential

Native PHP does not automatically load a .env file. Use it locally through your process manager or shell, and configure the same variables directly in PHP-FPM or your deployment platform in production.

Create .env.example:

IDENTITY_RESOLVER_ENDPOINT=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
DIRECTORY_DSN=sqlite:var/directory.sqlite
DIRECTORY_WRITE_TOKEN=YOUR_DIRECTORY_WRITE_TOKEN

DIRECTORY_WRITE_TOKEN protects your own submission endpoint; it is not an Identity Resolver token. There is intentionally no upstream API-key variable. Generate a strong application token outside source control, keep the real .env out of the repository, and inject its values at runtime.

Build the cURL boundary and domain mapper

Place the following in src/IdentityResolver.php. The transport enforces HTTPS, disables redirects, verifies TLS using cURL defaults, limits connection and total time, and aborts responses larger than 256 KiB.

<?php
declare(strict_types=1);

namespace App;

use JsonException;
use RuntimeException;

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
    {
        $uri = $url . '?' . http_build_query(
            $query,
            '',
            '&',
            PHP_QUERY_RFC3986
        );

        $headers = [];
        $body = '';
        $handle = curl_init($uri);

        if ($handle === false) {
            throw new RuntimeException('Unable to initialize cURL');
        }

        curl_setopt_array($handle, [
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT_MS => 2000,
            CURLOPT_TIMEOUT_MS => 6000,
            CURLOPT_HTTPHEADER => [
                'Accept: application/json',
                'User-Agent: community-directory/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);
            },
            CURLOPT_WRITEFUNCTION => static function (
                $handle,
                string $chunk
            ) use (&$body): int {
                if (strlen($body) + strlen($chunk) > 262144) {
                    return 0;
                }
                $body .= $chunk;
                return strlen($chunk);
            },
        ]);

        if (curl_exec($handle) === false) {
            $message = curl_error($handle);
            throw new RuntimeException('Resolver transport failed: ' . $message);
        }

        return new HttpResponse(
            curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
            $headers,
            $body,
        );
    }
}

final readonly class ResolvedIdentity
{
    public function __construct(
        public string $platform,
        public string $sourceUrl,
        public string $identityKey,
        public array $publicIdentity,
    ) {}

    public static function fromApi(
        string $platform,
        string $sourceUrl,
        array $payload
    ): self {
        if ($payload === [] || array_is_list($payload)) {
            throw new ResolverException(
                'invalid_response',
                false,
                'Resolver returned no identity object'
            );
        }

        $fingerprint = hash(
            'sha256',
            $platform . "\n" . json_encode($payload, JSON_THROW_ON_ERROR)
        );

        return new self($platform, $sourceUrl, $fingerprint, $payload);
    }

    public function toArray(): array
    {
        return [
            'platform' => $this->platform,
            'source_url' => $this->sourceUrl,
            'identity_key' => $this->identityKey,
            'public_identity' => $this->publicIdentity,
        ];
    }
}

final class ResolverException extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly bool $retryable,
        string $message
    ) {
        parent::__construct($message);
    }
}

final class IdentityResolver
{
    public function __construct(
        private HttpTransport $http,
        private string $endpoint,
        private \Closure $sleep,
        private \Closure $log,
    ) {}

    public function resolve(string $platform, string $url): ResolvedIdentity
    {
        if (!in_array($platform, ['facebook', 'instagram', 'linkedin'], true)) {
            throw new ResolverException(
                'validation',
                false,
                'Unsupported platform'
            );
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->get($this->endpoint, [
                    'platform' => $platform,
                    'url' => $url,
                ]);
            } catch (RuntimeException $exception) {
                ($this->log)([
                    'event' => 'identity_resolver_transport_failure',
                    'platform' => $platform,
                    'attempt' => $attempt,
                ]);

                if ($attempt === 3) {
                    throw new ResolverException(
                        'transport',
                        true,
                        'Identity service is temporarily unavailable'
                    );
                }

                ($this->sleep)(200000 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status === 429 || $response->status >= 500) {
                ($this->log)([
                    'event' => 'identity_resolver_retry',
                    'platform' => $platform,
                    'status' => $response->status,
                    'attempt' => $attempt,
                ]);

                if ($attempt === 3) {
                    throw new ResolverException(
                        'upstream_unavailable',
                        true,
                        'Identity service could not complete the request'
                    );
                }

                ($this->sleep)(200000 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status === 401 || $response->status === 403) {
                throw new ResolverException(
                    'access',
                    false,
                    'Identity service rejected access'
                );
            }

            if ($response->status < 200 || $response->status >= 300) {
                throw new ResolverException(
                    'invalid_reference',
                    false,
                    'Identity reference was rejected'
                );
            }

            try {
                $payload = json_decode(
                    $response->body,
                    true,
                    32,
                    JSON_THROW_ON_ERROR
                );
            } catch (JsonException) {
                throw new ResolverException(
                    'invalid_response',
                    false,
                    'Identity service returned invalid JSON'
                );
            }

            if (!is_array($payload)) {
                throw new ResolverException(
                    'invalid_response',
                    false,
                    'Identity service returned an unexpected document'
                );
            }

            return ResolvedIdentity::fromApi($platform, $url, $payload);
        }

        throw new ResolverException('internal', false, 'Unreachable state');
    }
}

Only transport failures, HTTP 429, and server errors are retried. Validation, access, and other client errors fail immediately. The delays are bounded at 200 and 400 milliseconds because an interactive directory submission should not wait indefinitely.

Accept and store a directory submission

Deploy the schema once:

CREATE TABLE directory_submissions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    identities_json TEXT NOT NULL,
    created_at TEXT NOT NULL
);

Then create public/index.php. It requires an application bearer token, caps request size, validates each URL locally, resolves all three identities, and persists only after every resolution succeeds.

<?php
declare(strict_types=1);

use App\CurlTransport;
use App\IdentityResolver;
use App\ResolverException;

require dirname(__DIR__) . '/vendor/autoload.php';

header('Content-Type: application/json');

$send = static function (int $status, array $body): never {
    http_response_code($status);
    echo json_encode($body, JSON_THROW_ON_ERROR);
    exit;
};

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Allow: POST');
    $send(405, ['error' => 'method_not_allowed']);
}

$expected = getenv('DIRECTORY_WRITE_TOKEN') ?: '';
$authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? '';

if ($expected === '' || !hash_equals('Bearer ' . $expected, $authorization)) {
    $send(401, ['error' => 'unauthorized']);
}

$raw = file_get_contents('php://input');
if ($raw === false || strlen($raw) > 32768) {
    $send(413, ['error' => 'request_too_large']);
}

try {
    $input = json_decode($raw, true, 16, JSON_THROW_ON_ERROR);
} catch (JsonException) {
    $send(400, ['error' => 'invalid_json']);
}

$roots = [
    'facebook' => 'facebook.com',
    'instagram' => 'instagram.com',
    'linkedin' => 'linkedin.com',
];

$links = $input['links'] ?? null;
if (!is_array($links) || array_keys($links) !== array_keys($roots)) {
    $send(422, ['error' => 'three_platform_links_required']);
}

foreach ($roots as $platform => $root) {
    $url = $links[$platform] ?? null;
    $host = is_string($url) ? strtolower(parse_url($url, PHP_URL_HOST) ?? '') : '';
    $scheme = is_string($url) ? parse_url($url, PHP_URL_SCHEME) : null;

    $allowedHost = $host === $root || str_ends_with($host, '.' . $root);
    if ($scheme !== 'https' || !$allowedHost) {
        $send(422, ['error' => 'invalid_' . $platform . '_url']);
    }
}

$logger = static fn(array $context) =>
    error_log(json_encode($context, JSON_THROW_ON_ERROR));

$resolver = new IdentityResolver(
    new CurlTransport(),
    getenv('IDENTITY_RESOLVER_ENDPOINT')
        ?: 'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
    static fn(int $microseconds) => usleep($microseconds),
    $logger,
);

try {
    $identities = [];
    foreach ($links as $platform => $url) {
        $identities[] = $resolver->resolve($platform, $url)->toArray();
    }

    $pdo = new PDO(
        getenv('DIRECTORY_DSN') ?: 'sqlite:var/directory.sqlite',
        null,
        null,
        [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
    );

    $pdo->beginTransaction();
    $statement = $pdo->prepare(
        'INSERT INTO directory_submissions
         (identities_json, created_at) VALUES (:identities, :created_at)'
    );
    $statement->execute([
        ':identities' => json_encode($identities, JSON_THROW_ON_ERROR),
        ':created_at' => gmdate('c'),
    ]);
    $id = (int) $pdo->lastInsertId();
    $pdo->commit();

    $send(201, ['id' => $id, 'identities' => $identities]);
} catch (ResolverException $exception) {
    $logger([
        'event' => 'directory_resolution_failed',
        'kind' => $exception->kind,
        'retryable' => $exception->retryable,
    ]);
    $send($exception->retryable ? 503 : 422, [
        'error' => $exception->kind,
        'retryable' => $exception->retryable,
    ]);
} catch (Throwable $exception) {
    $logger(['event' => 'directory_submission_failed']);
    $send(500, ['error' => 'internal_error']);
}

Test without calling the public service

A fake transport makes retries and failure classification deterministic. Save this as tests/IdentityResolverTest.php:

<?php
declare(strict_types=1);

use App\HttpResponse;
use App\HttpTransport;
use App\IdentityResolver;
use App\ResolverException;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements HttpTransport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function get(string $url, array $query): HttpResponse
    {
        return $this->responses[$this->calls++];
    }
}

final class IdentityResolverTest extends TestCase
{
    public function testMapsAValidIdentityObject(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(200, [], '{"normalized":"public-value"}'),
        ]);

        $resolver = new IdentityResolver(
            $fake,
            'https://example.test/resolve',
            static fn(int $delay) => null,
            static fn(array $context) => null,
        );

        $identity = $resolver->resolve(
            'instagram',
            'https://www.instagram.com/example/'
        );

        self::assertSame('instagram', $identity->platform);
        self::assertSame(
            ['normalized' => 'public-value'],
            $identity->publicIdentity
        );
        self::assertSame(1, $fake->calls);
    }

    public function testRetriesRateLimitThenSucceeds(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(429, [], '{}'),
            new HttpResponse(200, [], '{"normalized":"ok"}'),
        ]);

        $resolver = new IdentityResolver(
            $fake,
            'https://example.test/resolve',
            static fn(int $delay) => null,
            static fn(array $context) => null,
        );

        $resolver->resolve('facebook', 'https://facebook.com/example');
        self::assertSame(2, $fake->calls);
    }

    public function testDoesNotRetryRejectedReference(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(400, [], '{"error":"invalid"}'),
        ]);

        $resolver = new IdentityResolver(
            $fake,
            'https://example.test/resolve',
            static fn(int $delay) => null,
            static fn(array $context) => null,
        );

        try {
            $resolver->resolve(
                'linkedin',
                'https://www.linkedin.com/in/example/'
            );
            self::fail('Expected ResolverException');
        } catch (ResolverException $exception) {
            self::assertSame('invalid_reference', $exception->kind);
            self::assertFalse($exception->retryable);
            self::assertSame(1, $fake->calls);
        }
    }
}
vendor/bin/phpunit tests
php -l src/IdentityResolver.php
php -l public/index.php

Security, observability, and deployment

Terminate HTTPS at the web server, expose only public/ as the document root, and run PHP-FPM as a user that can write only the SQLite database directory. Keep Composer development packages and environment files outside the public tree.

The endpoint checks exact domains and subdomains, which blocks hosts such as facebook.com.attacker.example. Redirects are disabled at the upstream boundary, and only HTTPS is permitted. The service receives public profile URLs, but those values may still be sensitive in aggregate; the logs therefore record platform, status, attempt, and failure kind without recording submitted URLs or response bodies.

Send structured logs to your normal log collector and alert on sustained identity_resolver_transport_failure, identity_resolver_retry, or elevated 503 responses. HTTP 429 should be treated as capacity pressure, not as proof that a request is invalid. At larger volumes, move resolution to a bounded background queue and make submissions explicitly pending rather than increasing synchronous retry counts.

Deploy with reproducible dependencies, run the schema migration before switching traffic, and verify that the production process receives all three environment variables:

composer install --no-dev --classmap-authoritative
vendor/bin/phpunit tests
php -l src/IdentityResolver.php
php -l public/index.php

Common failures and final verification

  • Every request returns 401: the directory write token is missing or the caller omitted Authorization: Bearer YOUR_DIRECTORY_WRITE_TOKEN. This is local application security, not service authentication.
  • A valid-looking link returns 422: confirm HTTPS, the platform-domain pairing, and the currently supported reference formats in the official documentation.
  • Responses become 503: inspect structured events for timeouts, HTTP 429, or upstream server errors. Do not convert these into permanent validation failures.
  • SQLite reports a write error: verify the database directory exists and is writable by PHP-FPM, while remaining inaccessible from the web root.
  • Tests accidentally reach the network: construct the resolver with FakeTransport; integration tests against the public endpoint should be separate and explicitly enabled.

Before release, verify that:

  • The documentation still says the endpoint needs no token or API key.
  • The request uses exactly GET, the documented endpoint, platform, and url.
  • Facebook, Instagram, and LinkedIn links each resolve successfully.
  • A malformed domain is rejected before any external call.
  • HTTP 400 is not retried, while 429 and server failures receive bounded retries.
  • No submitted URL, response body, bearer token, or nonexistent service key appears in logs.
  • The database stores all three normalized public identity objects atomically.

The important result is not merely cleaner URLs. The directory now has a deliberate identity boundary: messy public references enter at one side, while stable application objects leave the other. That boundary is what keeps tomorrow’s search, deduplication, and profile features from inheriting today’s inconsistent input.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.