Vodiči

Laravel Quote Forms: Enrich Requests with Company Data Without Sacrificing Speed

Laravel obrasci za ponude: obogatite zahtjeve podacima o tvrtki bez žrtvovanja brzine

Obrazac za ponudu trebao bi djelovati neposredno. Posjetitelj unese nekoliko pojedinosti, primi potvrdu i nastavi dalje. Ipak, prodajni tim ima koristi od saznanja više nego što bi obrazac razumno trebao tražiti: naziv tvrtke, javne podatke za kontakt i relevantne osobe povezane s poslanom web-stranicom.

Čisto rješenje nije dulji obrazac ni sinkroni API poziv. To je kratka transakcija nakon koje slijedi obogaćivanje podataka u redu čekanja. Laravel vraća odgovor čim se ponuda pohrani, dok radnik u pozadini pretvara web-stranicu tvrtke u strukturirane podatke.

Dobijte pristup usluzi podataka o tvrtkama

Započnite stvaranjem računa na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako ga već imate.

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

Ova usluga nije bez tokena. Zahtijeva token u parametru upita token={serviceToken}. Ponovno generiranje tokena opoziva prethodno aktivni token, stoga uskladite njegovu rotaciju s implementacijom aplikacije umjesto da ga nepromišljeno ponovno generirate.

Točan zahtjev je HTTPS GET prema https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Prije izrade značajke napravite minimalan zahtjev s vrijednostima rezerviranog mjesta:

curl --get \
  --data-urlencode "token=YOUR_SERVICE_TOKEN" \
  --data-urlencode "website=https://example.com" \
  "https://ai.mihajlo.mk/api/website-to-company-data/v1/extract"

Imajte na umu da povijest naredbi i pregled procesa mogu otkriti argumente naredbenog retka. Koristite ovo samo kao kontrolirani osnovni test, nikada ne lijepite stvarni token u dokumentaciju ili tikete te očistite osjetljivu lokalnu povijest u skladu sa svojim operativnim postupcima.

Sada smjestite vjerodajnicu u okruženje projekta, a ne u kontrolu izvornog koda:

# .env
WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database
<?php
// config/services.php

return [
    // Existing services...

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

U .env.example spremite samo rezerviranu vrijednost. Produkcijske tajne pripadaju spremištu tajni platforme za implementaciju ili zaštićenoj konfiguraciji okruženja.

Arhitektura: prvo spremite, zatim obogatite

Ovaj projekt pretpostavlja PHP 8.3 ili noviji, postojeću Laravel aplikaciju, konfiguriranu bazu podataka i stvarnu asinkronu vezu reda čekanja. Nemojte koristiti upravljački program reda sync u produkciji: on bi izvršio obogaćivanje unutar zahtjeva obrasca i poništio ovaj dizajn.

Putanja zahtjeva ima samo tri odgovornosti:

  1. Validirati i pohraniti ponudu.
  2. Poslati zadatak obogaćivanja nakon potvrde transakcije baze podataka.
  3. Vratiti HTTP 202 Accepted.

Zadatak reda čekanja poziva vanjsku uslugu, mapira company, contact, email, phone i people na granici aplikacije, a zatim sprema normalizirani rezultat. Privremeni se neuspjesi ponovno pokušavaju uz ograničeno postupno odgađanje; neuspjesi autentikacije i validacije odmah se bilježe.

Relevantna struktura projekta namjerno je mala:

app/
  Data/CompanyEnrichment.php
  Exceptions/EnrichmentExceptions.php
  Http/Controllers/QuoteController.php
  Http/Requests/StoreQuoteRequest.php
  Jobs/EnrichQuoteRequest.php
  Models/Quote.php
  Services/WebsiteToCompanyClient.php
config/services.php
database/migrations/..._create_quotes_table.php
routes/web.php
tests/Feature/QuoteEnrichmentTest.php

Pohranite eksplicitna stanja obogaćivanja

Samo JSON stupac koji dopušta null ne može razlikovati „nije započeto” od „nije uspjelo”. Uz mapirane podatke pohranite stanje i strojno čitljiv kod pogreške.

<?php
// database/migrations/2026_01_01_000000_create_quotes_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('quotes', function (Blueprint $table): void {
            $table->id();
            $table->string('name');
            $table->string('email');
            $table->string('website', 2048);
            $table->text('summary');
            $table->string('enrichment_status')->default('pending');
            $table->json('company_enrichment')->nullable();
            $table->string('enrichment_error_code')->nullable();
            $table->timestamp('enriched_at')->nullable();
            $table->timestamps();
        });
    }

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

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class Quote extends Model
{
    protected $fillable = ['name', 'email', 'website', 'summary'];

    protected function casts(): array
    {
        return [
            'company_enrichment' => 'array',
            'enriched_at' => 'immutable_datetime',
        ];
    }
}

