Vodiči

Native PHP: Standardize Community Directory Links with AI Identity Resolution

Native PHP: Standardizirajte poveznice imenika zajednice uz AI razlučivanje identiteta

A community directory rarely receives clean social-profile data. One member pastes a full Instagram URL, another enters a LinkedIn identifier, and someone else submits a Facebook profile in a format copied from a mobile browser. Saving those values verbatim creates duplicate records, inconsistent links, and brittle display logic.

The Identity Resolver solves that boundary problem. This tutorial builds a production-oriented Native PHP 8.3 endpoint that accepts Facebook, Instagram, and LinkedIn references, resolves them through one external service, validates the response defensively, and stores a stable identity object in a small community directory.

Get access before writing integration code

Start with the Identity Resolver service and plan page, then read the official service documentation. The current public endpoint requires no account, subscription token, or API key.

  • Registration: registration is not required for the current public endpoint.
  • Login: there is no login step before the first request.
  • Token location: there is no token to copy and no authorization header to configure.

This distinction matters operationally. Do not invent an empty bearer token or commit a placeholder authorization header. If authentication is introduced later, follow the then-current documentation and put the credential in environment-backed configuration rather than source code.

The exact request is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Send platform plus one supported reference parameter: username, id, identifier, profile, or url.

Make a minimal test using a real public profile URL that you are permitted to process:

curl --get \
  "https://ai.mihajlo.mk/api/identity-resolver/v1/resolve" \
  --data-urlencode "platform=instagram" \
  --data-urlencode "url=YOUR_PUBLIC_PROFILE_URL"

No credential needs to be stored before continuing. Store only non-secret runtime settings in .env:

IDENTITY_RESOLVER_ENDPOINT=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
IDENTITY_RESOLVER_CONNECT_TIMEOUT_MS=1500
IDENTITY_RESOLVER_TIMEOUT_MS=5000

Architecture and trade-offs

The application has four boundaries: an HTTP controller validates submissions, an resolver client owns remote-service behavior, a transport isolates cURL, and a DTO maps the remote JSON into the directory’s domain model. SQLite keeps the example deployable for a freelancer or small community team, while the repository query can later move to PostgreSQL without changing the resolver.

Resolution happens synchronously so the submitter receives an immediate result. That is appropriate while traffic is modest and the five-second upper bound is acceptable. A busier directory should save a pending submission and resolve it in a worker, but adding a queue prematurely would obscure the important failure semantics.

Use PHP 8.3 with the cURL, JSON, PDO, and PDO SQLite extensions, plus Composer and PHPUnit 11. The project layout is:

community-directory/
├── .env
├── composer.json
├── database/schema.sql
├── public/normalize.php
├── src/Http/{CurlTransport,HttpResponse,Transport,TransportException}.php
├── src/Identity/{IdentityResolver,ResolverFailure,ResolvedIdentity}.php
├── tests/IdentityResolverTest.php
└── var/
{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "Directory\\": "src/"
    }
  }
}
composer install
set -a
. ./.env
set +a

Build a bounded cURL transport

The transport fixes connection and total timeouts, disables redirects, permits HTTPS only, and returns status, headers, and body without trying to understand identity data. Put each class below in the correspondingly named file under src/Http.

<?php
// HttpResponse.php
namespace Directory\Http;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body,
    ) {}
}

// Transport.php
namespace Directory\Http;

interface Transport
{
    public function get(
        string $url,
        array $query,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse;
}

// TransportException.php
namespace Directory\Http;

final class TransportException extends \RuntimeException {}

// CurlTransport.php
namespace Directory\Http;

final class CurlTransport implements Transport
{
    public function get(
        string $url,
        array $query,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse {
        $headers = [];
        $handle = curl_init($url . '?' . http_build_query(
            $query, '', '&', PHP_QUERY_RFC3986
        ));

        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $timeoutMs,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
            CURLOPT_HEADERFUNCTION => static function (
                $curl,
                string $line
            ) use (&$headers): int {
                if (str_contains($line, ':')) {
                    [$name, $value] = explode(':', $line, 2);
                    $headers[strtolower(trim($name))] = trim($value);
                }
                return strlen($line);
            },
        ]);

