Vodiči

Laravel Quote Forms: Enrich Requests with Company Data Seamlessly

Laravel obrasci za ponude: jednostavno obogatite zahtjeve podacima o tvrtki

Obrazac za ponudu trebao bi djelovati trenutačno, čak i kada poslovanje želi više od imena i e-pošte. Ako potencijalni klijent unese web-mjesto tvrtke, taj URL možemo pretvoriti u strukturirane podatke o tvrtki i kontaktu — ali preglednik ne bi trebao čekati dok vanjska usluga pregledava web-mjesto.

Ovaj vodič izrađuje taj tijek rada u Laravelu i PHP-u 8.3. Aplikacija najprije sprema zahtjev za ponudu, vraća 202 Accepted i obogaćuje ga putem zadatka u redu čekanja. Integracija koristi Laravelov ugrađeni HTTP klijent, preslikava nesigurne vanjske podatke u kontrolirani objekt domene te tretira neuspjehe autentikacije, ograničenja broja zahtjeva, vremenska ograničenja, ponovne pokušaje i implementaciju kao primarne probleme.

Dobijte pristup prije pisanja integracijskog koda

Ova usluga zahtijeva token ograničen na uslugu. Započnite registracijom računa ili upotrijebite stranicu za prijavu ako ga već imate.

  1. Otvorite stranicu usluge Website to Company data.
  2. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu.

Ponovno generiranje tokena opoziva prethodno aktivni token. Rotaciju tretirajte kao promjenu implementacije: ažurirajte svako okruženje koje koristi vjerodajnicu, ponovno pokrenite dugotrajne workere, provjerite novi token i tek tada smatrajte rotaciju dovršenom.

Potvrdite točan HTTP ugovor

Integracija šalje GET zahtjev na https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Javno web-mjesto šalje u parametru upita website, a autentikaciju provodi parametrom upita token={serviceToken}.

Pošaljite jedan minimalni zahtjev prije izrade Laravel značajke:

curl --fail-with-body --silent --show-error --get \
  'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract' \
  --data-urlencode 'website=https://example.com' \
  --data-urlencode 'token=YOUR_SERVICE_TOKEN'

Uspješan odgovor sadrži podatke o tvrtki, kontaktu, e-pošti, telefonu i osobama. Tih pet polja preslikat ćemo na granici aplikacije umjesto da dopustimo širenje vanjskog odgovora kroz model domene.

Pohranite vjerodajnicu u Laravel konfiguraciju

Stvarni token stavite samo u .env datoteku implementiranog okruženja. U .env.example spremite rezervirano mjesto, nikada vjerodajnicu.

WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database

Dodajte uslugu u config/services.php. Zadržavanje fiksne krajnje točke sprječava da slučajna promjena okruženja pošalje token drugom hostu.

'website_company' => [
    'endpoint' => 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
    'token' => env('WEBSITE_COMPANY_TOKEN'),
],

Dizajnirajte putanju zahtjeva oko latencije i neuspjeha

Vanjski poziv ne pripada HTTP zahtjevu obrasca. DNS razrješavanje, udaljena obrada, ograničavanje broja zahtjeva i prolazni mrežni neuspjesi imaju nepredvidivu latenciju. Umjesto toga, putanja zahtjeva namjerno je kratka:

  1. Validirajte i spremite zahtjev za ponudu.
  2. Pošaljite zadatak za obogaćivanje nakon potvrde transakcije baze podataka.
  3. Odmah vratite odgovor.
  4. Neka worker reda čekanja pozove uslugu i trajno spremi preslikani rezultat.

Time se stvara eventualna konzistentnost: ponuda postoji odmah, dok obogaćivanje prolazi kroz stanja pending, processing, completed ili failed. Taj je kompromis prikladan jer podaci o tvrtki poboljšavaju prodajni tijek rada, ali nisu potrebni za prihvaćanje zahtjeva za ponudu.

Relevantna struktura projekta je sažeta:

app/
  Data/CompanyEnrichment.php
  Exceptions/EnrichmentException.php
  Http/Controllers/QuoteRequestController.php
  Jobs/EnrichQuoteRequest.php
  Models/QuoteRequest.php
  Services/WebsiteCompanyClient.php
config/services.php
database/migrations/..._create_quote_requests_table.php
routes/api.php
tests/Feature/QuoteEnrichmentTest.php

Stvorite trajni zapis ponude

Nova Laravel aplikacija već uključuje HTTP klijent. Stvorite komponente aplikacije i stvorite tablicu reda čekanja u bazi podataka ako je projekt već nema:

composer create-project laravel/laravel quote-enrichment
cd quote-enrichment