Mapirajte nesigurne podatke na granici

Ugovor usluge navodi nazive vraćenih polja, ali produkcijski kod ne bi trebao pretpostavljati nedokumentirane unutarnje strukture. Mapper u nastavku prihvaća skalarne vrijednosti i vrijednosti polja sigurne za JSON, odbacuje objekte ili resurse te pohranjuje samo pet relevantnih polja. Time se sprječava širenje pretpostavki o obliku odgovora kroz aplikaciju.

<?php
// app/Data/CompanyEnrichment.php

namespace App\Data;

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

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

    public function toArray(): array
    {
        return [
            'company' => $this->company,
            'contact' => $this->contact,
            'email' => $this->email,
            'phone' => $this->phone,
            'people' => $this->people,
        ];
    }

    private static function safe(mixed $value): mixed
    {
        if ($value === null || is_scalar($value)) {
            return is_string($value) ? trim($value) : $value;
        }

        if (! is_array($value)) {
            return null;
        }

        $clean = [];

        foreach ($value as $key => $item) {
            $clean[$key] = self::safe($item);
        }

        return $clean;
    }
}

Izradite ograničeni HTTP klijent

Laravelov ugrađeni HTTP klijent je dovoljan. Ovdje zadržite politiku prijenosa kako kontroleri i zadaci ne bi morali razumjeti statusne kodove. Neuspjesi povezivanja, neuspjesi poslužitelja, neispravna tijela uspješnih odgovora i HTTP 429 su privremeni. Autentikacija i odbijanje zahtjeva trajni su dok se konfiguracija ili unos ne promijene.

<?php
// app/Exceptions/EnrichmentExceptions.php

namespace App\Exceptions;

use RuntimeException;
use Throwable;

class TransientEnrichmentException extends RuntimeException
{
    public function __construct(
        public readonly string $reason,
        public readonly ?int $status = null,
        public readonly ?int $retryAfter = null,
        ?Throwable $previous = null,
    ) {
        parent::__construct($reason, 0, $previous);
    }
}

class PermanentEnrichmentException extends RuntimeException
{
    public function __construct(
        public readonly string $reason,
        public readonly ?int $status = null,
    ) {
        parent::__construct($reason);
    }
}
<?php
// app/Services/WebsiteToCompanyClient.php

namespace App\Services;