        $body = curl_exec($handle);

        if ($body === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new TransportException($message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResponse($status, $headers, $body);
    }
}

Resolve and map identities defensively

The service contract promises a normalized public identity response, but application code should not guess undocumented fields. The DTO therefore wraps the complete returned object inside an application-owned envelope. Consumers can store and return it without coupling themselves to speculative property names.

The client retries only network errors, HTTP 429, and server failures. Validation and authentication failures are not retried. The maximum is three attempts, with short exponential backoff or a bounded numeric Retry-After value.

<?php
namespace Directory\Identity;

use Directory\Http\Transport;
use Directory\Http\TransportException;

final readonly class ResolvedIdentity
{
    public function __construct(
        public string $platform,
        public string $submitted,
        public array $identity,
    ) {}

    public function toArray(): array
    {
        return [
            'platform' => $this->platform,
            'submitted' => $this->submitted,
            'identity' => $this->identity,
        ];
    }
}

final class ResolverFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly bool $retryable,
        public readonly ?int $status = null,
    ) {
        parent::__construct($kind);
    }
}

final class IdentityResolver
{
    private const PLATFORMS = ['facebook', 'instagram', 'linkedin'];
    private const REFERENCES = [
        'username', 'id', 'identifier', 'profile', 'url'
    ];

    private \Closure $sleep;

    public function __construct(
        private readonly Transport $transport,
        private readonly string $endpoint,
        private readonly int $connectTimeoutMs = 1500,
        private readonly int $timeoutMs = 5000,
        ?\Closure $sleep = null,
    ) {
        $this->sleep = $sleep
            ?? static fn (int $microseconds) => usleep($microseconds);
    }

    public function resolve(
        string $platform,
        string $referenceType,
        string $value,
    ): ResolvedIdentity {
        $platform = strtolower(trim($platform));
        $value = trim($value);

        if (!in_array($platform, self::PLATFORMS, true)) {
            throw new \InvalidArgumentException('Unsupported platform');
        }

        if (!in_array($referenceType, self::REFERENCES, true)) {
            throw new \InvalidArgumentException('Unsupported reference type');
        }

        if ($value === '' || strlen($value) > 2048) {
            throw new \InvalidArgumentException('Invalid reference value');
        }

        for ($attempt = 0; $attempt < 3; $attempt++) {
            try {
                $response = $this->transport->get(
                    $this->endpoint,
                    ['platform' => $platform, $referenceType => $value],
                    $this->connectTimeoutMs,
                    $this->timeoutMs,
                );
            } catch (TransportException) {
                if ($attempt === 2) {
                    throw new ResolverFailure('network_failure', true);
                }
                ($this->sleep)(100000 * (2 ** $attempt));
                continue;
            }

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $data = json_decode(
                        $response->body,
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );
                } catch (\JsonException) {
                    throw new ResolverFailure('invalid_response', false);
                }

                if (!is_array($data) || array_is_list($data)) {
                    throw new ResolverFailure('invalid_response', false);
                }

                return new ResolvedIdentity($platform, $value, $data);
            }

            $retryable = $response->status === 429
                || $response->status >= 500;

            if ($retryable && $attempt < 2) {
                $retryAfter = $response->headers['retry-after'] ?? '';
                $delay = ctype_digit($retryAfter)
                    ? min((int) $retryAfter, 2) * 1000000
                    : 100000 * (2 ** $attempt);
                ($this->sleep)($delay);
                continue;
            }

            $kind = match (true) {
                $response->status === 429 => 'rate_limited',
                in_array($response->status, [401, 403], true)
                    => 'authentication_failure',
                $response->status >= 500 => 'upstream_unavailable',
                default => 'rejected',
            };

            throw new ResolverFailure(
                $kind,
                $retryable,
                $response->status
            );
        }

        throw new ResolverFailure('upstream_unavailable', true);
    }
}