php artisan make:model QuoteRequest -m
php artisan make:controller QuoteRequestController
php artisan make:job EnrichQuoteRequest
php artisan queue:table
php artisan migrate

Ako migracija jobs već postoji, nemojte generirati duplikat. U migraciju zahtjeva za ponudu dodajte sljedeće stupce. JSON stupci čuvaju strukturirane vrijednosti usluge bez pretpostavke da podaci o e-pošti ili telefonu uvijek moraju biti jedan niz znakova.

Schema::create('quote_requests', function (Blueprint $table) {
    $table->id();
    $table->uuid('public_id')->unique();
    $table->string('name');
    $table->string('email');
    $table->text('project');
    $table->string('website', 2048);
    $table->string('enrichment_status')->default('pending');
    $table->json('company_data')->nullable();
    $table->json('contact_data')->nullable();
    $table->json('email_data')->nullable();
    $table->json('phone_data')->nullable();
    $table->json('people_data')->nullable();
    $table->string('enrichment_error')->nullable();
    $table->timestamps();
});

Model generira javni identifikator koji nije moguće pogoditi i dosljedno pretvara polja obogaćivanja:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

final class QuoteRequest extends Model
{
    protected $fillable = [
        'name', 'email', 'project', 'website',
        'enrichment_status', 'company_data', 'contact_data',
        'email_data', 'phone_data', 'people_data',
        'enrichment_error',
    ];

    protected function casts(): array
    {
        return [
            'company_data' => 'array',
            'contact_data' => 'array',
            'email_data' => 'array',
            'phone_data' => 'array',
            'people_data' => 'array',
        ];
    }

    protected static function booted(): void
    {
        static::creating(function (self $quote): void {
            $quote->public_id ??= (string) Str::orderedUuid();
        });
    }

    public function getRouteKeyName(): string
    {
        return 'public_id';
    }
}

Preslikajte API odgovor na jednoj granici

Vanjski JSON zaslužuje oprez. Odgovor može biti valjan JSON, dok polje može nedostajati, biti null ili biti prikazano drukčije od oblika koji očekuje vaše korisničko sučelje. DTO u nastavku prepoznaje samo pet ugovorenih polja i svako normalizira u niz siguran za JSON. Nepoznata polja odgovora nikada ne ulaze u model aplikacije.

namespace App\Data;

final readonly class CompanyEnrichment
{
    public function __construct(
        public array $company,
        public array $contact,
        public array $email,
        public array $phone,
        public array $people,
    ) {}

    public static function fromApi(array $payload): self
    {
        return new self(
            company: self::normalize($payload['company'] ?? null),
            contact: self::normalize($payload['contact'] ?? null),
            email: self::normalize($payload['email'] ?? null),
            phone: self::normalize($payload['phone'] ?? null),
            people: self::normalize($payload['people'] ?? null),
        );
    }

    private static function normalize(mixed $value): array
    {
        if (is_array($value)) {
            return $value;
        }

        if (is_string($value) && trim($value) !== '') {
            return ['value' => trim($value)];
        }

        return [];
    }
}

Stvorite malu iznimku koja nosi stabilnu kategoriju neuspjeha, odluku o ponovnom pokušaju, HTTP status i neobaveznu odgodu. Kod usmjeren korisniku trebao bi pohraniti kategoriju poput authentication, a ne sirovo tijelo odgovora.

namespace App\Exceptions;

use RuntimeException;

final class EnrichmentException extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly bool $retryable,
        public readonly ?int $status = null,
        public readonly ?int $retryAfter = null,
    ) {
        parent::__construct("Company enrichment failed: {$kind}");
    }
}

Izgradite ograničeni HTTP klijent

Klijent koristi vremensko ograničenje povezivanja od tri sekunde i ukupno vremensko ograničenje odgovora od deset sekundi. Namjerno izvodi jedan mrežni pokušaj. Ponovni pokušaji reda čekanja osiguravaju odgodu, sprječavajući da ugniježđene petlje ponovnih pokušaja umnože pozive i neočekivano troše kvotu.

namespace App\Services;

use App\Data\CompanyEnrichment;
use App\Exceptions\EnrichmentException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;