use App\Data\CompanyEnrichment;
use App\Exceptions\PermanentEnrichmentException;
use App\Exceptions\TransientEnrichmentException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use LogicException;

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

        if (! is_string($token) || $token === '') {
            throw new LogicException('Website company service token is not configured.');
        }

        try {
            $response = Http::acceptJson()
                ->connectTimeout(2)
                ->timeout(8)
                ->get(config('services.website_company.endpoint'), [
                    'token' => $token,
                    'website' => $website,
                ]);
        } catch (ConnectionException $exception) {
            throw new TransientEnrichmentException(
                'connection_failure',
                previous: $exception,
            );
        }

        if ($response->status() === 429) {
            $header = filter_var(
                $response->header('Retry-After'),
                FILTER_VALIDATE_INT,
            );

            $delay = is_int($header) ? max(10, min(300, $header)) : 60;

            throw new TransientEnrichmentException(
                'rate_limited',
                429,
                $delay,
            );
        }

        if ($response->serverError()) {
            throw new TransientEnrichmentException(
                'upstream_failure',
                $response->status(),
            );
        }

        if (in_array($response->status(), [401, 403], true)) {
            throw new PermanentEnrichmentException(
                'authentication_failure',
                $response->status(),
            );
        }

        if ($response->clientError()) {
            throw new PermanentEnrichmentException(
                'request_rejected',
                $response->status(),
            );
        }

        $payload = $response->json();

        if (! is_array($payload)) {
            throw new TransientEnrichmentException('malformed_response');
        }

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

Unutar HTTP klijenta namjerno nema petlje za trenutačno ponovno pokušavanje. Ponovni pokušaji na razini reda čekanja izbjegavaju zadržavanje radnika u ponovljenim mrežnim pozivima, a njihovo odgađanje daje ograničenju brzine ili privremenom prekidu vremena za oporavak.

Neka zahtjev obrasca bude brz

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

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class StoreQuoteRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:120'],
            'email' => ['required', 'email', 'max:254'],
            'website' => ['required', 'url:http,https', 'max:2048'],
            'summary' => ['required', 'string', 'max:5000'],
        ];
    }
}
<?php
// app/Http/Controllers/QuoteController.php

namespace App\Http\Controllers;

use App\Http\Requests\StoreQuoteRequest;
use App\Jobs\EnrichQuoteRequest;
use App\Models\Quote;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;

final class QuoteController
{
    public function store(StoreQuoteRequest $request): JsonResponse
    {
        $quote = DB::transaction(function () use ($request): Quote {
            $quote = Quote::create($request->safe()->only([
                'name', 'email', 'website', 'summary',
            ]));

            EnrichQuoteRequest::dispatch($quote->id)->afterCommit();

            return $quote;
        });

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

// routes/web.php

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

Route::post('/quotes', [QuoteController::class, 'store'])
    ->middleware('throttle:10,1');

Budući da se ova ruta nalazi u web.php, uobičajene predaje iz preglednika također primaju Laravelovu CSRF zaštitu. Ograničavanje sprječava jednostavnu zloupotrebu; javni obrasci mogu dodatno trebati kontrole botova prikladne aplikaciji.

Obradite obogaćivanje s kontroliranim ponovnim pokušajima

<?php
// app/Jobs/EnrichQuoteRequest.php

namespace App\Jobs;

use App\Exceptions\PermanentEnrichmentException;
use App\Exceptions\TransientEnrichmentException;
use App\Models\Quote;
use App\Services\WebsiteToCompanyClient;
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 = 15;

    public function __construct(public readonly int $quoteId) {}

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

    public function handle(WebsiteToCompanyClient $client): void
    {
        $quote = Quote::find($this->quoteId);

        if ($quote === null || $quote->enrichment_status === 'complete') {
            return;
        }

        $quote->forceFill([
            'enrichment_status' => 'processing',
            'enrichment_error_code' => null,
        ])->save();

        try {
            $data = $client->extract($quote->website);
        } catch (PermanentEnrichmentException $exception) {
            $quote->forceFill([
                'enrichment_status' => 'failed',
                'enrichment_error_code' => $exception->reason,
            ])->save();

            Log::warning('Quote enrichment permanently rejected', [
                'quote_id' => $quote->id,
                'reason' => $exception->reason,
                'status' => $exception->status,
            ]);

            return;
        } catch (TransientEnrichmentException $exception) {
            $quote->forceFill([
                'enrichment_status' => 'pending',
                'enrichment_error_code' => $exception->reason,
            ])->save();

            Log::warning('Quote enrichment will be retried', [
                'quote_id' => $quote->id,
                'reason' => $exception->reason,
                'status' => $exception->status,
            ]);

            if ($exception->retryAfter !== null) {
                $this->release($exception->retryAfter);
                return;
            }

            throw $exception;
        }

        $quote->forceFill([
            'company_enrichment' => $data->toArray(),
            'enrichment_status' => 'complete',
            'enrichment_error_code' => null,
            'enriched_at' => now(),
        ])->save();
    }

    public function failed(?Throwable $exception): void
    {
        Quote::whereKey($this->quoteId)
            ->where('enrichment_status', '!=', 'complete')
            ->update([
                'enrichment_status' => 'failed',
                'enrichment_error_code' => 'retries_exhausted',
            ]);
    }
}

Zapisnici sadrže ID-jeve ponuda, sigurne kodove razloga i HTTP statuse. Namjerno isključuju web-stranicu, vraćene podatke o osobama, tijelo odgovora, URL zahtjeva i token. To je posebno važno jer vjerodajnice u nizu upita mogu procuriti kroz neselektivno zapisivanje URL-ova.

Testirajte i brzinu i ponašanje na granici

Http::fake() čini vanjski ugovor determinističkim. Jedan test dokazuje da predaja stavlja rad u red bez slanja HTTP zahtjeva; drugi provjerava točnu metodu, krajnju točku, parametre upita i mapiranje odgovora.

<?php
// tests/Feature/QuoteEnrichmentTest.php

namespace Tests\Feature;

use App\Jobs\EnrichQuoteRequest;
use App\Services\WebsiteToCompanyClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

final class QuoteEnrichmentTest extends TestCase
{
    use RefreshDatabase;

