Туториали

Native PHP 8.3: Resolve Social Media Links for Community Directories with Identity Resolver API

Native PHP 8.3: Разрешување врски до социјални медиуми за директориуми на заедници со Identity Resolver API

A community directory rarely receives tidy social identities. One member pastes a full Instagram URL, another submits a mobile Facebook link, and a third includes a LinkedIn profile with tracking parameters. Treating those strings as canonical identifiers creates duplicate records, brittle links, and cleanup work.

This tutorial builds a production-oriented Native PHP 8.3 service that accepts Facebook, Instagram, and LinkedIn profile URLs and sends them to the Identity Resolver API. The API normalizes each public reference into a stable identity object, while our application handles validation, timeouts, retries, failures, logging, and defensive response mapping.

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 credential to copy before your first request.

  1. Open the documentation and review the supported input forms.
  2. Review the service and plan page for current service information.
  3. Registration is not required for this public endpoint.
  4. Login is not required before testing it.
  5. Do not add an invented bearer token, API-key header, or placeholder credential. If authentication requirements change later, follow the official documentation rather than guessing an authentication scheme.

The exact request 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 uses url.

Replace the placeholder with a real public profile and make the first test without an authorization header:

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

The service contract guarantees a normalized public identity response, but this tutorial deliberately does not assume undocumented field names. The application validates that the response is a JSON object and preserves it as the upstream identity payload.

Architecture and trade-offs

The project exposes POST /profiles/normalize to the directory frontend. It validates the submitted platform and URL, calls the fixed upstream endpoint, maps the result into a domain object, and returns the normalized identity. An existing directory repository can persist that object only after the call succeeds.

Normalization remains synchronous because it is normally part of reviewing or saving one profile. A queue would add operational complexity and make immediate validation harder. If bulk imports become necessary, the same resolver class can later run inside a worker without changing its HTTP boundary.

Native cURL keeps dependencies small. The transport sits behind an interface so tests never contact the network.

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

Prerequisites and configuration

You need PHP 8.3 or newer, the cURL and JSON extensions, Composer, and PHPUnit 11 for tests.

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  }
}

Install dependencies with composer install. Put non-secret runtime settings in .env.example and copy it to an untracked .env for local development:

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

There is intentionally no token variable: the current endpoint needs no credential. In production, inject these values through the process manager or deployment platform. For the simple local file above, start the application with:

set -a
. ./.env
set +a
php -S 127.0.0.1:8080 -t public

Build a replaceable cURL transport

The transport owns cURL mechanics, while the resolver owns API policy. It disables redirects, applies separate connection and total timeouts, captures response headers, and converts network errors into a typed exception.

<?php
// src/Http.php
declare(strict_types=1);

namespace App;

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

final class TransportException extends \RuntimeException {}

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