final class WebsiteCompanyClient
{
    public function extract(string $website): CompanyEnrichment
    {
        $token = config('services.website_company.token');

        if (! is_string($token) || $token === '') {
            throw new EnrichmentException('configuration', false);
        }

        try {
            $response = Http::acceptJson()
                ->connectTimeout(3)
                ->timeout(10)
                ->get(config('services.website_company.endpoint'), [
                    'website' => $website,
                    'token' => $token,
                ]);
        } catch (ConnectionException) {
            throw new EnrichmentException('connection', true);
        }

        if (! $response->successful()) {
            $status = $response->status();
            $retryable = $status === 408 || $status === 429 || $status >= 500;
            $kind = match ($status) {
                401, 403 => 'authentication',
                429 => 'rate_limited',
                default => $retryable ? 'upstream' : 'request_rejected',
            };

            $retryAfter = filter_var(
                $response->header('Retry-After'),
                FILTER_VALIDATE_INT
            );

            throw new EnrichmentException(
                kind: $kind,
                retryable: $retryable,
                status: $status,
                retryAfter: $retryAfter === false
                    ? null
                    : min(300, max(1, $retryAfter)),
            );
        }

        $payload = $response->json();

        if (! is_array($payload)) {
            throw new EnrichmentException('invalid_response', false);
        }

        return CompanyEnrichment::fromApi($payload);
    }
}

Neuspjesi validacije i autentikacije trajni su dok se ne promijene ulazni podaci ili konfiguracija, stoga bi njihovo ponovno pokušavanje samo trošilo kvotu. Neuspjesi povezivanja, vremenska ograničenja zahtjeva, ograničenja broja zahtjeva i pogreške poslužitelja razumni su kandidati za ponovni pokušaj.

Pokrenite obogaćivanje u otpornom zadatku reda čekanja

Zadatak je sigurno ponovno izvršiti nakon rušenja workera: dovršena ponuda završava ranije, dok drugo uspješno izvršavanje prepisuje iste stupce obogaćivanja. Odgoda raste s deset sekundi na jednu minutu, a zatim na pet minuta. Numerička vrijednost Retry-After koju pruža poslužitelj poštuje se unutar ograničenja od pet minuta.

namespace App\Jobs;

use App\Exceptions\EnrichmentException;
use App\Models\QuoteRequest;
use App\Services\WebsiteCompanyClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;

final class EnrichQuoteRequest implements ShouldQueue
{
    use Queueable;

    public int $tries = 4;
    public int $timeout = 20;
    public bool $failOnTimeout = true;

    public function __construct(public QuoteRequest $quoteRequest) {}

    public function backoff(): array
    {
        return [10, 60, 300];
    }

    public function handle(WebsiteCompanyClient $client): void
    {
        if ($this->quoteRequest->fresh()->enrichment_status === 'completed') {
            return;
        }

        $this->quoteRequest->update(['enrichment_status' => 'processing']);

        try {
            $data = $client->extract($this->quoteRequest->website);
        } catch (EnrichmentException $exception) {
            Log::warning('Quote enrichment attempt failed', [
                'quote_request_id' => $this->quoteRequest->public_id,
                'kind' => $exception->kind,
                'status' => $exception->status,
                'attempt' => $this->attempts(),
            ]);

            if ($exception->kind === 'rate_limited') {
                $this->release($exception->retryAfter ?? 60);
                return;
            }

            if ($exception->retryable) {
                throw $exception;
            }

            $this->quoteRequest->update([
                'enrichment_status' => 'failed',
                'enrichment_error' => $exception->kind,
            ]);

            return;
        }

        $this->quoteRequest->update([
            'enrichment_status' => 'completed',
            'company_data' => $data->company,
            'contact_data' => $data->contact,
            'email_data' => $data->email,
            'phone_data' => $data->phone,
            'people_data' => $data->people,
            'enrichment_error' => null,
        ]);
    }

    public function failed(?Throwable $exception): void
    {
        $this->quoteRequest->update([
            'enrichment_status' => 'failed',
            'enrichment_error' => 'retries_exhausted',
        ]);
    }
}

Prihvatite obrazac bez čekanja

Kontroler validira HTTP ili HTTPS URL, potvrđuje ponudu i šalje zadatak tek nakon uspješnog potvrđivanja te transakcije. Primijenite ograničavanje broja zahtjeva na ruti jer anonimni obrazac inače može potrošiti i lokalni kapacitet reda čekanja i kvotu usluge.

namespace App\Http\Controllers;

use App\Jobs\EnrichQuoteRequest;
use App\Models\QuoteRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

