Tutorials

Native PHP 8.3: Integrate Public Social Profile Verification with Manual Review

Native PHP 8.3: Integrate Public Social Profile Verification with Manual Review

A social profile is useful onboarding evidence, but it should never become an automatic verdict. Usernames change, public pages disappear, and two people can share similar names. A safer production design imports the normalized public identity, records exactly what the resolver returned, and pauses for a human decision before accepting it.

This tutorial builds that workflow in native PHP 8.3. A command submits a Facebook, Instagram, or LinkedIn reference to the Identity Resolver, maps the response at a defensive API boundary, and inserts a pending review into SQLite. A second command lets an authorized reviewer approve or reject the import without calling the external service again.

Get access before writing integration code

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

  1. Check the official documentation for the currently supported platforms and identifier forms.
  2. Review the service page for current availability and plan information.
  3. No registration is required for this public endpoint.
  4. No login is required before the first request.
  5. There is no token-copying step and no credential to store. Do not invent an API key or send an empty authorization header.

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

curl --get \
  --connect-timeout 2 \
  --max-time 8 \
  --data-urlencode "platform=instagram" \
  --data-urlencode "username=YOUR_PUBLIC_USERNAME" \
  "https://ai.mihajlo.mk/api/identity-resolver/v1/resolve"

Although there is no credential, configuration still belongs outside source control. Store the fixed service URL and local database path in .env. If authentication is introduced in a future documented API version, add a separate placeholder such as IDENTITY_RESOLVER_TOKEN=YOUR_SERVICE_TOKEN only when the official contract requires it.

IDENTITY_RESOLVER_BASE_URL=https://ai.mihajlo.mk/api/identity-resolver
ONBOARDING_DB=var/onboarding.sqlite

Architecture: import first, trust later

The workflow has four boundaries: a cURL transport, an Identity Resolver client, a review repository, and two thin commands. The resolver client accepts only known platforms and locator names. It treats the returned JSON as an opaque normalized identity object because the supplied contract does not guarantee individual response fields.

That choice is deliberate. Guessing that every response contains fields such as name, avatar, or verified creates a brittle integration. We validate that the response is a JSON object, preserve it, and attach our own review state separately.

The project uses synchronous resolution because an onboarding operator needs an immediate pending record. It retries only transient transport failures, HTTP 429 responses, and server errors. Validation and other client errors return immediately.

Prerequisites and project structure

You need PHP 8.3 or newer with cURL, PDO, and PDO SQLite, plus Composer. Create this layout:

profile-review/
├── bin/
│   ├── import-profile.php
│   ├── migrate.php
│   └── review-profile.php
├── src/
│   ├── Http.php
│   ├── IdentityResolver.php
│   └── ReviewRepository.php
├── tests/
│   └── IdentityResolverTest.php
├── var/
├── .env
├── .env.example
├── composer.json
└── phpunit.xml
{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}

