Native PHP 8.3: Беспрекорно воведување со увоз на социјален профил и рачен преглед
Onboarding forms often ask people to retype information that already exists on a public social profile. That creates friction, spelling mistakes, and a subtler problem: imported data can look authoritative even when it is incomplete or belongs to the wrong person.
A safer workflow combines automation with judgment. The application resolves a submitted Facebook, Instagram, or LinkedIn reference into a normalized public identity, saves it as a pending proposal, and lets the user review it before approval. Nothing silently overwrites the account.
This tutorial builds that workflow in Native PHP 8.3 using cURL, PDO with SQLite, and PHPUnit. The API boundary remains isolated, failures become explicit domain outcomes, and approval is a separate state transition.
Get access before writing integration code
Start with the Identity Resolver service page, then read the official documentation. The documentation is also the authoritative reference for any future changes to registration or login requirements.
The current public endpoint requires no account token and no API key. Consequently, there is no registration step, login step, plan selection, authorization header, or credential-copy screen before the first request. Do not invent a placeholder token or send an empty bearer header. Recheck the documentation before deployment in case the access model changes.
The exact request is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Supply platform plus one supported reference parameter: username, id, identifier, profile, or url.
Make a minimal test with a public profile reference you are authorized to process:
curl --fail-with-body --silent --show-error \
--get 'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
--data-urlencode 'platform=linkedin' \
--data-urlencode 'url=https://www.linkedin.com/in/YOUR_PUBLIC_HANDLE'
Because there is no credential, there is nothing secret to store. Put only the non-secret base URL in .env.example, then configure the same variable in the real process environment. Native PHP does not automatically load .env files, so the production service manager or container must inject it.
# .env.example
IDENTITY_RESOLVER_BASE_URL=https://ai.mihajlo.mk/api/identity-resolver
# Local shell
export IDENTITY_RESOLVER_BASE_URL='https://ai.mihajlo.mk/api/identity-resolver'
Architecture: import first, trust later
The browser submits a platform, reference type, and reference value. The application calls the resolver synchronously, maps the JSON object at the API boundary, and stores the complete normalized object with status pending_review. A review page renders that proposal without assuming undocumented response fields. Approval changes only the local onboarding record.
Synchronous resolution keeps this small project understandable and gives immediate feedback. Its trade-off is that the browser waits for the upstream request. Tight timeouts and bounded retries prevent that wait from becoming unbounded. A background job becomes worthwhile only when onboarding can continue independently of the import.
The project requires PHP 8.3+, the cURL and PDO SQLite extensions, Composer, and PHPUnit 11:
social-onboarding/
├── composer.json
├── .env.example
├── public/index.php
├── migrations/schema.sql
├── src/IdentityResolver.php
├── src/OnboardingRepository.php
└── tests/IdentityResolverTest.php
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Run composer install and composer dump-autoload. Keep vendor, the SQLite database, and local environment files out of version control.
Build a defensive API boundary
The transport owns HTTP mechanics. The resolver owns request validation, retry policy, status handling, and JSON mapping. The domain object intentionally exposes the normalized response as an associative array rather than claiming undocumented fields exist.
<?php
// src/IdentityResolver.php
declare(strict_types=1);
namespace App;
use Closure;
use JsonException;
use RuntimeException;
final readonly class HttpResult
{
public function __construct(
public int $status,
public string $body,
public array $headers = [],
public ?string $transportError = null,
) {}
}
interface Transport
{
public function get(string $url): HttpResult;
}
final class CurlTransport implements Transport
{
public function get(string $url): HttpResult
{
$headers = [];
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 2000,
CURLOPT_TIMEOUT_MS => 5000,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_HEADERFUNCTION => static function ($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);
$error = $body === false ? curl_error($handle) : null;
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResult($status, $body === false ? '' : $body, $headers, $error);
}
}
final readonly class NormalizedIdentity
{
private function __construct(public array $data) {}
public static function fromResponse(array $data): self
{
if ($data === [] || array_is_list($data)) {
throw new RuntimeException('Resolver returned no identity object.');
}
return new self($data);
}
public function toJson(): string
{
return json_encode(
$this->data,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
}
}
final class ResolverFailure extends RuntimeException
{
public function __construct(
public readonly string $reason,
string $message,
public readonly ?int $httpStatus = null,
) {
parent::__construct($message);
}
}
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 $baseUrl,
?callable $sleep = null,
) {
$this->sleep = Closure::fromCallable($sleep ?? 'sleep');
}
public function resolve(string $platform, string $type, string $value): NormalizedIdentity
{
$platform = strtolower(trim($platform));
$type = strtolower(trim($type));
$value = trim($value);
if (!in_array($platform, self::PLATFORMS, true)
|| !in_array($type, self::REFERENCES, true)
|| $value === '') {
throw new ResolverFailure('validation', 'Unsupported or empty profile reference.');
}
$query = http_build_query(
['platform' => $platform, $type => $value],
'',
'&',
PHP_QUERY_RFC3986
);
$url = rtrim($this->baseUrl, '/') . '/v1/resolve?' . $query;
for ($attempt = 1; $attempt <= 3; $attempt++) {
$response = $this->transport->get($url);
$retryable = $response->transportError !== null
|| $response->status === 429
|| $response->status >= 500;
if ($retryable && $attempt < 3) {
$retryAfter = filter_var(
$response->headers['retry-after'] ?? null,
FILTER_VALIDATE_INT
);
($this->sleep)(
$retryAfter === false ? $attempt : max(1, min(5, $retryAfter))
);
continue;
}
if ($response->transportError !== null) {
throw new ResolverFailure('transport', 'Resolver connection failed.');
}
if ($response->status === 429) {
throw new ResolverFailure('rate_limited', 'Resolver rate limit reached.', 429);
}
if ($response->status < 200 || $response->status >= 300) {
throw new ResolverFailure('upstream', 'Resolver rejected the request.', $response->status);
}
try {
$decoded = json_decode($response->body, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new ResolverFailure('invalid_response', 'Resolver returned invalid JSON.');
}
if (!is_array($decoded)) {
throw new ResolverFailure('invalid_response', 'Resolver returned an unexpected shape.');
}
return NormalizedIdentity::fromResponse($decoded);
}
throw new ResolverFailure('upstream', 'Resolver was unavailable.');
}
}
Retries apply only to connection failures, rate limits, and server errors. Client errors are not retried because repeating an invalid platform or reference will not repair it. The retry delay is capped, and redirect following is disabled so an unexpected redirect cannot silently move profile data to another host.
Persist an auditable review state
Create the database schema with an explicit status constraint. Retaining the submitted reference alongside the normalized JSON makes the review decision explainable.
-- migrations/schema.sql
CREATE TABLE onboarding_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT NOT NULL CHECK (status IN ('pending_review', 'approved')),
platform TEXT NOT NULL,
reference_type TEXT NOT NULL,
reference_value TEXT NOT NULL,
resolver_json TEXT NOT NULL,
created_at TEXT NOT NULL,
approved_at TEXT NULL
);
<?php
// src/OnboardingRepository.php
declare(strict_types=1);
namespace App;
use PDO;
use RuntimeException;
final class OnboardingRepository
{
public function __construct(private readonly PDO $pdo) {}
public function create(
string $platform,
string $type,
string $value,
NormalizedIdentity $identity
): int {
$statement = $this->pdo->prepare(
'INSERT INTO onboarding_profiles
(status, platform, reference_type, reference_value, resolver_json, created_at)
VALUES (:status, :platform, :type, :value, :json, :created)'
);
$statement->execute([
'status' => 'pending_review',
'platform' => $platform,
'type' => $type,
'value' => $value,
'json' => $identity->toJson(),
'created' => gmdate('c'),
]);
return (int) $this->pdo->lastInsertId();
}
public function find(int $id): array
{
$statement = $this->pdo->prepare(
'SELECT * FROM onboarding_profiles WHERE id = :id'
);
$statement->execute(['id' => $id]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
if ($row === false) {
throw new RuntimeException('Onboarding record not found.');
}
return $row;
}
public function approve(int $id): bool
{
$statement = $this->pdo->prepare(
"UPDATE onboarding_profiles
SET status = 'approved', approved_at = :approved
WHERE id = :id AND status = 'pending_review'"
);
$statement->execute(['approved' => gmdate('c'), 'id' => $id]);
return $statement->rowCount() === 1;
}
}
The conditional update makes approval idempotent at the database boundary. A second click cannot perform the transition twice.
Connect import and manual approval
In public/index.php, bootstrap PDO with exception mode, instantiate the service, and route POST actions separately. The following core handler assumes the form supplies platform, reference_type, reference_value, and a session CSRF token:
<?php
declare(strict_types=1);
use App\CurlTransport;
use App\IdentityResolver;
use App\OnboardingRepository;
use App\ResolverFailure;
require dirname(__DIR__) . '/vendor/autoload.php';
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
'cookie_secure' => isset($_SERVER['HTTPS']),
]);
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$pdo = new PDO(
'sqlite:' . dirname(__DIR__) . '/var/onboarding.sqlite',
null,
null,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$repository = new OnboardingRepository($pdo);
$baseUrl = getenv('IDENTITY_RESOLVER_BASE_URL');
if ($baseUrl === false) {
throw new RuntimeException('IDENTITY_RESOLVER_BASE_URL is not configured.');
}
$resolver = new IdentityResolver(new CurlTransport(), $baseUrl);
$action = $_GET['action'] ?? 'new';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$validCsrf = hash_equals(
$_SESSION['csrf'],
(string) ($_POST['csrf'] ?? '')
);
if (!$validCsrf) {
http_response_code(403);
exit('Invalid request token.');
}
}
if ($action === 'import' && $_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$platform = (string) ($_POST['platform'] ?? '');
$type = (string) ($_POST['reference_type'] ?? '');
$value = (string) ($_POST['reference_value'] ?? '');
$identity = $resolver->resolve($platform, $type, $value);
$id = $repository->create($platform, $type, $value, $identity);
header('Location: /?action=review&id=' . $id, true, 303);
exit;
} catch (ResolverFailure $failure) {
error_log(json_encode([
'event' => 'identity_resolution_failed',
'reason' => $failure->reason,
'http_status' => $failure->httpStatus,
]));
http_response_code($failure->reason === 'validation' ? 422 : 503);
exit('The public profile could not be imported. Check it or try again later.');
}
}
if ($action === 'approve' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
if (!$id || !$repository->approve($id)) {
http_response_code(409);
exit('This proposal is missing or already reviewed.');
}
header('Location: /?action=review&id=' . $id, true, 303);
exit;
}
if ($action === 'review') {
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
$record = $repository->find((int) $id);
$display = json_encode(
json_decode($record['resolver_json'], true, 32, JSON_THROW_ON_ERROR),
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
echo '<h2>Review imported profile</h2>';
echo '<pre>' . htmlspecialchars($display, ENT_QUOTES, 'UTF-8') . '</pre>';
echo '<p>Status: ' . htmlspecialchars($record['status'], ENT_QUOTES, 'UTF-8') . '</p>';
if ($record['status'] === 'pending_review') {
echo '<form method="post" action="/?action=approve">';
echo '<input type="hidden" name="csrf" value="'
. htmlspecialchars($_SESSION['csrf'], ENT_QUOTES, 'UTF-8') . '">';
echo '<input type="hidden" name="id" value="' . (int) $record['id'] . '">';
echo '<button type="submit">Approve profile</button></form>';
}
exit;
}
The remaining new view is an ordinary HTML form. Use select controls for the three platforms and five reference types, escape every redisplayed value, and never accept an approval status from the browser.
Test retries and boundary mapping without the network
A deterministic fake transport makes failure paths fast and repeatable. It also proves that a validation error never reaches the network.
<?php
// tests/IdentityResolverTest.php
declare(strict_types=1);
use App\HttpResult;
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 $results) {}
public function get(string $url): HttpResult
{
$this->urls[] = $url;
return array_shift($this->results);
}
}
final class IdentityResolverTest extends TestCase
{
public function testMapsAJsonObject(): void
{
$fake = new FakeTransport([
new HttpResult(200, '{"stable":"public-value"}'),
]);
$resolver = new IdentityResolver($fake, 'https://service.test', static fn () => null);
$identity = $resolver->resolve('linkedin', 'username', 'person');
self::assertSame('public-value', $identity->data['stable']);
self::assertStringContainsString(
'/v1/resolve?platform=linkedin&username=person',
$fake->urls[0]
);
}
public function testRetriesRateLimitThenSucceeds(): void
{
$fake = new FakeTransport([
new HttpResult(429, '{}', ['retry-after' => '1']),
new HttpResult(200, '{"ok":true}'),
]);
$delays = [];
$resolver = new IdentityResolver(
$fake,
'https://service.test',
static function (int $seconds) use (&$delays): void {
$delays[] = $seconds;
}
);
self::assertTrue($resolver->resolve('instagram', 'id', '123')->data['ok']);
self::assertSame([1], $delays);
self::assertCount(2, $fake->urls);
}
public function testRejectsInvalidInputWithoutCallingTransport(): void
{
$fake = new FakeTransport([]);
$resolver = new IdentityResolver($fake, 'https://service.test');
$this->expectException(ResolverFailure::class);
try {
$resolver->resolve('unknown', 'username', 'person');
} finally {
self::assertSame([], $fake->urls);
}
}
}
Run vendor/bin/phpunit tests. Add repository tests against a temporary SQLite database, especially assertions that the initial status is pending and that only the first approval succeeds.
Security, observability, and deployment
Public data is not consequence-free data. Import only references supplied for onboarding, explain what will be stored, and provide a way to correct or remove the result. Avoid logging the reference value, complete response, profile URL, or returned personal attributes. The structured log above records an operational category and HTTP status without copying profile data.
Protect import and approval with authenticated sessions, CSRF tokens, HTTPS, output escaping, and application-level request limits. Restrict the outbound destination to the configured service origin. If users can submit URLs, send them only as resolver query values; never fetch those URLs from your server.
In production, create the database directory with write permission for the PHP process, run the schema once during deployment, and keep the database outside the public document root. Inject IDENTITY_RESOLVER_BASE_URL through the service manager or container configuration. Configure the web server to serve only public, expose a lightweight health check that does not call the upstream service, and alert on sustained increases in transport, rate_limited, or invalid_response failures.
Common failures have distinct remedies:
- HTTP 4xx: verify the platform, reference type, and submitted public reference. Do not retry automatically.
- HTTP 429: respect the bounded backoff, show a temporary failure, and reduce repeated imports.
- HTTP 5xx or timeout: retry briefly, preserve the onboarding form, and let the user try later.
- Invalid JSON or an unexpected root type: reject the import instead of guessing at fields.
- SQLite write errors: verify directory ownership, available storage, and that migrations ran.
Final verification checklist
- Confirm the documentation still describes a public endpoint requiring no token or API key.
- Run the minimal cURL request with an authorized public profile reference.
- Verify all PHPUnit tests pass without external network access.
- Submit each supported platform through the onboarding form.
- Confirm the imported identity remains
pending_reviewuntil approval. - Check that malformed input, timeouts, rate limits, and server errors reveal no sensitive details.
- Approve once, repeat the request, and confirm the second transition is rejected safely.
- Inspect logs to ensure they contain failure categories, not profile data.
The important feature is not merely profile import. It is the boundary between a useful suggestion and an accepted identity. A resolver can normalize public references; only the onboarding participant should decide whether the result belongs in their account. That small pause for review turns a convenient API call into a dependable production workflow.