Туториали

Laravel: AI-Powered Social Link Normalization for Your Community Directory

Laravel: Нормализација на врски до социјални мрежи со помош на ВИ за вашиот директориум на заедницата

A community directory rarely receives clean social data. One member pastes a full Facebook URL, another supplies an Instagram profile with tracking parameters, and a third uses a LinkedIn link copied from a mobile browser. Storing those strings unchanged creates duplicate records, brittle templates, and awkward cleanup work.

This tutorial builds a production-oriented Laravel feature that sends submitted Facebook, Instagram, and LinkedIn URLs to the Identity Resolver, validates the returned identity object, and stores both the original link and its normalized representation. The integration uses Laravel’s built-in HTTP client, bounded retries, structured failures, safe logging, and deterministic tests.

Get access before writing integration code

Start with the Identity Resolver service page, which describes the service and available plan information. Then open the official documentation and review the supported inputs before making a request.

The site also provides registration and login pages, but neither is part of the current onboarding path for this public endpoint. No account token or API key is currently required. Consequently, there is no credential to copy or store before the first test request.

The exact call is GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. It accepts platform plus a supported username, id, identifier, profile, or url parameter. Our directory will consistently submit platform and url.

curl --get 'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
  --data-urlencode 'platform=instagram' \
  --data-urlencode 'url=https://www.instagram.com/example/' \
  --header 'Accept: application/json'

Do not add an invented bearer token or placeholder API key. Store only the endpoint in Laravel’s environment-backed configuration:

# .env
IDENTITY_RESOLVER_URL=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve

Architecture and project shape

The controller should not interpret a third-party response directly. A dedicated client owns transport concerns, while a domain object validates the response and calculates an application-controlled fingerprint. The database keeps the original URL for auditing and the normalized object for rendering or later indexing.

  • app/Http/Requests/StoreSocialLinkRequest.php validates the submitted platform and URL.
  • app/Services/IdentityResolver.php handles HTTP behavior and failure classification.
  • app/Data/ResolvedIdentity.php maps the untrusted response into the domain.
  • app/Models/SocialLink.php persists the result.
  • app/Http/Controllers/CommunitySocialLinkController.php coordinates the operation.

This synchronous design is appropriate when an editor needs immediate confirmation that a link is valid. A queue would improve perceived latency during bulk imports, but it would also require pending states, idempotent jobs, and reconciliation. Add that machinery only when the workload justifies it.

Install and configure the Laravel application

Use PHP 8.3 or newer and a maintained Laravel release with the built-in HTTP client. Starting from an existing directory application that already has a Community model, create the feature files and migration:

php artisan make:model SocialLink -m
php artisan make:request StoreSocialLinkRequest
php artisan make:controller CommunitySocialLinkController
php artisan migrate

Add the endpoint to config/services.php. Centralizing it makes configuration cache-safe and gives tests one predictable override point.

<?php

return [
    // Existing services...

    'identity_resolver' => [
        'url' => env(
            'IDENTITY_RESOLVER_URL',
            'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'
        ),
    ],
];

Persist original and normalized identities

The unique key below permits one link per platform for each community. Re-submitting a Facebook URL replaces that community’s previous Facebook identity instead of creating a duplicate.

<?php
// database/migrations/..._create_social_links_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('social_links', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('community_id')->constrained()->cascadeOnDelete();
            $table->string('platform', 20);
            $table->text('submitted_url');
            $table->json('resolved_identity');
            $table->char('identity_fingerprint', 64);
            $table->timestamps();

            $table->unique(['community_id', 'platform']);
            $table->index(['platform', 'identity_fingerprint']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('social_links');
    }
};
<?php
// app/Models/SocialLink.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class SocialLink extends Model
{
    protected $fillable = [
        'community_id',
        'platform',
        'submitted_url',
        'resolved_identity',
        'identity_fingerprint',
    ];

    protected function casts(): array
    {
        return ['resolved_identity' => 'array'];
    }
}

Validate links before they leave the application

Platform validation is also a privacy and cost boundary. It prevents arbitrary URLs from being forwarded to an external service. The allow-list deliberately accepts common canonical and www hosts while rejecting deceptive suffixes such as instagram.com.example.org.

<?php
// app/Http/Requests/StoreSocialLinkRequest.php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;

final class StoreSocialLinkRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // Replace with the directory's policy check when required.
    }

    public function rules(): array
    {
        return [
            'platform' => ['required', 'string', Rule::in([
                'facebook', 'instagram', 'linkedin',
            ])],
            'url' => ['required', 'url:http,https', 'max:2048'],
        ];
    }

    public function after(): array
    {
        return [
            function (Validator $validator): void {
                $platform = $this->input('platform');
                $host = strtolower((string) parse_url(
                    (string) $this->input('url'),
                    PHP_URL_HOST
                ));

                $allowed = [
                    'facebook' => ['facebook.com', 'www.facebook.com', 'm.facebook.com'],
                    'instagram' => ['instagram.com', 'www.instagram.com'],
                    'linkedin' => ['linkedin.com', 'www.linkedin.com'],
                ];

                if (!isset($allowed[$platform]) ||
                    !in_array($host, $allowed[$platform], true)) {
                    $validator->errors()->add(
                        'url',
                        'The URL host does not match the selected platform.'
                    );
                }
            },
        ];
    }
}