final class QuoteRequestController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'name' => ['required', 'string', 'max:120'],
            'email' => ['required', 'email', 'max:254'],
            'project' => ['required', 'string', 'max:5000'],
            'website' => ['required', 'url:http,https', 'max:2048'],
        ]);

        $quote = DB::transaction(function () use ($validated) {
            $quote = QuoteRequest::create($validated);
            EnrichQuoteRequest::dispatch($quote)->afterCommit();

            return $quote;
        });

        return response()->json([
            'id' => $quote->public_id,
            'enrichment_status' => $quote->enrichment_status,
        ], 202);
    }

    public function show(QuoteRequest $quoteRequest): JsonResponse
    {
        return response()->json([
            'id' => $quoteRequest->public_id,
            'enrichment_status' => $quoteRequest->enrichment_status,
            'company' => $quoteRequest->company_data,
            'contact' => $quoteRequest->contact_data,
            'email' => $quoteRequest->email_data,
            'phone' => $quoteRequest->phone_data,
            'people' => $quoteRequest->people_data,
        ]);
    }
}
use App\Http\Controllers\QuoteRequestController;
use Illuminate\Support\Facades\Route;

Route::middleware('throttle:10,1')->group(function () {
    Route::post('/quote-requests', [QuoteRequestController::class, 'store']);
    Route::get('/quote-requests/{quoteRequest}', [QuoteRequestController::class, 'show']);
});

Javni UUID otežava slučajno nabrajanje, ali nije zamjena za autorizaciju. Ako su pojedinosti ponude vezane uz račun, zaštitite krajnju točku statusa uobičajenim provjerama autentikacije i pravila aplikacije.

Testirajte slanje, preslikavanje i trajne neuspjehe

Http::fake() održava testove determinističkima i dokazuje da nisu potrebni stvarna vjerodajnica ni mrežna veza. Ključni slučajevi su trenutačno slanje, ispravni parametri upita i preslikavanje te izostanak petlje ponovnih pokušaja za odbijene vjerodajnice.

namespace Tests\Feature;

use App\Jobs\EnrichQuoteRequest;
use App\Models\QuoteRequest;
use App\Services\WebsiteCompanyClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request as ClientRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

final class QuoteEnrichmentTest extends TestCase
{
    use RefreshDatabase;

    public function test_form_queues_enrichment_and_returns_immediately(): void
    {
        Queue::fake();

        $response = $this->postJson('/api/quote-requests', [
            'name' => 'Ada Developer',
            'email' => '[email protected]',
            'project' => 'A new product quote',
            'website' => 'https://example.com',
        ]);

        $response->assertStatus(202);
        Queue::assertPushed(EnrichQuoteRequest::class);
    }

    public function test_job_maps_the_contracted_response_fields(): void
    {
        config(['services.website_company.token' => 'TEST_TOKEN']);

        Http::fake([
            'https://ai.mihajlo.mk/*' => Http::response([
                'company' => ['name' => 'Example Company'],
                'contact' => ['page' => '/contact'],
                'email' => ['[email protected]'],
                'phone' => ['+1 555 0100'],
                'people' => [['name' => 'Example Person']],
            ]),
        ]);

        $quote = QuoteRequest::create([
            'name' => 'Ada Developer',
            'email' => '[email protected]',
            'project' => 'A quote',
            'website' => 'https://example.com',
        ]);

        (new EnrichQuoteRequest($quote))
            ->handle(app(WebsiteCompanyClient::class));

        $this->assertDatabaseHas('quote_requests', [
            'id' => $quote->id,
            'enrichment_status' => 'completed',
        ]);

        Http::assertSent(function (ClientRequest $request): bool {
            return $request->method() === 'GET'
                && $request->data()['website'] === 'https://example.com'
                && $request->data()['token'] === 'TEST_TOKEN';
        });
    }

    public function test_authentication_failure_is_recorded_without_throwing(): void
    {
        config(['services.website_company.token' => 'TEST_TOKEN']);
        Http::fake(['https://ai.mihajlo.mk/*' => Http::response([], 401)]);

        $quote = QuoteRequest::create([
            'name' => 'Ada Developer',
            'email' => '[email protected]',
            'project' => 'A quote',
            'website' => 'https://example.com',
        ]);

        (new EnrichQuoteRequest($quote))
            ->handle(app(WebsiteCompanyClient::class));

        $this->assertDatabaseHas('quote_requests', [
            'id' => $quote->id,
            'enrichment_status' => 'failed',
            'enrichment_error' => 'authentication',
        ]);
    }
}

Pokrenite skup testova naredbom php artisan test. Kako se integracija razvija, dodajte slučajeve za neispravan JSON, iznimke povezivanja, ograničavanje broja zahtjeva i iscrpljene ponovne pokušaje.

Granice sigurnosti i nadzora

Nikada nemojte zapisivati token, puni URL zahtjeva, niz upita za autorizaciju ni sirovi odgovor uzvodne usluge. Vjerodajnice u parametrima upita mogu procuriti kroz nemarne zapisnike pristupa i izvješćivanje o iznimkama, stoga strukturirani zapisnici aplikacije trebaju sadržavati samo javni identifikator ponude, kategoriju neuspjeha, statusni kod i broj pokušaja.