    public function test_submission_returns_before_enrichment(): void
    {
        Queue::fake();
        Http::preventStrayRequests();

        $response = $this->postJson('/quotes', [
            'name' => 'Ava Patel',
            'email' => '[email protected]',
            'website' => 'https://example.test',
            'summary' => 'A small application redesign.',
        ]);

        $response->assertStatus(202)->assertJson([
            'status' => 'accepted',
        ]);

        $this->assertDatabaseHas('quotes', [
            'website' => 'https://example.test',
            'enrichment_status' => 'pending',
        ]);

        Queue::assertPushed(EnrichQuoteRequest::class);
        Http::assertNothingSent();
    }

    public function test_client_maps_the_documented_fields(): void
    {
        config()->set('services.website_company.token', 'test-token');

        Http::fake([
            'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract*'
                => Http::response([
                    'company' => ['name' => 'Example Studio'],
                    'contact' => ['name' => 'Ava Patel'],
                    'email' => '[email protected]',
                    'phone' => '+1 555 0100',
                    'people' => [['name' => 'Ava Patel']],
                ], 200),
        ]);

        $data = app(WebsiteToCompanyClient::class)
            ->extract('https://example.test')
            ->toArray();

        $this->assertSame('Example Studio', $data['company']['name']);
        $this->assertSame('[email protected]', $data['email']);

        Http::assertSent(function (Request $request): bool {
            parse_str(parse_url($request->url(), PHP_URL_QUERY) ?? '', $query);

            return $request->method() === 'GET'
                && str_starts_with(
                    $request->url(),
                    'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract'
                )
                && ($query['token'] ?? null) === 'test-token'
                && ($query['website'] ?? null) === 'https://example.test';
        });
    }
}
php artisan migrate
php artisan test --filter=QuoteEnrichmentTest

Sigurnost, vidljivost i implementacija

Obogaćivanje tretirajte kao osobne i poslovne kontaktne podatke, a ne kao bezopasne metapodatke. Ograničite pristup osoblja, definirajte politiku zadržavanja i izbjegavajte kopiranje cijelog odgovora uzvodne usluge kada aplikacija treba samo pet polja. Validirajte samo HTTP i HTTPS web-stranice te nikada ne koristite poslani URL kao cilj preusmjeravanja ili lokalnog dohvaćanja drugdje bez zasebnih zaštitnih mjera.

Pratite broj zapisa complete, pending i failed, starost reda čekanja, neuspjehe zadataka, klase HTTP statusa i latenciju obogaćivanja. Upozorite na trajne neuspjehe autentikacije jer oni obično ukazuju na nedostajući, opozvani ili zastarjeli token. Zasebno upozorite na ograničavanje brzine kako bi se kapacitet paketa i promet mogli procijeniti bez zamjene pritiska kvote s prekidom rada.

Implementirajte migraciju baze podataka prije koda koji upisuje nove stupce. Zatim predmemorirajte konfiguraciju, ponovno pokrenite radnike kako bi učitali novi token i kôd te pokrenite nadzirani radnik reda čekanja:

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

Pokrenite radnika pod nadzornikom procesa operacijskog sustava ili upravljanom radničkom uslugom hosting platforme. Konfigurirajte prozor za ponovni pokušaj veze reda čekanja tako da bude dulji od vremenskih ograničenja zadatka i radnika; u suprotnom spor zadatak može postati vidljiv dvaput i proizvesti preklapajuće izvršavanje.

Uobičajeni neuspjesi koje vrijedi namjerno dijagnosticirati

