Native PHP 8.3: Pojednostavite uvođenje uz uvoz javnih profila na društvenim mrežama i ručnu provjeru
A social-profile import looks like a convenient onboarding shortcut until it silently attaches the wrong person, overwrites a carefully entered name, or turns a temporary upstream failure into a broken signup. The reliable design is deliberately less magical: resolve a public reference, preserve the normalized result, and require a human to approve it before any imported data becomes authoritative.
This tutorial builds that workflow in native PHP 8.3. A small onboarding endpoint calls the Identity Resolver, stores the returned public identity as a pending import, and leaves the final approve-or-reject decision to a restricted command. The implementation uses native cURL, SQLite, PHPUnit, bounded retries, defensive response mapping, and structured failure states.
Get access before writing integration code
Start with the official Identity Resolver service page, then read the official documentation. The service normalizes public Facebook, Instagram, and LinkedIn references into a stable identity object.
The current public endpoint requires no account token or API key. Consequently, the official registration requirements and login requirements for this endpoint are both “not required.” There is no plan-selection step, credential screen, or token-copy field before the first request. Do not fabricate an authorization header or place an unrelated platform credential in the application.
The exact contract is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Send platform together with one supported reference parameter: username, id, identifier, profile, or url.
Make a minimal test request, replacing the example username with a public reference you are permitted to process:
curl --get \
--connect-timeout 3 \
--max-time 10 \
--data-urlencode 'platform=instagram' \
--data-urlencode 'username=example' \
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'
A native PHP application has no universal environment-file convention, so this project uses .env.local for local development and real process environment variables in deployment. Record the authentication state explicitly. The token value is empty because there is currently nowhere to copy one from:
IDENTITY_RESOLVER_ENDPOINT=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
IDENTITY_RESOLVER_TOKEN=
DATABASE_PATH=var/app.sqlite
Do not send IDENTITY_RESOLVER_TOKEN. Keeping the empty setting documents the current contract while providing an obvious configuration point if authentication is introduced and documented later.
Architecture: imported does not mean approved
The request path stays synchronous so the person onboarding receives an immediate, meaningful result. The application validates the reference, calls the resolver, stores the normalized object, and returns 202 Accepted with a pending-review identifier. It does not copy remote attributes into the user record.
A reviewer then inspects the stored object and approves or rejects it through a command available only to authorized operators. Approval is a guarded state transition: only pending_review can become approved or rejected.
- API boundary: builds the fixed HTTPS request and classifies upstream failures.
- Domain boundary: accepts a JSON object without assuming undocumented response fields.
- Repository: stores the normalized payload and a one-way hash of the submitted reference.
- Review command: separates human judgment from automated resolution.
SQLite suits a small single-host application. For multiple application instances, replace it with a shared transactional database while preserving the conditional review update.
Create the PHP 8.3 project
You need PHP 8.3 or later, Composer, the cURL extension, PDO SQLite, and JSON support. Create this structure:
profile-onboarding/
├── bin/review-import.php
├── public/index.php
├── src/IdentityResolver.php
├── src/ImportRepository.php
├── tests/IdentityResolverTest.php
├── var/
├── .env.local
└── composer.json
Use PHPUnit 11 for deterministic boundary tests:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
composer install
set -a
. ./.env.local
set +a
mkdir -p var
php -S 127.0.0.1:8080 -t public
Do not commit .env.local or the SQLite database. In production, inject the same variables through the process manager or deployment platform rather than sourcing a file.
Build a defensive resolver client
The transport is an interface so tests never contact the network. cURL is restricted to HTTPS, redirects are disabled, certificate verification remains enabled, and both connection and total time are bounded.
<?php
// src/IdentityResolver.php
namespace App;
use Closure;
use JsonException;
use RuntimeException;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
public ?int $retryAfterSeconds = null,
) {}
}
interface Transport
{
public function get(string $url, int $connectTimeout, int $timeout): HttpResponse;
}
final class CurlTransport implements Transport
{
public function get(string $url, int $connectTimeout, int $timeout): HttpResponse
{
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_CONNECTTIMEOUT => $connectTimeout,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$raw = curl_exec($handle);
if ($raw === false) {
$message = curl_error($handle);
curl_close($handle);
throw new RuntimeException('Transport failure: ' . $message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($handle, CURLINFO_HEADER_SIZE);
curl_close($handle);
$headers = substr($raw, 0, $headerSize);
$body = substr($raw, $headerSize);
$retryAfter = preg_match('/^Retry-After:\s*(\d+)/mi', $headers, $match)
? (int) $match[1]
: null;
return new HttpResponse($status, $body, $retryAfter);
}
}
final class ResolverFailure extends RuntimeException
{
public function __construct(
public readonly string $reason,
public readonly bool $retryable,
public readonly ?int $upstreamStatus = null,
) {
parent::__construct($reason);
}
}
final readonly class NormalizedIdentity
{
private function __construct(public array $data) {}
public static function fromJson(string $json): self
{
try {
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
throw new ResolverFailure('malformed_response', false);
}
if (!is_array($data) || array_is_list($data)) {
throw new ResolverFailure('malformed_response', false);
}
return new self($data);
}
}
final class IdentityResolver
{
private const PARAMETERS = ['username', 'id', 'identifier', 'profile', 'url'];
private const PLATFORMS = ['facebook', 'instagram', 'linkedin'];
public function __construct(
private readonly Transport $transport,
private readonly string $endpoint,
private readonly Closure $sleep,
private readonly Closure $log,
) {}
public function resolve(
string $platform,
string $parameter,
string $reference,
): NormalizedIdentity {
$platform = strtolower(trim($platform));
$reference = trim($reference);
if (!in_array($platform, self::PLATFORMS, true)
|| !in_array($parameter, self::PARAMETERS, true)
|| $reference === ''
|| strlen($reference) > 500) {
throw new ResolverFailure('invalid_input', false);
}
$url = $this->endpoint . '?' . http_build_query(
['platform' => $platform, $parameter => $reference],
'',
'&',
PHP_QUERY_RFC3986,
);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->get($url, 3, 10);
} catch (RuntimeException) {
if ($attempt === 3) {
throw new ResolverFailure('upstream_unavailable', true);
}
$this->pause($attempt, null, 'transport');
continue;
}
if ($response->status === 200) {
return NormalizedIdentity::fromJson($response->body);
}
if ($response->status === 429 || $response->status >= 500) {
if ($attempt === 3) {
$reason = $response->status === 429
? 'rate_limited'
: 'upstream_unavailable';
throw new ResolverFailure($reason, true, $response->status);
}
$this->pause(
$attempt,
$response->retryAfterSeconds,
(string) $response->status,
);
continue;
}
if (in_array($response->status, [400, 404, 422], true)) {
throw new ResolverFailure('reference_rejected', false, $response->status);
}
if (in_array($response->status, [401, 403], true)) {
throw new ResolverFailure('authentication_configuration', false, $response->status);
}
throw new ResolverFailure('unexpected_upstream_response', false, $response->status);
}
throw new ResolverFailure('upstream_unavailable', true);
}
private function pause(int $attempt, ?int $retryAfter, string $cause): void
{
$milliseconds = $retryAfter === null
? 150 * (2 ** ($attempt - 1))
: min(max($retryAfter, 0), 2) * 1000;
($this->log)('identity_resolver_retry', [
'attempt' => $attempt,
'cause' => $cause,
'delay_ms' => $milliseconds,
]);
($this->sleep)($milliseconds);
}
}
Only transport errors, 429, and server failures are retried. Validation, authentication, and other client errors fail immediately. The maximum retry delay is capped, preventing an untrusted Retry-After value from tying up a PHP worker indefinitely.
Persist the pending import
The repository stores the complete normalized object because its field names are not assumed here. A hash retains correlation value without duplicating the submitted username or URL.
<?php
// src/ImportRepository.php
namespace App;
use PDO;
use RuntimeException;
final class ImportRepository
{
public function __construct(private readonly PDO $pdo)
{
$this->pdo->exec(
'CREATE TABLE IF NOT EXISTS profile_imports (
id TEXT PRIMARY KEY,
platform TEXT NOT NULL,
reference_type TEXT NOT NULL,
reference_hash TEXT NOT NULL,
normalized_json TEXT NOT NULL,
status TEXT NOT NULL CHECK (
status IN ("pending_review", "approved", "rejected")
),
reviewer TEXT,
created_at TEXT NOT NULL,
reviewed_at TEXT
)'
);
}
public function create(
string $platform,
string $type,
string $reference,
NormalizedIdentity $identity,
): string {
$id = bin2hex(random_bytes(16));
$statement = $this->pdo->prepare(
'INSERT INTO profile_imports
(id, platform, reference_type, reference_hash,
normalized_json, status, created_at)
VALUES (?, ?, ?, ?, ?, "pending_review", ?)'
);
$statement->execute([
$id,
$platform,
$type,
hash('sha256', $reference),
json_encode($identity->data, JSON_THROW_ON_ERROR),
gmdate('c'),
]);
return $id;
}
public function find(string $id): ?array
{
$statement = $this->pdo->prepare(
'SELECT * FROM profile_imports WHERE id = ?'
);
$statement->execute([$id]);
return $statement->fetch(PDO::FETCH_ASSOC) ?: null;
}
public function review(string $id, string $decision, string $reviewer): void
{
if (!in_array($decision, ['approved', 'rejected'], true)
|| trim($reviewer) === '') {
throw new RuntimeException('Invalid review decision');
}
$statement = $this->pdo->prepare(
'UPDATE profile_imports
SET status = ?, reviewer = ?, reviewed_at = ?
WHERE id = ? AND status = "pending_review"'
);
$statement->execute([$decision, $reviewer, gmdate('c'), $id]);
if ($statement->rowCount() !== 1) {
throw new RuntimeException('Import missing or already reviewed');
}
}
}
Expose the onboarding endpoint
The controller accepts JSON and returns a public workflow identifier, not the upstream response. An existing application should associate that identifier with its authenticated onboarding session and enforce CSRF protection when browser cookies are involved.
<?php
// public/index.php
use App\CurlTransport;
use App\IdentityResolver;
use App\ImportRepository;
use App\ResolverFailure;
require dirname(__DIR__) . '/vendor/autoload.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)
!== '/onboarding/profile-import') {
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 JsonException();
}
$logger = static function (string $event, array $context): void {
error_log(json_encode(
['event' => $event, 'context' => $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 $milliseconds) => usleep($milliseconds * 1000),
$logger,
);
$identity = $resolver->resolve(
(string) ($input['platform'] ?? ''),
(string) ($input['reference_type'] ?? ''),
(string) ($input['reference'] ?? ''),
);
$database = getenv('DATABASE_PATH') ?: dirname(__DIR__) . '/var/app.sqlite';
$pdo = new PDO('sqlite:' . $database, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$repository = new ImportRepository($pdo);
$id = $repository->create(
strtolower(trim((string) $input['platform'])),
(string) $input['reference_type'],
(string) $input['reference'],
$identity,
);
http_response_code(202);
echo json_encode(['import_id' => $id, 'status' => 'pending_review']);
} catch (ResolverFailure $failure) {
$status = $failure->reason === 'invalid_input'
|| $failure->reason === 'reference_rejected' ? 422 : 502;
http_response_code($status);
echo json_encode([
'error' => $failure->reason,
'retryable' => $failure->retryable,
]);
} catch (JsonException) {
http_response_code(400);
echo json_encode(['error' => 'invalid_json']);
} catch (Throwable $error) {
error_log(json_encode(['event' => 'profile_import_failed']));
http_response_code(500);
echo json_encode(['error' => 'internal_error']);
}
Production code should also apply a per-user request limit. The local database could otherwise be filled even if the upstream service successfully handles every request.
Make manual review explicit
The review command first supports inspection, then a separate decision. Operating-system or container access becomes the authorization boundary, so do not expose this script through the web server.
<?php
// bin/review-import.php
use App\ImportRepository;
require dirname(__DIR__) . '/vendor/autoload.php';
[$script, $action, $id, $reviewer] = array_pad($argv, 4, null);
$database = getenv('DATABASE_PATH') ?: dirname(__DIR__) . '/var/app.sqlite';
$pdo = new PDO('sqlite:' . $database, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$repository = new ImportRepository($pdo);
if ($action === 'show' && $id !== null) {
$record = $repository->find($id);
if ($record === null) {
fwrite(STDERR, "Import not found\n");
exit(1);
}
echo json_encode([
'id' => $record['id'],
'platform' => $record['platform'],
'status' => $record['status'],
'identity' => json_decode(
$record['normalized_json'],
true,
512,
JSON_THROW_ON_ERROR,
),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
exit;
}
if (in_array($action, ['approved', 'rejected'], true)
&& $id !== null
&& $reviewer !== null) {
$repository->review($id, $action, $reviewer);
echo "Review recorded\n";
exit;
}
fwrite(
STDERR,
"Usage: php bin/review-import.php show ID\n"
. " or: php bin/review-import.php approved|rejected ID REVIEWER\n"
);
exit(2);
Inspect first, compare the normalized public identity with information supplied during onboarding, and record a decision:
php bin/review-import.php show IMPORT_ID
php bin/review-import.php approved IMPORT_ID [email protected]
A separate application service can react to approval and selectively copy reviewed attributes. Keep that operation idempotent and record which values came from the import. Rejection should leave the user’s manually entered data untouched.
Test retries and boundary validation
A deterministic fake proves the retry policy without sleeping or relying on the live service:
<?php
// tests/IdentityResolverTest.php
use App\HttpResponse;
use App\IdentityResolver;
use App\ResolverFailure;
use App\Transport;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public array $urls = [];
public function __construct(private array $responses) {}
public function get(string $url, int $connectTimeout, int $timeout): HttpResponse
{
$this->urls[] = $url;
$next = array_shift($this->responses);
if ($next instanceof Throwable) {
throw $next;
}
return $next;
}
}
final class IdentityResolverTest extends TestCase
{
private function resolver(FakeTransport $transport): IdentityResolver
{
return new IdentityResolver(
$transport,
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
static fn (int $milliseconds) => null,
static fn (string $event, array $context) => null,
);
}
public function testMapsAnObjectWithoutAssumingItsFields(): void
{
$fake = new FakeTransport([
new HttpResponse(200, '{"fixture_value":true}'),
]);
$identity = $this->resolver($fake)
->resolve('instagram', 'username', 'example');
self::assertTrue($identity->data['fixture_value']);
self::assertStringContainsString(
'username=example',
$fake->urls[0],
);
}
public function testRetriesRateLimitThenSucceeds(): void
{
$fake = new FakeTransport([
new HttpResponse(429, '', 1),
new HttpResponse(200, '{"fixture_value":true}'),
]);
$this->resolver($fake)
->resolve('linkedin', 'url', 'https://example.test/profile');
self::assertCount(2, $fake->urls);
}
public function testDoesNotRetryValidationFailure(): void
{
$fake = new FakeTransport([new HttpResponse(400, '{}')]);
try {
$this->resolver($fake)
->resolve('facebook', 'id', '123');
self::fail('Expected ResolverFailure');
} catch (ResolverFailure $failure) {
self::assertSame('reference_rejected', $failure->reason);
self::assertFalse($failure->retryable);
self::assertCount(1, $fake->urls);
}
}
public function testRejectsNonObjectJson(): void
{
$fake = new FakeTransport([new HttpResponse(200, '[]')]);
$this->expectException(ResolverFailure::class);
$this->resolver($fake)
->resolve('instagram', 'identifier', 'example');
}
}
vendor/bin/phpunit tests
Security, observability, and deployment
Public does not mean consequence-free. Treat the normalized response as personal data: define retention, restrict reviewer access, encrypt storage where appropriate, and provide deletion behavior. Obtain a valid onboarding purpose before resolving someone’s profile. Never log the submitted reference, returned body, cookies, or future credentials.
Useful structured logs include the event, attempt number, upstream status category, bounded delay, terminal reason, and internal import identifier. Add counters for successes, validation rejections, rate limits, server failures, and review outcomes. Alert on sustained failure ratios rather than a single timeout.
Deploy behind a real web server with TLS, request-size limits, authenticated onboarding sessions, CSRF protection, and application-level throttling. Ensure the PHP worker can write only to the intended SQLite directory. Run database creation or migrations during deployment, not simultaneously across many workers. A health endpoint should test the application and database without calling the external resolver on every probe.
Common failures
- Unexpected 401 or 403: do not keep retrying. The public endpoint currently needs no token, so check the documented contract, endpoint, proxy behavior, and any accidentally injected authorization header.
- Repeated 429 responses: preserve the pending onboarding state, respect bounded backoff, and ask the user to retry later instead of creating an unbounded request loop.
- Valid JSON with an unexpected shape: classify it as
malformed_response. Never guess field names or partially approve the import. - Duplicate review attempts: the conditional update rejects the second decision, preventing an approval from silently overwriting a rejection.
- SQLite locking under load: shorten transactions or move the repository to a shared database before adding more web instances.
Final verification checklist
- The request uses exactly
GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. - No token or API key is sent for the current public endpoint.
platformand exactly one supported reference parameter are URL-encoded.- Connection and total timeouts are bounded.
- Only transport failures, rate limits, and server failures are retried.
- Unknown response fields are preserved, while malformed top-level data is rejected.
- The imported identity remains
pending_reviewuntil an authorized person acts. - Raw references and normalized responses are absent from operational logs.
- The fake-transport PHPUnit suite passes without network access.
- Approval is idempotent at the database boundary and does not overwrite user data automatically.
The important engineering decision is not the cURL call. It is refusing to confuse successful resolution with trustworthy application state. When imported identity data crosses a defensive boundary, enters a visible pending state, and earns human approval before changing an account, onboarding becomes faster without becoming reckless.