Laravel uvođenje: riješite društvene profile i pojednostavite korisničke recenzije
A social-profile field looks harmless until onboarding depends on it. People paste handles, profile URLs, numeric IDs, and references copied from mobile apps. Saving that raw string shifts ambiguity into every later workflow.
This Laravel implementation resolves public Facebook, Instagram, and LinkedIn references into a normalized identity object, stores the result without assuming undocumented fields, and places it in a manual review queue. The external lookup runs asynchronously, failures remain visible, and approval is a deliberate human decision rather than an automatic side effect.
Get access before writing integration code
Start with the Identity Resolver service page, then open the official documentation to confirm supported platforms and request parameters.
The current public endpoint requires no account token and no API key. Consequently, there is no credential to copy. The documentation is also the authoritative registration guidance and login guidance for this integration: registration and login are not required before the first request. Do not invent an authorization header or place a placeholder token in the application.
The exact call 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. This project uses identifier so one form can accept either a handle or profile URL.
curl --get 'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
--data-urlencode 'platform=linkedin' \
--data-urlencode 'identifier=YOUR_PUBLIC_PROFILE_REFERENCE' \
--header 'Accept: application/json'
Inspect the real response from the documentation and test request before designing presentation logic. The contract promises a normalized public identity response, but this tutorial deliberately does not invent individual response fields.
There is no credential to store in .env. Store only the configurable service location:
IDENTITY_RESOLVER_BASE_URL=https://ai.mihajlo.mk/api/identity-resolver
QUEUE_CONNECTION=database
Architecture: resolution first, approval second
The request path should be fast and predictable. A controller validates the submitted platform and reference, creates an onboarding record, and dispatches a queue job. The job calls the resolver through a dedicated client and stores the normalized response. A reviewer then approves or rejects the record through protected routes.
This introduces eventual consistency: the POST response means “accepted for resolution,” not “profile verified.” That trade-off is useful because a slow or temporarily unavailable external service no longer holds open the onboarding request. It also gives support staff an auditable failure state.
The relevant project structure is:
app/
Domain/Identity/ResolvedIdentity.php
Exceptions/IdentityResolutionException.php
Http/Controllers/OnboardingProfileController.php
Http/Controllers/ProfileReviewController.php
Jobs/ResolveOnboardingProfile.php
Models/OnboardingProfile.php
Services/IdentityResolverClient.php
config/services.php
database/migrations/..._create_onboarding_profiles_table.php
routes/web.php
tests/Feature/OnboardingProfileTest.php
tests/Unit/IdentityResolverClientTest.php
Create the persistence boundary
Use a JSON column for the complete normalized response. This preserves the service object without coupling the database schema to fields that are not part of the supplied contract. The status column represents the application workflow, not the external provider.
<?php
// database/migrations/2026_01_01_000000_create_onboarding_profiles_table.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('onboarding_profiles', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('platform', 20);
$table->string('submitted_reference', 2048);
$table->string('status', 20)->default('pending')->index();
$table->json('resolved_identity')->nullable();
$table->string('failure_code', 50)->nullable();
$table->text('failure_message')->nullable();
$table->foreignId('reviewed_by')->nullable()
->constrained('users')->nullOnDelete();
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('onboarding_profiles');
}
};
<?php
// app/Models/OnboardingProfile.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class OnboardingProfile extends Model
{
protected $fillable = [
'user_id',
'platform',
'submitted_reference',
'status',
];
protected function casts(): array
{
return [
'resolved_identity' => 'array',
'reviewed_at' => 'immutable_datetime',
];
}
}
Build a defensive API client
Add the endpoint root to Laravel’s environment-backed service configuration:
<?php
// config/services.php
return [
// Existing services...
'identity_resolver' => [
'base_url' => env(
'IDENTITY_RESOLVER_BASE_URL',
'https://ai.mihajlo.mk/api/identity-resolver'
),
],
];
The domain DTO accepts only a non-list JSON object. It intentionally exposes generic attributes because naming fields such as display name, avatar, or canonical URL without a documented guarantee would make the integration brittle.
<?php
// app/Domain/Identity/ResolvedIdentity.php
namespace App\Domain\Identity;
use App\Exceptions\IdentityResolutionException;
final readonly class ResolvedIdentity
{
private function __construct(public array $attributes) {}
public static function fromResponse(array $payload): self
{
if ($payload === [] || array_is_list($payload)) {
throw new IdentityResolutionException(
'invalid_response',
'Resolver returned an unexpected JSON shape.'
);
}
return new self($payload);
}
}
The client applies bounded timeouts and retries only connection failures, HTTP 429 responses, and server errors. Validation-style 4xx responses are returned immediately. Backoff is short and bounded, so one job cannot occupy a worker indefinitely.
<?php
// app/Exceptions/IdentityResolutionException.php
namespace App\Exceptions;
use RuntimeException;
final class IdentityResolutionException extends RuntimeException
{
public function __construct(public readonly string $failureCode, string $message)
{
parent::__construct($message);
}
}
// app/Services/IdentityResolverClient.php
namespace App\Services;
use App\Domain\Identity\ResolvedIdentity;
use App\Exceptions\IdentityResolutionException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Factory;
use Illuminate\Http\Client\Response;
final class IdentityResolverClient
{
public function __construct(private readonly Factory $http) {}
public function resolve(string $platform, string $identifier): ResolvedIdentity
{
$delays = [150_000, 500_000, 1_000_000];
$lastConnectionError = null;
foreach ($delays as $attempt => $delay) {
try {
$response = $this->request($platform, $identifier);
$retryable = $response->status() === 429 || $response->serverError();
if (! $retryable) {
return $this->map($response);
}
if ($attempt === array_key_last($delays)) {
$code = $response->status() === 429
? 'rate_limited'
: 'upstream_unavailable';
throw new IdentityResolutionException(
$code,
"Resolver request failed with HTTP {$response->status()}."
);
}
} catch (ConnectionException $exception) {
$lastConnectionError = $exception;
if ($attempt === array_key_last($delays)) {
throw new IdentityResolutionException(
'connection_failed',
'Could not connect to the identity resolver.'
);
}
}
usleep($delay);
}
throw new IdentityResolutionException(
'connection_failed',
$lastConnectionError?->getMessage() ?? 'Resolver request failed.'
);
}
private function request(string $platform, string $identifier): Response
{
return $this->http
->baseUrl((string) config('services.identity_resolver.base_url'))
->acceptJson()
->connectTimeout(3)
->timeout(8)
->get('/v1/resolve', [
'platform' => $platform,
'identifier' => $identifier,
]);
}
private function map(Response $response): ResolvedIdentity
{
if (! $response->successful()) {
throw new IdentityResolutionException(
'request_rejected',
"Resolver rejected the request with HTTP {$response->status()}."
);
}
$payload = $response->json();
if (! is_array($payload)) {
throw new IdentityResolutionException(
'invalid_response',
'Resolver returned invalid JSON.'
);
}
return ResolvedIdentity::fromResponse($payload);
}
}
Queue the import and expose review actions
The job records structured failures but does not store response bodies or submitted profile references in logs. Those values can contain personal data even when the source profile is public.
<?php
// app/Jobs/ResolveOnboardingProfile.php
namespace App\Jobs;
use App\Exceptions\IdentityResolutionException;
use App\Models\OnboardingProfile;
use App\Services\IdentityResolverClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
final class ResolveOnboardingProfile implements ShouldQueue
{
use Queueable;
public function __construct(public readonly int $profileId) {}
public function handle(IdentityResolverClient $resolver): void
{
$profile = OnboardingProfile::findOrFail($this->profileId);
$profile->update(['status' => 'resolving']);
try {
$identity = $resolver->resolve(
$profile->platform,
$profile->submitted_reference
);
$profile->update([
'status' => 'ready',
'resolved_identity' => $identity->attributes,
'failure_code' => null,
'failure_message' => null,
]);
} catch (IdentityResolutionException $exception) {
$profile->update([
'status' => 'failed',
'failure_code' => $exception->failureCode,
'failure_message' => $exception->getMessage(),
]);
Log::warning('Identity resolution failed', [
'onboarding_profile_id' => $profile->id,
'failure_code' => $exception->failureCode,
]);
}
}
}
<?php
// app/Http/Controllers/OnboardingProfileController.php
namespace App\Http\Controllers;
use App\Jobs\ResolveOnboardingProfile;
use App\Models\OnboardingProfile;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
final class OnboardingProfileController
{
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'platform' => ['required', Rule::in([
'facebook', 'instagram', 'linkedin',
])],
'identifier' => ['required', 'string', 'max:2048'],
]);
$profile = OnboardingProfile::create([
'user_id' => $request->user()->id,
'platform' => $data['platform'],
'submitted_reference' => trim($data['identifier']),
'status' => 'pending',
]);
ResolveOnboardingProfile::dispatch($profile->id);
return response()->json([
'id' => $profile->id,
'status' => $profile->status,
], 202);
}
}
Review actions must be authenticated and authorized. Define a review-onboarding gate using your application’s existing roles or permissions, then make transitions atomic:
<?php
// app/Http/Controllers/ProfileReviewController.php
namespace App\Http\Controllers;
use App\Models\OnboardingProfile;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class ProfileReviewController
{
public function approve(Request $request, OnboardingProfile $profile): JsonResponse
{
DB::transaction(function () use ($request, $profile): void {
$locked = OnboardingProfile::lockForUpdate()->findOrFail($profile->id);
if ($locked->status !== 'ready') {
throw ValidationException::withMessages([
'status' => 'Only a resolved profile can be approved.',
]);
}
$locked->update([
'status' => 'approved',
'reviewed_by' => $request->user()->id,
'reviewed_at' => now(),
]);
});
return response()->json(['status' => 'approved']);
}
public function reject(Request $request, OnboardingProfile $profile): JsonResponse
{
abort_unless(in_array($profile->status, ['ready', 'failed'], true), 409);
$profile->update([
'status' => 'rejected',
'reviewed_by' => $request->user()->id,
'reviewed_at' => now(),
]);
return response()->json(['status' => 'rejected']);
}
}
<?php
// routes/web.php
use App\Http\Controllers\OnboardingProfileController;
use App\Http\Controllers\ProfileReviewController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->group(function (): void {
Route::post('/onboarding/social-profile',
[OnboardingProfileController::class, 'store']);
Route::middleware('can:review-onboarding')->group(function (): void {
Route::post('/profile-reviews/{profile}/approve',
[ProfileReviewController::class, 'approve']);
Route::post('/profile-reviews/{profile}/reject',
[ProfileReviewController::class, 'reject']);
});
});
Test success, throttling, and workflow boundaries
Http::fake() makes the integration deterministic and prevents tests from contacting the public service.
<?php
// tests/Unit/IdentityResolverClientTest.php
namespace Tests\Unit;
use App\Exceptions\IdentityResolutionException;
use App\Services\IdentityResolverClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class IdentityResolverClientTest extends TestCase
{
public function test_it_maps_a_normalized_object(): void
{
Http::fake([
'*/v1/resolve*' => Http::response([
'stable_reference' => 'fixture-value',
'public' => true,
]),
]);
$result = app(IdentityResolverClient::class)
->resolve('linkedin', 'example-reference');
$this->assertSame('fixture-value', $result->attributes['stable_reference']);
Http::assertSent(fn ($request) =>
$request['platform'] === 'linkedin'
&& $request['identifier'] === 'example-reference'
&& ! $request->hasHeader('Authorization')
);
}
public function test_it_reports_persistent_rate_limiting(): void
{
Http::fake(['*/v1/resolve*' => Http::response([], 429)]);
$this->expectException(IdentityResolutionException::class);
app(IdentityResolverClient::class)
->resolve('instagram', 'example-reference');
}
}
Add feature tests for the authenticated POST, unsupported platforms, the queued job, reviewer authorization, approval from ready, and rejection from failed. Also assert that approval from pending is refused. These tests protect the human-review boundary, which is more important than a happy-path status code.
Security, observability, and deployment
- Apply login throttling and a route rate limiter to onboarding submissions so one user cannot create an unbounded queue.
- Render normalized values as escaped text. A public profile is still untrusted input; never inject returned HTML into a review screen.
- Restrict review routes through a real role or permission gate. Authentication alone is insufficient.
- Define retention rules for raw submissions, normalized identity data, failures, and review history.
- Log record IDs, duration, status class, attempt count, and failure codes. Avoid URLs, handles, full response bodies, and credentials.
- Monitor queue age, failed-resolution rate, HTTP 429 frequency, server-error frequency, and time spent awaiting manual review.
Deploy the migration, cache production configuration, and restart long-running queue workers so they load the new code and environment. For a database-backed queue, create its table if the application does not already have one, migrate, and run a supervised worker:
php artisan queue:table
php artisan migrate
php artisan config:cache
php artisan queue:work --queue=default --tries=1 --timeout=30
Run queue:table only when that migration does not already exist. The job uses one queue attempt because the client already performs bounded retries; stacking queue retries on top would amplify outages and rate limits. A failed business resolution remains reviewable in the database instead of disappearing into the failed-jobs table.
Common failures and final verification
An HTTP 4xx response usually points to an unsupported platform, malformed reference, or changed request expectation; correct the input rather than retrying it. HTTP 429 means the service is limiting requests, while 5xx and connection failures indicate temporary upstream trouble. Invalid JSON or an unexpected top-level list should become invalid_response, not a PHP type error.
Before release, verify the complete path:
- The documentation confirms that the public endpoint still requires no token.
- A minimal GET request succeeds with
platformand one supported reference parameter. - The application sends no authorization header and logs no profile reference.
- A submission returns HTTP 202 and creates one queued job.
- A successful lookup moves the record from
pendingthroughresolvingtoready. - Rate limits and upstream failures become structured, visible failure states.
- An ordinary user cannot access either review action.
- A reviewer can approve only a
readyrecord and can reject areadyorfailedrecord. - Automated tests make no real network calls.
- Production workers, alerts, retention, and reviewer ownership are documented.
The important design choice is not the HTTP request. It is refusing to confuse normalization with trust. Resolve the public reference at a disciplined boundary, retain the response defensively, and let a person make the consequential decision. That small separation turns a fragile onboarding shortcut into a workflow a growing team can operate with confidence.