Run composer install, copy .env.example, and keep .env plus var/*.sqlite out of version control.

Build a bounded native cURL transport

The transport fixes connection and total timeouts, disables redirects, exposes response headers, and converts network failures into a typed exception. Disabling redirects also prevents the client from following an unexpected upstream location.

<?php
// src/Http.php
namespace App;

final class TransportException extends \RuntimeException {}

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): HttpResponse;
}

final class CurlTransport implements Transport
{
    public function get(string $url, array $query): 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 => 2000,
            CURLOPT_TIMEOUT_MS => 8000,
            CURLOPT_USERAGENT => 'profile-review/1.0',
            CURLOPT_HEADERFUNCTION => static function (
                $curl,
                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);
            curl_close($handle);
            throw new TransportException($message);
        }

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

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

Map the API into explicit domain outcomes

The client returns either a normalized object or a structured failure. Logs contain the platform, locator type, attempt, and status, but never the submitted profile value or response body. Even public profile data can be sensitive when collected and correlated.

<?php
// src/IdentityResolver.php
namespace App;

enum FailureCode: string
{
    case InvalidInput = 'invalid_input';
    case RateLimited = 'rate_limited';
    case UpstreamUnavailable = 'upstream_unavailable';
    case UpstreamRejected = 'upstream_rejected';
    case MalformedResponse = 'malformed_response';
    case TransportFailure = 'transport_failure';
}

final readonly class Resolution
{
    private function __construct(
        public bool $ok,
        public ?array $identity,
        public ?FailureCode $failure,
        public ?int $httpStatus
    ) {}

    public static function success(array $identity): self
    {
        return new self(true, $identity, null, 200);
    }

    public static function failure(FailureCode $code, ?int $status): self
    {
        return new self(false, null, $code, $status);
    }
}

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

    public function __construct(
        private Transport $transport,
        private string $baseUrl,
        private mixed $sleep = null,
        private mixed $logger = null
    ) {}

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

        if (!in_array($platform, self::PLATFORMS, true)
            || !in_array($locator, self::LOCATORS, true)
            || $value === '') {
            return Resolution::failure(FailureCode::InvalidInput, null);
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->get(
                    rtrim($this->baseUrl, '/') . '/v1/resolve',
                    ['platform' => $platform, $locator => $value]
                );
            } catch (TransportException $exception) {
                $this->log($platform, $locator, $attempt, null, 'transport');

                if ($attempt === 3) {
                    return Resolution::failure(
                        FailureCode::TransportFailure,
                        null
                    );
                }

                $this->pause(200 * $attempt);
                continue;
            }

            $this->log(
                $platform,
                $locator,
                $attempt,
                $response->status,
                'response'
            );

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $data = json_decode(
                        $response->body,
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );
                } catch (\JsonException) {
                    return Resolution::failure(
                        FailureCode::MalformedResponse,
                        $response->status
                    );
                }

                if (!is_array($data) || array_is_list($data)) {
                    return Resolution::failure(
                        FailureCode::MalformedResponse,
                        $response->status
                    );
                }

                return Resolution::success($data);
            }

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

            if (!$transient) {
                return Resolution::failure(
                    FailureCode::UpstreamRejected,
                    $response->status
                );
            }

            if ($attempt < 3) {
                $retryAfter = $response->headers['retry-after'] ?? null;
                $delay = ctype_digit((string) $retryAfter)
                    ? min(2000, (int) $retryAfter * 1000)
                    : 200 * $attempt;
                $this->pause($delay);
            }
        }

        return Resolution::failure(
            $response->status === 429
                ? FailureCode::RateLimited
                : FailureCode::UpstreamUnavailable,
            $response->status
        );
    }

    private function pause(int $milliseconds): void
    {
        if (is_callable($this->sleep)) {
            ($this->sleep)($milliseconds);
            return;
        }
        usleep($milliseconds * 1000);
    }

    private function log(
        string $platform,
        string $locator,
        int $attempt,
        ?int $status,
        string $event
    ): void {
        $context = compact(
            'event', 'platform', 'locator', 'attempt', 'status'
        );

        if (is_callable($this->logger)) {
            ($this->logger)($context);
        }
    }
}

Persist an immutable candidate for manual review

The database stores the submitted reference, the complete normalized object, and a separate decision. The conditional update ensures that two reviewers cannot silently overwrite each other.

<?php
// src/ReviewRepository.php
namespace App;

final class ReviewRepository
{
    public function __construct(private \PDO $pdo) {}

    public function migrate(): void
    {
        $this->pdo->exec(
            "CREATE TABLE IF NOT EXISTS profile_reviews (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                platform TEXT NOT NULL,
                locator TEXT NOT NULL,
                locator_value TEXT NOT NULL,
                normalized_json TEXT NOT NULL,
                state TEXT NOT NULL CHECK (
                    state IN ('pending','approved','rejected')
                ),
                reviewer TEXT,
                reviewed_at TEXT,
                created_at TEXT NOT NULL
            )"
        );
    }

    public function enqueue(
        string $platform,
        string $locator,
        string $value,
        array $identity
    ): int {
        $statement = $this->pdo->prepare(
            'INSERT INTO profile_reviews
             (platform, locator, locator_value, normalized_json, state, created_at)
             VALUES (:platform, :locator, :value, :json, :state, :created)'
        );
        $statement->execute([
            'platform' => $platform,
            'locator' => $locator,
            'value' => $value,
            'json' => json_encode($identity, JSON_THROW_ON_ERROR),
            'state' => 'pending',
            'created' => gmdate(DATE_ATOM),
        ]);

        return (int) $this->pdo->lastInsertId();
    }

    public function decide(int $id, string $decision, string $reviewer): bool
    {
        if (!in_array($decision, ['approved', 'rejected'], true)) {
            throw new \InvalidArgumentException('Invalid decision');
        }

        $statement = $this->pdo->prepare(
            'UPDATE profile_reviews
             SET state = :state, reviewer = :reviewer, reviewed_at = :reviewed
             WHERE id = :id AND state = :pending'
        );
        $statement->execute([
            'state' => $decision,
            'reviewer' => $reviewer,
            'reviewed' => gmdate(DATE_ATOM),
            'id' => $id,
            'pending' => 'pending',
        ]);

        return $statement->rowCount() === 1;
    }
}

Wire the onboarding and review commands

A small bootstrap can load .env locally while allowing deployment-provided environment variables to win. It creates PDO with exception mode enabled. The migration command calls ReviewRepository::migrate().

<?php
// bin/import-profile.php
require dirname(__DIR__) . '/vendor/autoload.php';

foreach (parse_ini_file(dirname(__DIR__) . '/.env', false, INI_SCANNER_RAW) as $key => $value) {
    if (getenv($key) === false) {
        putenv("$key=$value");
    }
}

$pdo = new PDO('sqlite:' . dirname(__DIR__) . '/' . getenv('ONBOARDING_DB'));
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

[$script, $platform, $locator, $value] = $argv + [null, null, null, null];
if ($value === null) {
    fwrite(STDERR, "Usage: php bin/import-profile.php PLATFORM LOCATOR VALUE\n");
    exit(64);
}

$resolver = new App\IdentityResolver(
    new App\CurlTransport(),
    getenv('IDENTITY_RESOLVER_BASE_URL'),
    null,
    static fn(array $event) =>
        error_log(json_encode($event, JSON_THROW_ON_ERROR))
);

$result = $resolver->resolve($platform, $locator, $value);
if (!$result->ok) {
    fwrite(STDERR, "Import failed: {$result->failure->value}\n");
    exit(1);
}

$repository = new App\ReviewRepository($pdo);
$id = $repository->enqueue($platform, $locator, $value, $result->identity);
fwrite(STDOUT, "Pending review created: {$id}\n");
<?php
// bin/review-profile.php
require dirname(__DIR__) . '/vendor/autoload.php';

$env = parse_ini_file(dirname(__DIR__) . '/.env', false, INI_SCANNER_RAW);
$pdo = new PDO('sqlite:' . dirname(__DIR__) . '/' . $env['ONBOARDING_DB']);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

[$script, $id, $decision, $reviewer] = $argv + [null, null, null, null];
if (!$reviewer || !ctype_digit((string) $id)) {
    fwrite(STDERR, "Usage: php bin/review-profile.php ID approved|rejected REVIEWER\n");
    exit(64);
}

$changed = (new App\ReviewRepository($pdo))->decide(
    (int) $id,
    $decision,
    $reviewer
);

fwrite($changed ? STDOUT : STDERR, $changed
    ? "Review recorded\n"
    : "Record missing or already reviewed\n"
);
exit($changed ? 0 : 1);

After running the migration, import a profile with php bin/import-profile.php instagram username YOUR_PUBLIC_USERNAME. Inspect the pending record in an authenticated internal screen or administrative tool, compare the normalized public identity with the onboarding submission, and then run php bin/review-profile.php 1 approved [email protected].

In a web application, the reviewer argument must come from the authenticated staff session, not a form field. Render every stored value with HTML escaping. Approval should unlock only the business action you explicitly associate with this review; it should not be treated as proof of legal identity or account ownership.

Test retries and malformed boundaries deterministically

A fake transport makes tests fast and prevents accidental network calls. Injecting a sleeper also lets the rate-limit path execute without delaying the suite.

<?php
// tests/IdentityResolverTest.php
use App\FailureCode;
use App\HttpResponse;
use App\IdentityResolver;
use App\Transport;
use PHPUnit\Framework\TestCase;

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

    public function __construct(private array $responses) {}

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

final class IdentityResolverTest extends TestCase
{
    public function testItPreservesAnUnknownNormalizedObject(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(200, '{"opaque":{"stable":true}}')
        ]);

        $result = (new IdentityResolver($fake, 'https://service.test'))
            ->resolve('instagram', 'username', 'public-name');

        self::assertTrue($result->ok);
        self::assertSame(['opaque' => ['stable' => true]], $result->identity);
    }

    public function testItRetriesRateLimitingThenSucceeds(): void
    {
        $delays = [];
        $fake = new FakeTransport([
            new HttpResponse(429, '{}', ['retry-after' => '1']),
            new HttpResponse(200, '{"identity":"normalized"}'),
        ]);

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

        self::assertTrue(
            $resolver->resolve('linkedin', 'url', 'https://example.test/p')->ok
        );
        self::assertSame(2, $fake->calls);
        self::assertSame([1000], $delays);
    }

    public function testItDoesNotRetryClientErrors(): void
    {
        $fake = new FakeTransport([new HttpResponse(400, '{}')]);
        $result = (new IdentityResolver($fake, 'https://service.test'))
            ->resolve('facebook', 'id', '123');

        self::assertFalse($result->ok);
        self::assertSame(FailureCode::UpstreamRejected, $result->failure);
        self::assertSame(1, $fake->calls);
    }

    public function testItRejectsMalformedSuccessBodies(): void
    {
        $fake = new FakeTransport([new HttpResponse(200, 'not-json')]);
        $result = (new IdentityResolver($fake, 'https://service.test'))
            ->resolve('instagram', 'profile', 'public-profile');

        self::assertSame(FailureCode::MalformedResponse, $result->failure);
    }
}

Run the suite with vendor/bin/phpunit tests. Add repository tests against a temporary SQLite database, particularly one asserting that the second decision on the same row returns false.

Production security, observability, and deployment

Keep the resolver base URL under operator control; never accept it from an onboarding request. The fixed host, disabled redirects, platform allowlist, and locator allowlist reduce SSRF and query-injection risk. Apply input-length limits before resolution, restrict the review interface to authorized staff, and define a retention period for rejected records and raw normalized responses.

Log one structured event per attempt with a correlation or onboarding ID when available. Useful metrics include attempts, latency, response-status class, failure code, pending-review age, and review outcome. Avoid profile values, response bodies, and full URLs in routine logs.

Deploy with cURL and PDO SQLite enabled, a writable var directory, and environment variables injected by the runtime. Run the migration once during deployment. For multiple application instances or sustained concurrent writes, replace SQLite with your normal transactional database while retaining the same repository contract and conditional review update.

Common failures

  • HTTP 400: verify the platform and that exactly one documented locator parameter is being sent. Do not retry unchanged input.
  • HTTP 429: honor a bounded Retry-After, stop after the retry budget, and let onboarding offer a later retry.
  • HTTP 5xx or network timeout: retry briefly, then preserve a structured unavailable state rather than creating an empty review.
  • HTTP 2xx with invalid JSON: treat it as a malformed upstream response. Never approve from partially parsed data.
  • Review update affects zero rows: the record is missing or another reviewer already decided it. Reload instead of overwriting the decision.

Final verification checklist

  • The first test request uses the exact documented GET endpoint and sends no authorization header.
  • Only Facebook, Instagram, and LinkedIn plus documented locator names cross the API boundary.
  • Connection time, total response time, retries, and backoff are bounded.
  • Client and validation failures are not blindly retried.
  • The normalized response is validated as an object without assuming undocumented fields.
  • Every successful import enters the pending state.
  • An authenticated human must explicitly approve or reject the candidate.
  • Tests run without external network access and cover success, throttling, client failure, and malformed JSON.
  • Logs exclude profile values, response bodies, and credentials.
  • Deployment provides writable storage, migrations, monitoring, and a retention policy.

The important result is not merely that PHP can resolve a social profile. It is that the integration knows where automation should stop. Normalize the public evidence, preserve uncertainty, and make the consequential decision visible, reviewable, and auditable.

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.