Laravel воведување: Разрешете ги профилите на социјалните мрежи со API за разрешување на идентитет и рачен преглед
Social-profile onboarding looks simple until a user pastes a LinkedIn URL, another enters an Instagram handle, and a third supplies a Facebook identifier. Saving those values verbatim leaves the application with inconsistent data and no reliable way to distinguish a resolved public identity from an unverified claim.
This tutorial builds a production-oriented Laravel integration that sends those references to the Identity Resolver API, stores its normalized public identity object, and requires the user to review the result before approval. The API boundary remains deliberately defensive: the service response is treated as an opaque normalized object rather than relying on undocumented fields.
Get access before writing integration code
Begin with the Identity Resolver service page, then open the official documentation. The current public endpoint requires no account token or API key.
Consequently, there is no credential to copy into Laravel before your first request. Optional account access is available through the site's registration page and login page, but neither registration nor login is part of the current public-endpoint authentication contract. If the service introduces authenticated plans later, follow the documentation and place any issued credential in environment-backed configuration; never commit it to source control.
The exact operation is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. It accepts platform plus one supported reference parameter: username, id, identifier, profile, or url. Test it with a public reference you are entitled to process:
curl --get \
--url 'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
--data-urlencode 'platform=instagram' \
--data-urlencode 'username=YOUR_PUBLIC_USERNAME' \
--header 'Accept: application/json'
There is no authorization header. Before building the feature, store the endpoint—not a fictional token—in .env:
IDENTITY_RESOLVER_URL=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
Expose it through config/services.php so configuration caching works during deployment:
'identity_resolver' => [
'url' => env(
'IDENTITY_RESOLVER_URL',
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'
),
],
Design the review boundary
The application will make the request synchronously after the user submits a social reference. A successful response creates a pending import and redirects to a review screen. Approval makes that stored import eligible for use; rejection preserves an auditable decision without presenting the data as verified.
A queue would reduce form latency, but it would also require polling, notifications, and more state transitions. For a single bounded GET during onboarding, a synchronous call is a reasonable trade-off. If response latency later harms conversion, the dedicated service boundary can move into a job without changing the domain model.
The relevant project structure is:
app/
Data/ResolvedIdentity.php
Exceptions/IdentityResolutionException.php
Http/Controllers/SocialProfileImportController.php
Models/SocialProfileImport.php
Services/IdentityResolver.php
database/migrations/
resources/views/social-profiles/
routes/web.php
tests/Feature/SocialProfileImportTest.php
This assumes PHP 8.3 or later, a Laravel application with authenticated users, a configured database, and Laravel's built-in HTTP client. No third-party HTTP package is required.
Persist a pending import safely
Create a migration with php artisan make:model SocialProfileImport -m. Use text for the normalized document because encrypted JSON can exceed an ordinary string column.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('social_profile_imports', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('submitted_platform', 32);
$table->string('submitted_reference_type', 32);
$table->text('submitted_reference');
$table->longText('resolved_identity');
$table->string('status', 20)->default('pending');
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('social_profile_imports');
}
};
The model encrypts the normalized document at rest. Approved records become the application's trusted imports; pending or rejected records must never be consumed as approved profile data.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class SocialProfileImport extends Model
{
protected $fillable = [
'user_id',
'submitted_platform',
'submitted_reference_type',
'submitted_reference',
'resolved_identity',
'status',
'reviewed_at',
];
protected function casts(): array
{
return [
'resolved_identity' => 'encrypted:array',
'reviewed_at' => 'immutable_datetime',
];
}
}
Build a defensive API boundary
The published contract promises a normalized public identity response, but this integration does not guess its field names. The boundary requires valid JSON whose top level is an object, then carries the normalized document into the domain.
<?php
namespace App\Data;
final readonly class ResolvedIdentity
{
public function __construct(public array $normalized)
{
}
}
// app/Exceptions/IdentityResolutionException.php
namespace App\Exceptions;
use RuntimeException;
use Throwable;
enum ResolutionFailure: string
{
case InvalidRequest = 'invalid_request';
case RateLimited = 'rate_limited';
case Upstream = 'upstream_failure';
case InvalidResponse = 'invalid_response';
case Network = 'network_failure';
}
final class IdentityResolutionException extends RuntimeException
{
public function __construct(
public ResolutionFailure $failure,
public ?int $upstreamStatus = null,
?Throwable $previous = null,
) {
parent::__construct($failure->value, 0, $previous);
}
}
The client uses two-second connection and eight-second overall timeouts. Because the operation is a read-only GET, transient network failures, HTTP 429, and server errors receive at most two retries. Other client errors are not retried.
<?php
namespace App\Services;
use App\Data\ResolvedIdentity;
use App\Exceptions\IdentityResolutionException;
use App\Exceptions\ResolutionFailure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use JsonException;
final class IdentityResolver
{
public function resolve(
string $platform,
string $referenceType,
string $reference,
): ResolvedIdentity {
$query = [
'platform' => $platform,
$referenceType => $reference,
];
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::acceptJson()
->connectTimeout(2)
->timeout(8)
->get(config('services.identity_resolver.url'), $query);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new IdentityResolutionException(
ResolutionFailure::Network,
previous: $exception,
);
}
usleep(($attempt === 1 ? 200 : 500) * 1000);
continue;
}
if ($response->successful()) {
return $this->map($response);
}
$transient = $response->status() === 429
|| $response->serverError();
if ($transient && $attempt < 3) {
usleep($this->delayMilliseconds($response, $attempt) * 1000);
continue;
}
$failure = match (true) {
$response->status() === 429 => ResolutionFailure::RateLimited,
$response->clientError() => ResolutionFailure::InvalidRequest,
default => ResolutionFailure::Upstream,
};
throw new IdentityResolutionException(
$failure,
$response->status(),
);
}
throw new IdentityResolutionException(ResolutionFailure::Upstream);
}
private function map(Response $response): ResolvedIdentity
{
try {
$object = json_decode(
$response->body(),
false,
512,
JSON_THROW_ON_ERROR,
);
if (! is_object($object)) {
throw new JsonException('Expected a top-level JSON object.');
}
$normalized = json_decode(
$response->body(),
true,
512,
JSON_THROW_ON_ERROR,
);
} catch (JsonException $exception) {
throw new IdentityResolutionException(
ResolutionFailure::InvalidResponse,
$response->status(),
$exception,
);
}
return new ResolvedIdentity($normalized);
}
private function delayMilliseconds(Response $response, int $attempt): int
{
$retryAfter = $response->header('Retry-After');
if (is_string($retryAfter) && ctype_digit($retryAfter)) {
return min(2000, (int) $retryAfter * 1000);
}
return $attempt === 1 ? 200 : 500;
}
}
The retry cap matters. Obeying an unbounded Retry-After value inside a web request can exhaust PHP workers, while blindly retrying every 4xx response only repeats invalid input.
Connect resolution to manual review
Validate both the platform and the name of the reference parameter. This prevents arbitrary query keys and keeps the upstream host fixed, even when the submitted value is itself a URL.
<?php
namespace App\Http\Controllers;
use App\Exceptions\IdentityResolutionException;
use App\Models\SocialProfileImport;
use App\Services\IdentityResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
final class SocialProfileImportController extends Controller
{
public function create(): View
{
return view('social-profiles.create');
}
public function store(
Request $request,
IdentityResolver $resolver,
): RedirectResponse {
$data = $request->validate([
'platform' => ['required', Rule::in([
'facebook', 'instagram', 'linkedin',
])],
'reference_type' => ['required', Rule::in([
'username', 'id', 'identifier', 'profile', 'url',
])],
'reference' => ['required', 'string', 'max:500'],
]);
try {
$identity = $resolver->resolve(
$data['platform'],
$data['reference_type'],
$data['reference'],
);
} catch (IdentityResolutionException $exception) {
Log::warning('identity_resolver_failed', [
'failure' => $exception->failure->value,
'upstream_status' => $exception->upstreamStatus,
'reference_hash' => hash('sha256', $data['reference']),
'user_id' => $request->user()->id,
]);
return back()
->withInput()
->withErrors([
'reference' => 'The public profile could not be resolved. Please verify the reference or try again later.',
]);
}
$import = SocialProfileImport::create([
'user_id' => $request->user()->id,
'submitted_platform' => $data['platform'],
'submitted_reference_type' => $data['reference_type'],
'submitted_reference' => $data['reference'],
'resolved_identity' => $identity->normalized,
'status' => 'pending',
]);
return redirect()->route('social-profiles.review', $import);
}
public function review(
Request $request,
SocialProfileImport $import,
): View {
abort_unless($import->user_id === $request->user()->id, 404);
return view('social-profiles.review', compact('import'));
}
public function decide(
Request $request,
SocialProfileImport $import,
): RedirectResponse {
$data = $request->validate([
'decision' => ['required', Rule::in(['approved', 'rejected'])],
]);
$updated = SocialProfileImport::query()
->whereKey($import->getKey())
->where('user_id', $request->user()->id)
->where('status', 'pending')
->update([
'status' => $data['decision'],
'reviewed_at' => now(),
]);
abort_unless($updated === 1, 409);
return redirect()->route('onboarding.next');
}
}
The conditional update makes the decision atomic: a double-click or repeated request cannot approve an already reviewed import. Register the routes behind authentication and retain Laravel's default CSRF protection:
use App\Http\Controllers\SocialProfileImportController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->group(function () {
Route::get('/onboarding/social-profile', [
SocialProfileImportController::class, 'create',
])->name('social-profiles.create');
Route::post('/onboarding/social-profile', [
SocialProfileImportController::class, 'store',
])->name('social-profiles.store');
Route::get('/onboarding/social-profile/{import}/review', [
SocialProfileImportController::class, 'review',
])->name('social-profiles.review');
Route::post('/onboarding/social-profile/{import}/decision', [
SocialProfileImportController::class, 'decide',
])->name('social-profiles.decide');
});
The create view needs selectors for platform and reference type plus a text input. The review view should display the submitted value beside the escaped normalized document and offer explicit decisions:
<h2>Review imported social profile</h2>
<p>Platform: {{ $import->submitted_platform }}</p>
<p>Submitted reference: {{ $import->submitted_reference }}</p>
<pre>{{ json_encode(
$import->resolved_identity,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
) }}</pre>
<form method="POST"
action="{{ route('social-profiles.decide', $import) }}">
@csrf
<button name="decision" value="approved" type="submit">
Approve profile
</button>
<button name="decision" value="rejected" type="submit">
Reject profile
</button>
</form>
Test the contract without calling production
Http::fake() makes the integration deterministic. An empty JSON object is intentional here: it verifies the documented top-level object boundary without fabricating response fields.
<?php
namespace Tests\Feature;
use App\Exceptions\IdentityResolutionException;
use App\Exceptions\ResolutionFailure;
use App\Models\User;
use App\Services\IdentityResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class SocialProfileImportTest extends TestCase
{
use RefreshDatabase;
public function test_resolution_creates_a_pending_review(): void
{
Http::fake([
'ai.mihajlo.mk/*' => Http::response(
'{}',
200,
['Content-Type' => 'application/json'],
),
]);
$user = User::factory()->create();
$response = $this->actingAs($user)->post(
route('social-profiles.store'),
[
'platform' => 'instagram',
'reference_type' => 'username',
'reference' => 'public-example',
],
);
$response->assertRedirect();
$this->assertDatabaseHas('social_profile_imports', [
'user_id' => $user->id,
'submitted_platform' => 'instagram',
'status' => 'pending',
]);
Http::assertSentCount(1);
}
public function test_validation_failure_is_not_retried(): void
{
Http::fake([
'ai.mihajlo.mk/*' => Http::response('{}', 422),
]);
try {
app(IdentityResolver::class)->resolve(
'instagram',
'username',
'bad-reference',
);
$this->fail('Expected resolution to fail.');
} catch (IdentityResolutionException $exception) {
$this->assertSame(
ResolutionFailure::InvalidRequest,
$exception->failure,
);
}
Http::assertSentCount(1);
}
}
Add tests for 429 and 5xx retry exhaustion, malformed JSON, cross-user access, rejection, approval, and repeated decisions. Run the suite with php artisan test.
Security, observability, and deployment
Public data still deserves deliberate handling. Obtain user consent, retain only what the onboarding flow needs, define a deletion policy, and restrict staff access. The example encrypts the normalized document and never logs the submitted reference. Laravel's APP_KEY must therefore remain stable across deployments and available to every application instance.
Keep the upstream URL fixed in server-controlled configuration. Validate the platform and reference-key allowlists, escape review output, enforce ownership on every read and decision, and apply application-level throttling to the submission route. Do not place normalized profiles, full URLs, response bodies, or future credentials in logs.
The structured failure name, upstream status, hashed reference, and user ID provide enough context for operational investigation. Monitor failure counts and latency by failure category. Alerting should distinguish rate limiting from invalid submissions; they demand different responses.
Before deployment, run php artisan migrate --force, then rebuild cached configuration with php artisan config:cache. Verify that production can reach the HTTPS endpoint and that its PHP workers permit the worst-case bounded request time. A rolling deployment must apply the migration before code begins writing imports.
Common failures
- Every request fails after deployment: inspect the cached endpoint value and rebuild configuration after changing
.env. - HTTP 4xx responses: check the selected platform, reference type, and public reference. These failures are intentionally not retried.
- HTTP 429 responses: reduce submission bursts and honor the bounded retry policy; do not loop indefinitely.
- Invalid-response failures: treat non-object or malformed JSON as an upstream contract problem rather than storing partial data.
- Encrypted values cannot be read: confirm that the original production
APP_KEYis present. Rotating it requires a deliberate data migration. - A review returns 404 or 409: the record belongs to another user, no longer exists, or has already been decided.
Final verification checklist
- Open the official documentation and confirm that the public endpoint still requires no token.
- Send a test
GETwithplatformand exactly one supported reference parameter. - Confirm the endpoint is loaded through
config/services.php. - Submit Facebook, Instagram, and LinkedIn references through the authenticated onboarding form.
- Verify that successful resolutions remain pending until an explicit review decision.
- Confirm that another user cannot view or decide the import.
- Exercise network, 429, 4xx, 5xx, and malformed-JSON paths with HTTP fakes.
- Check that logs contain structured failure metadata but no raw profile reference or response body.
- Approve one import and verify that only approved records are consumed downstream.
The important result is not merely a successful API call. It is a trustworthy boundary between user input, normalized public data, and application truth. Automation resolves the reference; a deliberate review step decides whether it belongs in the product. That separation keeps onboarding convenient without turning an external response into an unquestioned identity claim.