final class CurlTransport implements HttpTransport
{
    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_HTTPHEADER => ['Accept: application/json'],
            CURLOPT_USERAGENT => 'community-directory/1.0',
            CURLOPT_HEADERFUNCTION => static function (
                mixed $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 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 class accepts only the three directory platforms, requires HTTPS, and verifies that the hostname belongs to the selected platform. This catches accidental mismatches before they consume upstream capacity.

Transient network errors, HTTP 429, and server-side 5xx responses receive at most three attempts. HTTP 4xx responses are not blindly retried. The backoff is bounded, and an integer Retry-After value is honored up to two seconds.

<?php
// src/IdentityResolver.php
declare(strict_types=1);

namespace App;

final readonly class IdentityResolution
{
    public function __construct(
        public string $platform,
        public string $submittedUrl,
        public array $publicIdentity,
    ) {}
}

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

final class IdentityResolver
{
    private \Closure $sleep;
    private \Closure $log;

    public function __construct(
        private HttpTransport $transport,
        private string $baseUrl,
        private int $connectTimeoutMs = 1500,
        private int $timeoutMs = 5000,
        ?\Closure $sleep = null,
        ?\Closure $log = null,
    ) {
        $this->sleep = $sleep ?? static fn (int $microseconds) =>
            usleep($microseconds);
        $this->log = $log ?? static function (array $context): void {};
    }

    public function resolve(string $platform, string $url): IdentityResolution
    {
        $platform = strtolower(trim($platform));
        $domains = [
            'facebook' => 'facebook.com',
            'instagram' => 'instagram.com',
            'linkedin' => 'linkedin.com',
        ];

        if (!isset($domains[$platform])) {
            throw new ResolverException('validation', 'Unsupported platform.');
        }
        if (strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
            throw new ResolverException('validation', 'Invalid profile URL.');
        }

        $parts = parse_url($url);
        $host = strtolower(rtrim((string) ($parts['host'] ?? ''), '.'));
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
        $domain = $domains[$platform];

        if ($scheme !== 'https' ||
            ($host !== $domain && !str_ends_with($host, '.' . $domain))) {
            throw new ResolverException(
                'validation',
                'The URL does not match the selected platform.'
            );
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->get(
                    rtrim($this->baseUrl, '/') . '/v1/resolve',
                    ['platform' => $platform, 'url' => $url],
                    $this->connectTimeoutMs,
                    $this->timeoutMs,
                );
            } catch (TransportException $exception) {
                ($this->log)([
                    'event' => 'identity_resolver_transport_error',
                    'attempt' => $attempt,
                ]);
                if ($attempt === 3) {
                    throw new ResolverException(
                        'transport',
                        'Identity service could not be reached.',
                        true
                    );
                }
                ($this->sleep)(100000 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status === 429 ||
                ($response->status >= 500 && $response->status <= 599)) {
                if ($attempt === 3) {
                    $kind = $response->status === 429
                        ? 'rate_limited'
                        : 'unavailable';
                    throw new ResolverException(
                        $kind,
                        'Identity service is temporarily unavailable.',
                        true
                    );
                }

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

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

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

            if (!is_array($payload) || $payload === [] || array_is_list($payload)) {
                throw new ResolverException(
                    'contract',
                    'Identity service returned an unexpected response.',
                    true
                );
            }

            ($this->log)([
                'event' => 'identity_resolver_success',
                'platform' => $platform,
                'attempt' => $attempt,
            ]);

            return new IdentityResolution($platform, $url, $payload);
        }

        throw new \LogicException('Retry loop terminated unexpectedly.');
    }
}

Expose the directory endpoint

The front controller returns predictable local errors without exposing cURL messages or upstream response bodies. A malformed submission receives 422, an exhausted transient failure receives 503, and an unexpected upstream contract receives 502.

<?php
// public/index.php
declare(strict_types=1);

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

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

header('Content-Type: application/json; charset=utf-8');

$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' ||
    $path !== '/profiles/normalize') {
    http_response_code(404);
    echo json_encode(['error' => 'not_found']);
    exit;
}

try {
    $input = json_decode(
        file_get_contents('php://input'),
        true,
        512,
        JSON_THROW_ON_ERROR
    );
    if (!is_array($input)) {
        throw new JsonException('Expected an object.');
    }

    $resolver = new IdentityResolver(
        new CurlTransport(),
        getenv('IDENTITY_RESOLVER_BASE_URL')
            ?: 'https://ai.mihajlo.mk/api/identity-resolver',
        (int) (getenv('IDENTITY_RESOLVER_CONNECT_TIMEOUT_MS') ?: 1500),
        (int) (getenv('IDENTITY_RESOLVER_TIMEOUT_MS') ?: 5000),
        log: static fn (array $context) =>
            error_log(json_encode($context, JSON_THROW_ON_ERROR)),
    );

    $result = $resolver->resolve(
        (string) ($input['platform'] ?? ''),
        (string) ($input['url'] ?? ''),
    );

    echo json_encode(
        ['data' => $result->publicIdentity],
        JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
    );
} catch (JsonException) {
    http_response_code(400);
    echo json_encode(['error' => 'invalid_json']);
} catch (ResolverException $exception) {
    $status = match ($exception->kind) {
        'validation' => 422,
        'rate_limited', 'transport', 'unavailable' => 503,
        default => 502,
    };
    http_response_code($status);
    echo json_encode([
        'error' => $exception->kind,
        'retryable' => $exception->retryable,
    ]);
}

Test without depending on the network

A deterministic fake verifies mapping and retry behavior. It also proves that validation failures make no upstream request.

<?php
// tests/IdentityResolverTest.php
declare(strict_types=1);

namespace Tests;

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

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

        $result = (new IdentityResolver($fake, 'https://service.test'))
            ->resolve('instagram', 'https://www.instagram.com/example/');

        self::assertSame(
            ['stable' => 'public-value'],
            $result->publicIdentity
        );
        self::assertSame('instagram', $fake->queries[0]['platform']);
    }

