Laravel формулари за понуди: збогатете ги барањата со податоци за компанијата без да ја жртвувате брзината
Формуларот за понуда треба да делува непосредно. Посетителот испраќа неколку детали, добива потврда и продолжува понатаму. Сепак, продажниот тим има корист од сознанија што формуларот разумно не би требало да ги бара: името на компанијата, јавните контакт-детали и релевантните лица поврзани со испратената веб-страница.
Чистото решение не е подолг формулар ниту синхрон API повик. Тоа е кратка трансакција проследена со збогатување ставено во редица. Laravel го враќа одговорот веднаш штом понудата е зачувана, додека worker-от ја претвора веб-страницата на компанијата во структурирани податоци во заднина.
Добијте пристап до услугата за податоци за компании
Започнете со создавање сметка на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
- Отворете ја страницата на услугата Website to Company data.
- Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за услугата.
- Пронајдете го панелот Service token и копирајте го токенот ограничен на услугата.
Оваа услуга не е без токен. Таа го бара токенот во параметарот за барање token={serviceToken}. Повторното генерирање на токенот го поништува претходно активниот токен, затоа координирајте ја ротацијата со распоредувањето на апликацијата наместо неформално да го регенерирате.
Точното барање е HTTPS GET до https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Пред да ја изградите функционалноста, направете минимално барање со вредности-местодржачи:
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"
Имајте предвид дека историјата на команди и проверката на процеси можат да ги откријат аргументите од командната линија. Користете го ова само како контролиран smoke test, никогаш не го вметнувајте вистинскиот токен во документација или тикети и исчистете ја чувствителната локална историја согласно вашите оперативни процедури.
Сега сместете ја акредитацијата во проектната околина наместо во контролата на изворниот код:
# .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',
],
];
Потврдете само местодржач во .env.example. Продукциските тајни припаѓаат во складиштето за тајни на платформата за распоредување или во заштитената конфигурација на околината.
Архитектура: прво зачувајте, потоа збогатете
Овој проект претпоставува PHP 8.3 или понов, постоечка Laravel апликација, конфигурирана база на податоци и вистинска асинхрона конекција со редица. Не го користете двигателот за редица sync во продукција: тој би го извршувал збогатувањето во рамките на барањето од формуларот и би го поништил дизајнот.
Патеката на барањето има само три одговорности:
- Валидирајте ја и зачувајте ја понудата.
- Испратете задача за збогатување откако ќе се потврди трансакцијата со базата на податоци.
- Вратете HTTP
202 Accepted.
Задачата во редицата ја повикува надворешната услуга, ги мапира company, contact, email, phone и people на границата на апликацијата, а потоа го зачувува нормализираниот резултат. Привремените неуспеси се обидуваат повторно со ограничено постепено одложување; неуспесите при автентикација и валидација се евидентираат веднаш.
Релевантната структура на проектот е намерно мала:
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
Зачувајте експлицитни состојби на збогатување
Само nullable JSON колона не може да разликува „не е започнато“ од „неуспешно“. Зачувајте состојба и машински читлив код за грешка покрај мапираните податоци.
<?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',
];
}
}
Мапирајте ги неизвесните податоци на границата
Договорот на услугата ги именува вратените полиња, но продукцискиот код не треба да претпоставува недокументирани внатрешни облици. Маперот подолу прифаќа JSON-безбедни скаларни вредности и низи, отфрла објекти или ресурси и ги зачувува само петте релевантни полиња. Тоа спречува претпоставките за обликот на одговорот да се шират низ апликацијата.
<?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;
}
}
Изградете ограничен HTTP клиент
Вградениот HTTP клиент на Laravel е доволен. Задржете ја транспортната политика тука, за контролерите и задачите да не мораат да ги разбираат статусните кодови. Неуспесите на конекцијата, неуспесите на серверот, невалидните тела на успешен одговор и HTTP 429 се привремени. Автентикацијата и одбивањето на барањето се трајни сè додека не се изменат конфигурацијата или внесот.
<?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);
}
}
Намерно нема непосредна јамка за повторни обиди во HTTP клиентот. Повторните обиди на ниво на редица спречуваат worker-от да остане зафатен со повторени мрежни повици, а нивното одложување ѝ дава време на стапката на ограничување или на привремениот прекин да се опорави.
Одржете го барањето од формуларот брзо
<?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');
Бидејќи оваа рута се наоѓа во web.php, вообичаените испраќања од прелистувачот исто така добиваат Laravel CSRF заштита. Ограничувањето спречува едноставна злоупотреба; на јавните формулари може дополнително да им бидат потребни контроли за ботови соодветни за апликацијата.
Обработувајте го збогатувањето со контролирани повторни обиди
<?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',
]);
}
}
Дневниците содржат идентификатори на понуди, безбедни кодови за причини и HTTP статуси. Тие намерно ги исклучуваат веб-страницата, вратените податоци за лица, телото на одговорот, URL-то на барањето и токенот. Ова е особено важно бидејќи акредитациите во низа за барање можат да протечат преку неселективно евидентирање URL-адреси.
Тестирајте ги и брзината и однесувањето на границата
Http::fake() го прави надворешниот договор детерминистички. Еден тест докажува дека испраќањето става работа во редица без да направи HTTP барање; друг го проверува точниот метод, крајна точка, параметри за барање и мапирањето на одговорот.
<?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
Безбедност, набљудливост и распоредување
Третирајте го збогатувањето како лични и деловни контакт-податоци, а не како безопасни метаподатоци. Ограничете го пристапот на персоналот, дефинирајте политика за задржување и избегнувајте копирање на целиот одговор од горниот извор кога на апликацијата ѝ се потребни само пет полиња. Валидирајте само HTTP и HTTPS веб-страници и никогаш не го користете испратеното URL како цел за пренасочување или локално преземање на друго место без посебни заштитни мерки.
Следете го бројот на записи complete, pending и failed, староста на редицата, неуспесите на задачи, класите на HTTP статуси и латентноста на збогатувањето. Активирајте предупредување за трајни неуспеси на автентикација бидејќи тие обично укажуваат на отсутен, поништен или застарен токен. Одделно активирајте предупредување за ограничување на стапката, за да може да се проценат капацитетот на планот и сообраќајот без притисокот од квотата да се меша со прекин.
Распоредете ја миграцијата на базата на податоци пред кодот што ги запишува новите колони. Потоа кеширајте ја конфигурацијата, рестартирајте ги worker-ите за да го вчитаат новиот токен и код и извршете надгледуван worker за редицата:
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
Извршувајте го worker-от под процесниот надгледувач на оперативниот систем или под управуваната worker-функција на платформата за хостирање. Конфигурирајте го прозорецот за повторен обид на конекцијата на редицата да ги надминува временските ограничувања на задачата и worker-от; во спротивно бавна задача може да стане видлива двапати и да предизвика преклопено извршување.
Вообичаени неуспеси што вреди намерно да се дијагностицираат
- Секоја задача пријавува неуспех на автентикација: потврдете дека токенот ограничен на услугата е присутен во продукциската конфигурација. Ако бил повторно генериран, претходно активниот токен е поништен. Освежете ја тајната, повторно изградете го кешот на конфигурацијата и рестартирајте ги worker-ите.
- Формуларот сè уште е бавен: проверете дека
QUEUE_CONNECTIONне еsyncи дека испраќањето се случува откако понудата е зачувана. - Понудите остануваат во состојба pending: проверете дека worker работи, ја обработува точната редица и може да стигне до HTTPS крајната точка.
- HTTP 429 се повторува: зачувајте го ограниченото одложување наместо веднаш да се обидувате повторно. Прегледајте го обемот на барања и активираниот Free, Plus или Pro план.
- Податоците неочекувано се празни: проверете ги лажниот одговор и безбедно редигираниот облик на одговорот, па приспособете го само маперот на границата. Не расфрлајте претпоставки за обликот на одговорот низ контролерите и моделите.
Конечна листа за проверка
- Формуларот враќа
202по локалната трансакција со базата на податоци, без да чека збогатување. - Барањето ставено во редица користи
GET, точната крајна точка/v1/extractи задолжителните параметри за барањеtokenиwebsite. - Апликацијата ги мапира само
company,contact,email,phoneиpeopleна границата. - Временските ограничувања за конекција и одговор се ограничени, привремените повторни обиди се лимитирани, а неуспесите на автентикација или валидација не се обидуваат повторно без размислување.
- Ниту токен, целосно URL на барањето, тело на одговорот или лични контакт-податоци не се појавуваат во дневниците или фикстурите.
- Продукциските worker-и користат освежена конфигурација и се надгледуваат за старост на редица, неуспеси и ограничување на стапката.
Најсилната функционалност за збогатување е речиси невидлива за лицето што го пополнува формуларот. Посетителот добива брза потврда; тимот добива корисен контекст за компанијата неколку моменти подоцна; а привремените проблеми со горниот извор стануваат контролирана состојба во заднина наместо расипана интеракција со клиентот. Таа поделба е разликата меѓу едноставно повикување API и негово одговорно интегрирање.