Laravel Forms: Instantly Verify Leads with AI-Driven Company Insights
A quote form should feel instant, even when the information behind it is anything but simple. A visitor may provide only a work email, a project summary, and a company website. That is enough to start a conversation, but not enough to understand the business without manual research.
This tutorial builds a production Laravel workflow that accepts the quote immediately, queues enrichment in the background, and turns the submitted website into structured company and contact data. The remote call never sits on the form’s critical path, while validation, retries, failure states, and tests keep the integration predictable.
Get access to the Website to Company data service
First, register for an account, or use the sign-in page if you already have one.
Open the Website to Company data service page. Choose the available Free, Plus, or Pro plan and complete its activation. The appropriate plan depends on the volume and operating limits your application needs; keep those limits in mind when setting worker concurrency.
Next, open the official service documentation. Find the Service token panel and copy the service-scoped token displayed there. This service requires that token. Regenerating it revokes the previously active token, so token rotation must include updating the application environment and restarting its queue workers.
The exact API operation is:
GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract
Query parameters:
token={serviceToken}
website={publicCompanyWebsite}
Before writing Laravel code, make a minimal request from a trusted terminal. Reading the token without echoing it keeps the real value out of the command itself, although operators must still ensure that HTTP tracing and proxy logs do not record query strings.
read -rsp "Service token: " WEBSITE_COMPANY_TOKEN
curl --get \
--data-urlencode "token=${WEBSITE_COMPANY_TOKEN}" \
--data-urlencode "website=https://example.com" \
"https://ai.mihajlo.mk/api/website-to-company-data/v1/extract"
unset WEBSITE_COMPANY_TOKEN
A successful request confirms the token, plan activation, endpoint, and network path. Do not copy its response shape directly into controllers. The application boundary will explicitly map the returned company, contact, email, phone, and people values.
Choose an asynchronous architecture
The quote request and company enrichment have different reliability requirements. Saving a quote is a local operation that should finish quickly. Website analysis is remote work that may encounter a slow site, a temporary service failure, or a plan limit.
The controller will therefore validate and save the quote, dispatch a queue job, and return HTTP 202. A queue worker performs the API call. The job stores either normalized insight data or a controlled failure state, allowing the quote to remain usable even when enrichment is unavailable.
The relevant project structure is intentionally small:
app/
Data/CompanyInsight.php
Exceptions/CompanyInsightException.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/web.php
tests/Feature/QuoteEnrichmentTest.php
Generate the Laravel components and configure a durable database-backed queue:
php artisan make:model QuoteRequest -m
php artisan make:controller QuoteRequestController
php artisan make:job EnrichQuoteRequest
php artisan make:queue-table
php artisan migrate
If the application already has the queue jobs migration, do not generate a duplicate.
Store configuration outside the codebase
Add the credential and endpoint to config/services.php. The endpoint is configuration so staging can be isolated cleanly, but production should retain the documented HTTPS URL.
<?php
return [
// Existing services...
'website_company' => [
'token' => env('WEBSITE_COMPANY_TOKEN'),
'endpoint' => env(
'WEBSITE_COMPANY_ENDPOINT',
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract'
),
],
];
Put the secret only in the deployment environment or local .env, which must not be committed:
WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database
Persist the quote and its enrichment state
The database record distinguishes pending, completed, and failed enrichment. JSON is a pragmatic fit because company and people data may contain nested structures, while the original website remains available for later reprocessing.
<?php
// database/migrations/..._create_quote_requests_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('quote_requests', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('email');
$table->text('project_summary');
$table->string('website', 2048);
$table->string('enrichment_status')->default('pending');
$table->json('company_insight')->nullable();
$table->string('enrichment_error')->nullable();
$table->timestamp('enriched_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('quote_requests');
}
};
// app/Models/QuoteRequest.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class QuoteRequest extends Model
{
protected $fillable = [
'name',
'email',
'project_summary',
'website',
'enrichment_status',
'company_insight',
'enrichment_error',
'enriched_at',
];
protected function casts(): array
{
return [
'company_insight' => 'array',
'enriched_at' => 'immutable_datetime',
];
}
}
Map the response at the application boundary
External JSON is untrusted input, even after an HTTP 200 response. A dedicated data object accepts only the documented business fields. It tolerates either a top-level result or a conventional data envelope, but rejects scalar values and an entirely empty result.
<?php
// app/Data/CompanyInsight.php
namespace App\Data;
use App\Exceptions\CompanyInsightException;
final readonly class CompanyInsight
{
public function __construct(
public array|string|null $company,
public array|string|null $contact,
public array|string|null $email,
public array|string|null $phone,
public ?array $people,
) {}
public static function fromPayload(array $payload): self
{
$root = isset($payload['data']) && is_array($payload['data'])
? $payload['data']
: $payload;
$insight = new self(
self::value($root, 'company'),
self::value($root, 'contact'),
self::value($root, 'email'),
self::value($root, 'phone'),
isset($root['people']) && is_array($root['people'])
? $root['people']
: null,
);
if (collect($insight->toArray())->every(fn ($value) => $value === null)) {
throw new CompanyInsightException(
'The enrichment response contained no usable fields.',
false
);
}
return $insight;
}
private static function value(array $root, string $key): array|string|null
{
$value = $root[$key] ?? null;
if (is_array($value)) {
return $value;
}
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
return null;
}
public function toArray(): array
{
return [
'company' => $this->company,
'contact' => $this->contact,
'email' => $this->email,
'phone' => $this->phone,
'people' => $this->people,
];
}
}
// app/Exceptions/CompanyInsightException.php
namespace App\Exceptions;
use RuntimeException;
final class CompanyInsightException extends RuntimeException
{
public function __construct(
string $message,
public readonly bool $retryable,
public readonly ?int $status = null,
) {
parent::__construct($message);
}
}
Build a bounded HTTP client
The client uses Laravel’s built-in HTTP client with separate connection and total-response timeouts. It retries network failures, HTTP 429, and server errors up to three times with bounded backoff. Authentication and validation failures are not retried because repeating the same request cannot repair them.
<?php
// app/Services/WebsiteCompanyClient.php
namespace App\Services;
use App\Data\CompanyInsight;
use App\Exceptions\CompanyInsightException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
final class WebsiteCompanyClient
{
public function extract(string $website): CompanyInsight
{
$token = config('services.website_company.token');
$endpoint = config('services.website_company.endpoint');
if (! is_string($token) || $token === '') {
throw new CompanyInsightException(
'Website company service token is not configured.',
false
);
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::acceptJson()
->connectTimeout(2)
->timeout(8)
->get($endpoint, [
'token' => $token,
'website' => $website,
]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new CompanyInsightException(
'Could not connect to the enrichment service.',
true
);
}
usleep($this->delayMilliseconds($attempt) * 1000);
continue;
}
if ($response->successful()) {
$payload = $response->json();
if (! is_array($payload)) {
throw new CompanyInsightException(
'The enrichment response was not a JSON object.',
false,
$response->status()
);
}
return CompanyInsight::fromPayload($payload);
}
if (in_array($response->status(), [400, 401, 403, 422], true)) {
throw new CompanyInsightException(
'The enrichment request was rejected.',
false,
$response->status()
);
}
if ($response->status() === 429 || $response->serverError()) {
if ($attempt < 3) {
$retryAfter = (int) $response->header('Retry-After');
$delay = max(
$this->delayMilliseconds($attempt),
min(2000, $retryAfter * 1000)
);
usleep($delay * 1000);
continue;
}
throw new CompanyInsightException(
'The enrichment service is temporarily unavailable.',
true,
$response->status()
);
}
throw new CompanyInsightException(
'Unexpected enrichment response.',
false,
$response->status()
);
}
throw new CompanyInsightException('Enrichment attempts exhausted.', true);
}
private function delayMilliseconds(int $attempt): int
{
return 250 * (2 ** ($attempt - 1));
}
}
Accept the form quickly and enrich it in the queue
The controller restricts websites to HTTP or HTTPS URLs, persists the request, and dispatches only the database identifier. Passing an identifier avoids serializing a potentially stale model snapshot.
<?php
// app/Http/Controllers/QuoteRequestController.php
namespace App\Http\Controllers;
use App\Jobs\EnrichQuoteRequest;
use App\Models\QuoteRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class QuoteRequestController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email', 'max:255'],
'project_summary' => ['required', 'string', 'max:5000'],
'website' => ['required', 'url:http,https', 'max:2048'],
]);
$quote = QuoteRequest::create($validated + [
'enrichment_status' => 'pending',
]);
EnrichQuoteRequest::dispatch($quote->id);
return response()->json([
'id' => $quote->id,
'enrichment_status' => 'pending',
], 202);
}
}
// app/Jobs/EnrichQuoteRequest.php
namespace App\Jobs;
use App\Exceptions\CompanyInsightException;
use App\Models\QuoteRequest;
use App\Services\WebsiteCompanyClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;
final class EnrichQuoteRequest implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 4;
public function __construct(public readonly int $quoteRequestId) {}
public function backoff(): array
{
return [60, 300, 900];
}
public function handle(WebsiteCompanyClient $client): void
{
$quote = QuoteRequest::find($this->quoteRequestId);
if (! $quote || $quote->enrichment_status === 'completed') {
return;
}
try {
$insight = $client->extract($quote->website);
} catch (CompanyInsightException $exception) {
Log::warning('quote_enrichment_attempt_failed', [
'quote_request_id' => $this->quoteRequestId,
'retryable' => $exception->retryable,
'http_status' => $exception->status,
]);
if ($exception->retryable) {
throw $exception;
}
$quote->update([
'enrichment_status' => 'failed',
'enrichment_error' => 'non_retryable_service_error',
]);
return;
}
$quote->update([
'company_insight' => $insight->toArray(),
'enrichment_status' => 'completed',
'enrichment_error' => null,
'enriched_at' => now(),
]);
}
public function failed(?Throwable $exception): void
{
QuoteRequest::whereKey($this->quoteRequestId)->update([
'enrichment_status' => 'failed',
'enrichment_error' => 'retry_limit_exhausted',
]);
}
}
// routes/web.php
use App\Http\Controllers\QuoteRequestController;
use Illuminate\Support\Facades\Route;
Route::post('/quotes', [QuoteRequestController::class, 'store'])
->middleware('throttle:quotes');
Define the named rate limiter for the public form in the application’s normal routing configuration, using limits appropriate to the site. CAPTCHA or abuse controls may also be justified, but they should complement server-side throttling rather than replace it.
Test success, dispatch, and authentication failure
Http::fake() keeps tests deterministic and proves that the exact endpoint receives both required query parameters. Queue faking separately verifies that the web request does not execute enrichment synchronously.
<?php
// tests/Feature/QuoteEnrichmentTest.php
namespace Tests\Feature;
use App\Exceptions\CompanyInsightException;
use App\Jobs\EnrichQuoteRequest;
use App\Services\WebsiteCompanyClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
final class QuoteEnrichmentTest extends TestCase
{
use RefreshDatabase;
public function test_quote_is_accepted_and_enrichment_is_queued(): void
{
Queue::fake();
$this->postJson('/quotes', [
'name' => 'Ada Developer',
'email' => '[email protected]',
'project_summary' => 'A new customer portal.',
'website' => 'https://example.com',
])->assertStatus(202)
->assertJsonPath('enrichment_status', 'pending');
Queue::assertPushed(EnrichQuoteRequest::class);
}
public function test_client_maps_documented_business_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 Company'],
'contact' => ['name' => 'Sales'],
'email' => '[email protected]',
'phone' => '+1 555 0100',
'people' => [['name' => 'Ada Developer']],
], 200),
]);
$result = app(WebsiteCompanyClient::class)
->extract('https://example.com');
$this->assertSame('[email protected]', $result->email);
Http::assertSent(fn ($request) =>
$request->url() === config('services.website_company.endpoint')
&& $request['token'] === 'test-token'
&& $request['website'] === 'https://example.com'
);
}
public function test_authentication_failure_is_not_retryable(): void
{
config()->set('services.website_company.token', 'invalid');
Http::fake([
'*' => Http::response(['message' => 'Unauthorized'], 401),
]);
try {
app(WebsiteCompanyClient::class)
->extract('https://example.com');
$this->fail('Expected CompanyInsightException.');
} catch (CompanyInsightException $exception) {
$this->assertFalse($exception->retryable);
$this->assertSame(401, $exception->status);
}
Http::assertSentCount(1);
}
}
Deploy and operate the integration
Run tests and rebuild cached configuration during deployment. Restart queue workers so they load the new code and any rotated token:
php artisan test
php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --queue=default --tries=4 --timeout=40
In production, a process manager should keep queue:work running. Its timeout must exceed the client’s bounded internal attempts while remaining below the worker manager’s termination limit.
Never log the service token, the complete outgoing URL, raw response bodies, or unnecessary contact data. Query-string authentication makes URL redaction especially important in HTTP diagnostics, proxies, and error-reporting tools. Restrict environment access, serve the form over HTTPS, and define retention rules for personal and company contact information.
Useful operational signals include the number of pending, completed, and failed enrichments; job age; retry counts; HTTP status groups; and enrichment latency. Logs should contain the local quote identifier and sanitized status metadata, as the job does, rather than the submitted contact details.
Common failures worth planning for
- HTTP 401 or 403: verify the service-scoped token and plan activation. If the token was regenerated, replace the revoked value and restart workers.
- HTTP 429: reduce worker concurrency, respect the bounded backoff, and check whether the selected plan matches real traffic.
- Persistent pending records: confirm that
QUEUE_CONNECTIONis notsyncand that a queue worker is running. - Empty enrichment: the submitted site may expose little public information, or the response contract may have changed. Preserve the quote and inspect sanitized diagnostics.
- Duplicate remote calls: workers provide at-least-once processing. The completed-state guard makes repeated deliveries harmless after the first successful update.
Final verification checklist
- Submit a valid quote and confirm the response is HTTP 202 without waiting for enrichment.
- Verify that the database record begins with
enrichment_statusset topending. - Run a queue worker and confirm the state becomes
completed. - Inspect
company_insightfor mappedcompany,contact,email,phone, andpeoplekeys. - Use an invalid test token and confirm one request produces a non-retryable failure without exposing the credential.
- Simulate a server error in a test and verify that retries remain bounded.
- Confirm production logs and monitoring systems redact outgoing query strings.
The important result is not merely richer lead data. It is a cleaner boundary between customer-facing speed and background uncertainty. The visitor gets an immediate acknowledgment, the team gets useful company context when it is available, and a slow or failed external service never gets to hold the form hostage.