Persist a normalized directory record

Create the database schema with a uniqueness constraint covering the platform, reference type, and submitted value. Repeated submissions update the resolved object instead of producing duplicates.

PRAGMA journal_mode = WAL;

CREATE TABLE IF NOT EXISTS directory_identities (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    platform TEXT NOT NULL,
    reference_type TEXT NOT NULL,
    submitted_value TEXT NOT NULL,
    identity_json TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    UNIQUE (platform, reference_type, submitted_value)
);
mkdir -p var
sqlite3 var/directory.sqlite < database/schema.sql

The controller accepts JSON owned by our application, calls the resolver, and stores the normalized response. It logs failure categories and status codes, but never profile values or upstream bodies.

<?php
declare(strict_types=1);

use Directory\Http\CurlTransport;
use Directory\Identity\IdentityResolver;
use Directory\Identity\ResolverFailure;

require dirname(__DIR__) . '/vendor/autoload.php';
header('Content-Type: application/json');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['error' => 'method_not_allowed']);
    exit;
}

try {
    $input = json_decode(
        file_get_contents('php://input'),
        true,
        512,
        JSON_THROW_ON_ERROR
    );

    foreach (['platform', 'referenceType', 'value'] as $field) {
        if (!isset($input[$field]) || !is_string($input[$field])) {
            throw new InvalidArgumentException('Invalid input');
        }
    }

    $resolver = new IdentityResolver(
        new CurlTransport(),
        getenv('IDENTITY_RESOLVER_ENDPOINT')
            ?: 'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
        (int) (getenv('IDENTITY_RESOLVER_CONNECT_TIMEOUT_MS') ?: 1500),
        (int) (getenv('IDENTITY_RESOLVER_TIMEOUT_MS') ?: 5000),
    );

    $identity = $resolver->resolve(
        $input['platform'],
        $input['referenceType'],
        $input['value'],
    );

    $pdo = new PDO(
        'sqlite:' . dirname(__DIR__) . '/var/directory.sqlite',
        null,
        null,
        [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
    );
    $pdo->exec('PRAGMA busy_timeout = 3000');

    $statement = $pdo->prepare(
        'INSERT INTO directory_identities
         (platform, reference_type, submitted_value, identity_json, updated_at)
         VALUES (:platform, :type, :value, :identity, :updated)
         ON CONFLICT(platform, reference_type, submitted_value)
         DO UPDATE SET identity_json = excluded.identity_json,
                       updated_at = excluded.updated_at'
    );
    $statement->execute([
        'platform' => $identity->platform,
        'type' => $input['referenceType'],
        'value' => $identity->submitted,
        'identity' => json_encode(
            $identity->identity,
            JSON_THROW_ON_ERROR
        ),
        'updated' => gmdate('c'),
    ]);

    http_response_code(201);
    echo json_encode($identity->toArray(), JSON_THROW_ON_ERROR);
} catch (InvalidArgumentException | JsonException $exception) {
    http_response_code(422);
    echo json_encode(['error' => 'invalid_submission']);
} catch (ResolverFailure $exception) {
    error_log(json_encode([
        'event' => 'identity_resolution_failed',
        'kind' => $exception->kind,
        'status' => $exception->status,
        'retryable' => $exception->retryable,
    ]));
    http_response_code($exception->retryable ? 503 : 502);
    echo json_encode(['error' => $exception->kind]);
} catch (Throwable $exception) {
    error_log(json_encode(['event' => 'directory_write_failed']));
    http_response_code(500);
    echo json_encode(['error' => 'internal_error']);
}

Test retries without making network calls

A deterministic fake transport keeps tests fast and proves both response mapping and retry policy. The injected sleeper prevents real delays.

<?php
use Directory\Http\HttpResponse;
use Directory\Http\Transport;
use Directory\Identity\IdentityResolver;
use Directory\Identity\ResolverFailure;
use PHPUnit\Framework\TestCase;

final class IdentityResolverTest extends TestCase
{
    public function testRetriesServerFailureThenMapsObject(): void
    {
        $fake = new class([
            new HttpResponse(503, [], '{}'),
            new HttpResponse(200, [], '{"stable":"identity"}'),
        ]) implements Transport {
            public int $calls = 0;
            public function __construct(private array $responses) {}
            public function get(
                string $url,
                array $query,
                int $connectTimeoutMs,
                int $timeoutMs
            ): HttpResponse {
                $this->calls++;
                return array_shift($this->responses);
            }
        };

        $resolver = new IdentityResolver(
            $fake,
            'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
            sleep: static fn (int $delay) => null,
        );

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

        self::assertSame(['stable' => 'identity'], $result->identity);
        self::assertSame(2, $fake->calls);
    }

    public function testDoesNotRetryClientRejection(): void
    {
        $fake = new class implements Transport {
            public int $calls = 0;
            public function get(
                string $url,
                array $query,
                int $connectTimeoutMs,
                int $timeoutMs
            ): HttpResponse {
                $this->calls++;
                return new HttpResponse(400, [], '{}');
            }
        };

        try {
            (new IdentityResolver(
                $fake,
                'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'
            ))->resolve('facebook', 'username', 'example');
            self::fail('Expected ResolverFailure');
        } catch (ResolverFailure $failure) {
            self::assertSame('rejected', $failure->kind);
            self::assertSame(1, $fake->calls);
        }
    }
}
vendor/bin/phpunit tests
php -S 127.0.0.1:8080 -t public

curl -X POST "http://127.0.0.1:8080/normalize.php" \
  -H "Content-Type: application/json" \
  -d '{"platform":"linkedin","referenceType":"url","value":"YOUR_PUBLIC_PROFILE_URL"}'

Security, observability, and deployment

Although the resolver is public, submitted profile references are still user data. Apply request-size limits at the web server, authorize directory edits, add CSRF protection if a browser session calls this endpoint, and avoid logging submitted URLs or returned identity documents. The fixed resolver endpoint also prevents user-controlled server-side requests.

Expose metrics for attempts, successful resolutions, latency, rate limits, upstream failures, invalid responses, and directory-write failures. Use low-cardinality labels such as failure kind and platform; never use usernames or URLs as metric labels.

In deployment, install production dependencies with composer install --no-dev --classmap-authoritative, provision the writable var directory, run the schema before switching traffic, inject the three environment settings, and serve public as the document root. Multiple application hosts should use a shared production database rather than separate SQLite files.

Common failures

  • HTTP 400 or another client rejection: verify the platform and that exactly one supported reference parameter is being sent.
  • HTTP 401 or 403: the documented public endpoint needs no token, so check the endpoint, proxy, and current official documentation. Do not retry blindly.
  • HTTP 429: honor bounded backoff and return a retryable application failure after the attempt limit.
  • HTTP 5xx or network timeout: retry briefly, record the failure category, and avoid holding a PHP worker indefinitely.
  • Successful status with malformed JSON: reject it at the API boundary instead of storing partial or guessed fields.
  • SQLite locking: keep writes short, enable WAL and a busy timeout, or move to a shared database as concurrency grows.

Final verification checklist

  • The service documentation has been reviewed and no unnecessary token is configured.
  • Facebook, Instagram, and LinkedIn submissions use the exact HTTPS GET endpoint.
  • Only supported reference parameter names reach the external service.
  • Timeouts and the three-attempt retry ceiling are active.
  • Client and authentication failures are not retried.
  • Malformed responses cannot enter the directory.
  • Logs contain failure metadata but no profile references or response bodies.
  • PHPUnit passes with no external network access.
  • A repeated submission updates one directory record.
  • A real permitted public profile resolves correctly in the deployed environment.

Normalization is most valuable when it becomes a boundary rule rather than a cleanup task. Once every social reference enters the directory through one defensive resolver, the rest of the application can work with stable identity objects instead of rediscovering every strange way a person can paste a link.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.