Validirajte web-mjesto prije stavljanja u red čekanja i ograničite anonimne prijave. Udaljena usluga izvodi ekstrakciju web-mjesta, ali prihvaćanje samo javnih HTTP i HTTPS URL-ova tvrtki i dalje čini namjeru aplikacije jasnom. Razmotrite dodatna poslovna pravila za nazive localhosta, doslovne privatne IP adrese i domene koje vaš proizvod ne podržava.

Operativno pratite broj i starost zapisa obogaćivanja sa statusima na čekanju, u obradi, dovršeno i neuspjelo. Postavite upozorenja za trajne neuspjehe autentikacije, neuobičajen broj ograničenja broja zahtjeva, rastuću dubinu reda čekanja i stare retke u obradi. Ti signali razlikuju loše vjerodajnice od problema s kapacitetom i uzvodnih incidenata.

Implementirajte workera, ne samo web-aplikaciju

Produkcija zahtijeva neprekidno nadziranog workera reda čekanja. Dostavite WEBSITE_COMPANY_TOKEN putem spremišta tajni platforme za hosting, pokrenite migracije, predmemorirajte konfiguraciju i pokrenite workera pod systemd-om, Supervisorom ili mogućnošću workera platforme.

php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --queue=default --tries=4 --timeout=20

queue:restart traži od postojećih workera da se uredno zaustave nakon trenutnog zadatka, omogućujući nadzorniku procesa da pokrene workere s novim kodom i tokenom. Pobrinite se da vlastito vremensko ograničenje procesa workera bude dulje od vremenskog ograničenja zadatka. Zadržite zapise neuspjelih zadataka i pregledajte ih s php artisan queue:failed; ponovno ih pokrenite tek nakon ispravljanja temeljnog uzroka.

Česti neuspjesi koje vrijedi precizno dijagnosticirati

  • Svaki zahtjev prijavljuje autentikaciju: potvrdite da okruženje sadrži aktivni token ograničen na uslugu, ponovno izgradite predmemoriju konfiguracije i ponovno pokrenite workere. Ponovno generirani token poništava svojeg prethodnika.
  • Ponude ostaju na čekanju: web-aplikacija šalje zadatke, ali nijedan worker ne obrađuje konfigurirani red čekanja.
  • Ponude ostaju u obradi: pregledajte neuspjele zadatke i zapisnike prekida workera. Prisilno gašenje procesa može spriječiti izvršavanje povratnog poziva za neuspjeh zadatka.
  • Ograničenja broja zahtjeva se ponavljaju: smanjite zloupotrebu prijava, provjerite kapacitet plana i zadržite ograničenu odgodu. Dodavanje trenutačnih ponovnih pokušaja pogoršava problem.
  • Curl zahtjev radi, ali Laravel ne: usporedite okruženje workera s interaktivnom ljuskom, očistite zastarjelu konfiguraciju i provjerite odlazni HTTPS pristup.
  • Polja su neočekivano prazna: pregledajte sanirani odgovor u kontroliranom razvojnom okruženju i ažurirajte samo granični mapper. Nemojte raspršivati pretpostavke o obliku odgovora po kontrolerima i prikazima.

Završni kontrolni popis provjere

  • Koraci registracije, aktivacije plana, dokumentacije i tokena usluge su dovršeni.
  • Token postoji samo u konfiguraciji podržanoj varijablama okruženja.
  • Minimalni GET zahtjev uspijeva s parametrima upita website i token.
  • Slanje ponude vraća 202 bez čekanja na obogaćivanje.
  • Stvarni worker reda čekanja mijenja zapis iz stanja na čekanju u dovršeno.
  • Podaci o tvrtki, kontaktu, e-pošti, telefonu i osobama trajno se spremaju putem DTO mappera.
  • Neuspjesi autentikacije i validacije ne pokušavaju se ponovno.
  • Vremenska ograničenja, ograničenja broja zahtjeva i neuspjesi poslužitelja dobivaju ograničene ponovne pokušaje i korisne zapisnike.
  • Testovi prolaze bez vanjskog mrežnog pristupa ili stvarnih vjerodajnica.

Važan rezultat veći je od jednog obogaćenog obrasca. Zahtjev za ponudu ostaje pouzdan čak i kada je pružatelj obogaćivanja spor ili privremeno nedostupan, dok koristan kontekst o tvrtki stiže nekoliko trenutaka kasnije. To razdvajanje — brzo prihvaćanje na rubu, disciplinirana neizvjesnost u pozadini — pretvara praktičan API poziv u produkcijsku integraciju.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.