  • Svaki zadatak prijavljuje neuspjeh autentikacije: potvrdite da je token ograničen na tu uslugu prisutan u produkcijskoj konfiguraciji. Ako je ponovno generiran, prethodni aktivni token je opozvan. Osvježite tajnu, ponovno izgradite predmemoriju konfiguracije i ponovno pokrenite radnike.
  • Obrazac je i dalje spor: provjerite da QUEUE_CONNECTION nije sync i da se slanje u red događa nakon pohrane ponude.
  • Ponude ostaju na čekanju: provjerite radi li radnik, obrađuje li ispravan red i može li dosegnuti HTTPS krajnju točku.
  • HTTP 429 se ponavlja: zadržite ograničeno odgađanje umjesto trenutačnog ponovnog pokušaja. Pregledajte volumen zahtjeva i aktivirani paket Free, Plus ili Pro.
  • Podaci su neočekivano prazni: pregledajte lažni odgovor i sigurno redigirani oblik odgovora, a zatim prilagodite samo mapper na granici. Nemojte raspršiti nagađanja o obliku odgovora kroz kontrolere i modele.

Kontrolni popis za završnu provjeru

  • Obrazac vraća 202 nakon lokalne transakcije baze podataka, bez čekanja na obogaćivanje.
  • Zahtjev u redu čekanja koristi GET, točnu krajnju točku /v1/extract i obavezne parametre upita token i website.
  • Aplikacija na granici mapira samo company, contact, email, phone i people.
  • Vremenska ograničenja povezivanja i odgovora su ograničena, privremeni ponovni pokušaji su ograničeni, a neuspjesi autentikacije ili validacije ne pokušavaju se slijepo ponovno.
  • U zapisnicima ili testnim podacima ne pojavljuju se token, puni URL zahtjeva, tijelo odgovora ni osobni kontaktni podaci.
  • Produkcijski radnici koriste osvježenu konfiguraciju i nadziru se s obzirom na starost reda, neuspjehe i ograničavanje brzine.

Najjača značajka obogaćivanja gotovo je nevidljiva osobi koja ispunjava obrazac. Posjetitelj dobiva brzu potvrdu; tim nekoliko trenutaka kasnije dobiva koristan kontekst o tvrtki; a privremeni problemi uzvodne usluge postaju kontrolirano stanje u pozadini umjesto pokvarene interakcije s korisnikom. To razdvajanje čini razliku između pukog pozivanja API-ja i njegove odgovorne integracije.

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.