Build a defensive API boundary

The documented contract promises a normalized public identity response, but application code should not assume undocumented field names. This mapper requires a non-empty JSON object and preserves its contents. The fingerprint comes from recursively key-sorted JSON, so object key ordering cannot create different hashes for equivalent payloads.

<?php
// app/Data/ResolvedIdentity.php

namespace App\Data;

use JsonException;
use UnexpectedValueException;

final readonly class ResolvedIdentity
{
    public function __construct(
        public array $attributes,
        public string $fingerprint,
    ) {}

    public static function fromResponse(mixed $value): self
    {
        if (!is_array($value) || $value === [] || array_is_list($value)) {
            throw new UnexpectedValueException(
                'Resolver response must be a non-empty JSON object.'
            );
        }

        $canonical = self::sortRecursively($value);

        try {
            $json = json_encode($canonical, JSON_THROW_ON_ERROR);
        } catch (JsonException $exception) {
            throw new UnexpectedValueException(
                'Resolver response could not be encoded.',
                previous: $exception
            );
        }

        return new self($value, hash('sha256', $json));
    }

    private static function sortRecursively(array $value): array
    {
        if (array_is_list($value)) {
            return array_map(
                fn (mixed $item): mixed => is_array($item)
                    ? self::sortRecursively($item)
                    : $item,
                $value
            );
        }

        ksort($value);

        foreach ($value as $key => $item) {
            if (is_array($item)) {
                $value[$key] = self::sortRecursively($item);
            }
        }

        return $value;
    }
}

The client retries connection failures, rate limits, and transient server failures. It does not retry validation-style responses or other permanent client errors. URLs and returned identities stay out of logs because public identifiers can still be sensitive operational data.

<?php
// app/Services/ResolverException.php

namespace App\Services;

use RuntimeException;

final class ResolverException extends RuntimeException
{
    public function __construct(
        public readonly string $reason,
        public readonly int $httpStatus,
    ) {
        parent::__construct($reason);
    }
}

// app/Services/IdentityResolver.php

namespace App\Services;

use App\Data\ResolvedIdentity;
use Exception;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;

final class IdentityResolver
{
    public function resolve(string $platform, string $url): ResolvedIdentity
    {
        $requestId = (string) Str::uuid();

        try {
            $response = Http::acceptJson()
                ->withHeaders(['X-Request-ID' => $requestId])
                ->connectTimeout(2)
                ->timeout(8)
                ->retry(
                    3,
                    fn (int $attempt): int => 200 * (2 ** ($attempt - 1)),
                    function (Exception $exception, PendingRequest $request): bool {
                        if ($exception instanceof ConnectionException) {
                            return true;
                        }

                        return $exception instanceof RequestException
                            && in_array(
                                $exception->response->status(),
                                [429, 500, 502, 503, 504],
                                true
                            );
                    },
                    throw: false
                )
                ->get(config('services.identity_resolver.url'), [
                    'platform' => $platform,
                    'url' => $url,
                ]);
        } catch (ConnectionException $exception) {
            Log::warning('Identity resolver connection failed', [
                'request_id' => $requestId,
                'platform' => $platform,
            ]);

            throw new ResolverException('resolver_unavailable', 503);
        }

        if ($response->status() === 429) {
            throw new ResolverException('resolver_rate_limited', 503);
        }

        if (!$response->successful()) {
            Log::warning('Identity resolver rejected request', [
                'request_id' => $requestId,
                'platform' => $platform,
                'status' => $response->status(),
            ]);

            throw new ResolverException('resolver_failed', 502);
        }

        try {
            return ResolvedIdentity::fromResponse($response->json());
        } catch (Throwable $exception) {
            Log::error('Identity resolver returned an invalid payload', [
                'request_id' => $requestId,
                'platform' => $platform,
            ]);

            throw new ResolverException('invalid_resolver_response', 502);
        }
    }
}

Connect the controller and route

Resolve first and write afterward. That ordering prevents a failed upstream call from leaving a partially updated row.

<?php
// app/Http/Controllers/CommunitySocialLinkController.php

namespace App\Http\Controllers;

use App\Http\Requests\StoreSocialLinkRequest;
use App\Models\Community;
use App\Models\SocialLink;
use App\Services\IdentityResolver;
use App\Services\ResolverException;
use Illuminate\Http\JsonResponse;