    public function testRetriesRateLimitThenSucceeds(): void
    {
        $fake = new QueueTransport([
            new HttpResponse(429, ['retry-after' => '1'], '{}'),
            new HttpResponse(200, [], '{"stable":"value"}'),
        ]);
        $delays = [];

        $resolver = new IdentityResolver(
            $fake,
            'https://service.test',
            sleep: static function (int $delay) use (&$delays): void {
                $delays[] = $delay;
            }
        );

        $resolver->resolve('linkedin', 'https://linkedin.com/in/example');
        self::assertCount(2, $fake->queries);
        self::assertSame([1000000], $delays);
    }

    public function testRejectsMismatchedDomainWithoutCallingTransport(): void
    {
        $fake = new QueueTransport([]);

        $this->expectException(ResolverException::class);
        try {
            (new IdentityResolver($fake, 'https://service.test'))
                ->resolve('facebook', 'https://example.com/member');
        } finally {
            self::assertCount(0, $fake->queries);
        }
    }
}

final class QueueTransport implements HttpTransport
{
    public array $queries = [];

    public function __construct(private array $responses) {}

    public function get(
        string $url,
        array $query,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse {
        $this->queries[] = $query;
        return array_shift($this->responses);
    }
}

Run the suite with vendor/bin/phpunit tests.

Security, observability, and deployment

The fixed upstream base URL prevents user-controlled server-side requests. URL validation rejects HTTP links, unrelated domains, oversized input, and platform mismatches. Apply request-body limits and per-client rate limits at the web server or gateway as well.

Profile URLs can contain personal identifiers and tracking parameters. Do not log submitted URLs or complete upstream payloads. The example logs only event type, platform, and attempt number. In production, add a request correlation identifier, latency, final status, and retry count without recording identity data.

Deploy with the cURL extension enabled, production dependencies installed, and environment variables injected by the runtime. Keep .env out of version control. Configure PHP-FPM or the container environment explicitly; do not assume a development shell file will be loaded automatically.

Use health monitoring for error-rate and latency changes, but do not make a live third-party request from every application health probe. That turns transient upstream trouble into unnecessary local restarts.

Common failure patterns

  • HTTP 422 locally: the platform is unsupported, the URL is malformed, or its hostname does not match the selected platform.
  • HTTP 503 locally: the service was unreachable, rate-limited the request, or remained unavailable after bounded retries.
  • HTTP 502 locally: the upstream rejected the reference or returned data outside the documented top-level JSON-object expectation.
  • Immediate cURL failure: confirm that ext-curl, DNS, TLS certificates, and outbound HTTPS access are available.
  • Valid but unresolved profile: confirm that the profile is public and that the selected platform and input form are supported by the official documentation.

Final verification checklist

  • The application calls exactly GET /api/identity-resolver/v1/resolve.
  • Each request sends platform and url.
  • No token, API key, or invented authentication header is present.
  • Facebook, Instagram, and LinkedIn hosts are validated before the request.
  • Connection and response timeouts are bounded.
  • Only network errors, 429, and 5xx responses are retried.
  • The response is decoded and validated at the API boundary.
  • Tests use a deterministic fake transport and never call the public service.
  • Logs exclude submitted URLs, upstream payloads, and credentials.
  • A successful normalized object is ready for the directory’s persistence layer.

Social links look like simple strings until they become long-lived application data. The durable solution is not a larger pile of URL-cleaning regular expressions; it is a narrow integration boundary with strict inputs, defensive output mapping, bounded failure behavior, and tests that make the network replaceable. With that boundary in place, the directory can treat three inconsistent social platforms as one dependable identity workflow.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.