final class CommunitySocialLinkController extends Controller
{
    public function store(
        StoreSocialLinkRequest $request,
        Community $community,
        IdentityResolver $resolver
    ): JsonResponse {
        $input = $request->validated();

        try {
            $identity = $resolver->resolve($input['platform'], $input['url']);
        } catch (ResolverException $exception) {
            return response()->json([
                'error' => $exception->reason,
            ], $exception->httpStatus);
        }

        $link = SocialLink::updateOrCreate(
            [
                'community_id' => $community->id,
                'platform' => $input['platform'],
            ],
            [
                'submitted_url' => $input['url'],
                'resolved_identity' => $identity->attributes,
                'identity_fingerprint' => $identity->fingerprint,
            ]
        );

        return response()->json(['data' => $link], $link->wasRecentlyCreated ? 201 : 200);
    }
}

// routes/api.php

use App\Http\Controllers\CommunitySocialLinkController;
use Illuminate\Support\Facades\Route;

Route::post(
    '/communities/{community}/social-links',
    [CommunitySocialLinkController::class, 'store']
)->middleware('auth:sanctum');

Test success, validation, and failure paths

Http::fake() prevents tests from contacting the live service. The fixture is intentionally shape-agnostic: it verifies that an arbitrary non-empty identity object crosses the boundary without claiming undocumented response fields.

<?php
// tests/Feature/CommunitySocialLinkTest.php

namespace Tests\Feature;

use App\Models\Community;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class CommunitySocialLinkTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_resolves_and_stores_a_social_link(): void
    {
        Http::fake([
            'ai.mihajlo.mk/*' => Http::response([
                'fixture_identity' => ['value' => 'stable-example'],
            ], 200),
        ]);

        $community = Community::factory()->create();
        $user = User::factory()->create();

        $this->actingAs($user)
            ->postJson("/api/communities/{$community->id}/social-links", [
                'platform' => 'instagram',
                'url' => 'https://www.instagram.com/example/',
            ])
            ->assertCreated()
            ->assertJsonPath('data.platform', 'instagram');

        Http::assertSent(fn (Request $request): bool =>
            $request->method() === 'GET'
            && $request['platform'] === 'instagram'
            && $request['url'] === 'https://www.instagram.com/example/'
        );

        $this->assertDatabaseHas('social_links', [
            'community_id' => $community->id,
            'platform' => 'instagram',
        ]);
    }

    public function test_it_rejects_a_mismatched_host_without_calling_api(): void
    {
        Http::fake();

        $community = Community::factory()->create();

        $this->actingAs(User::factory()->create())
            ->postJson("/api/communities/{$community->id}/social-links", [
                'platform' => 'linkedin',
                'url' => 'https://example.org/profile',
            ])
            ->assertUnprocessable()
            ->assertJsonValidationErrors('url');

        Http::assertNothingSent();
    }

    public function test_it_returns_a_structured_upstream_failure(): void
    {
        Http::fake([
            'ai.mihajlo.mk/*' => Http::response([], 503),
        ]);

        $community = Community::factory()->create();

        $this->actingAs(User::factory()->create())
            ->postJson("/api/communities/{$community->id}/social-links", [
                'platform' => 'facebook',
                'url' => 'https://www.facebook.com/example',
            ])
            ->assertStatus(502)
            ->assertJson(['error' => 'resolver_failed']);

        $this->assertDatabaseCount('social_links', 0);
    }
}

Security, observability, and deployment

Keep authentication and authorization at the directory boundary. The example uses Sanctum authentication; add a policy when only community owners or moderators may change links. Never render values from resolved_identity as raw HTML, and avoid logging submitted URLs, query strings, or response bodies.

Monitor counts by failure reason, upstream status, and platform. Alert on sustained resolver_unavailable, resolver_rate_limited, or invalid_resolver_response events. Request identifiers help correlate application logs without exposing profile data.

During deployment, set IDENTITY_RESOLVER_URL, run php artisan migrate --force, then rebuild configuration with php artisan config:cache. If configuration was cached before the environment variable existed, Laravel will continue using the old value until the cache is rebuilt.

Common failures

  • A 422 from the directory usually means the platform, URL scheme, or hostname failed local validation.
  • A 503 application response represents exhaustion of connection retries or upstream rate limiting.
  • A 502 represents another upstream failure or a successful HTTP response whose JSON is not a usable identity object.
  • Repeated duplicate rows indicate that the composite unique index was omitted or the migration was not applied.
  • Unexpected authentication headers usually mean someone added a token that this public endpoint does not require.

Final verification checklist

  • Confirm the exact resolver URL is present in the deployed environment.
  • Verify that no API token, authorization header, or secret appears in source control.
  • Submit one valid Facebook, Instagram, and LinkedIn URL.
  • Confirm each row contains the submitted URL, normalized identity object, and fingerprint.
  • Submit a mismatched hostname and verify that no external request is made.
  • Run php artisan test and confirm the fake transport covers success and failure paths.
  • Review logs to ensure profile URLs and response payloads are absent.

Normalization is most valuable when it becomes a boundary, not a cleanup script. By validating before transmission, treating the remote payload as untrusted, classifying failures, and preserving an application-owned fingerprint, the directory gains stable social identities without coupling its domain model to undocumented response details. The result is a small integration that remains understandable when traffic grows, upstream behavior changes, or the next maintainer has to diagnose it under pressure.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.