Туториали

Laravel Quote Forms: Enrich Requests with Company Data Seamlessly

Laravel форми за понуди: беспрекорно збогатете ги барањата со податоци за компанијата

A quote form should feel immediate, even when the business wants more than a name and email. If a prospect provides a company website, we can turn that URL into structured company and contact data—but the browser should not wait while an external service examines the site.

This tutorial builds that workflow in Laravel and PHP 8.3. The application saves the quote request first, returns 202 Accepted, and enriches it through a queued job. The integration uses Laravel’s built-in HTTP client, maps uncertain external data into a controlled domain object, and treats authentication failures, rate limits, timeouts, retries, and deployment as first-class concerns.

Get access before writing integration code

This service requires a service-scoped token. Start by registering an account, or use the sign-in page if you already have one.

  1. Open the Website to Company data service page.
  2. Choose the available Free, Plus, or Pro plan and complete its activation.
  3. Open the official service documentation.
  4. Find the Service token panel and copy the service-scoped token shown there.

Regenerating the token revokes the previously active token. Treat rotation as a deployment change: update every environment that uses the credential, restart long-running workers, verify the new token, and only then consider the rotation complete.

Confirm the exact HTTP contract

The integration makes a GET request to https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. It sends the public website in the website query parameter and authenticates with the token={serviceToken} query parameter.

Make one minimal request before building the Laravel feature:

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'

A successful response contains company, contact, email, phone, and people data. We will map those five fields at the application boundary instead of allowing an external response to spread through the domain model.

Store the credential in Laravel configuration

Put the real token only in the deployed environment’s .env file. Commit the placeholder, never the credential, to .env.example.

WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database

Add the service to config/services.php. Keeping the endpoint fixed prevents an accidental environment change from sending the token to another host.

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

Design the request path around latency and failure

The external call does not belong in the form’s HTTP request. DNS resolution, remote processing, rate limiting, and transient network failures all have unpredictable latency. Instead, the request path is deliberately short:

  1. Validate and save the quote request.
  2. Dispatch an enrichment job after the database transaction commits.
  3. Return a response immediately.
  4. Let a queue worker call the service and persist the mapped result.

This creates eventual consistency: the quote exists immediately, while enrichment moves through pending, processing, completed, or failed. That trade-off is appropriate because company details improve the sales workflow but are not required to accept the quote request.

The relevant project structure is compact:

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

Create the durable quote record

A fresh Laravel application already includes the HTTP client. Create the application components, and create a database queue table if the project does not already have one:

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

If a jobs migration already exists, do not generate a duplicate. Add the following columns to the quote request migration. JSON columns preserve the service’s structured values without pretending that email or phone data must always be a single string.

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();
});

The model generates an unguessable public identifier and casts enrichment fields consistently:

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';
    }
}

Map the API response at one boundary

External JSON deserves suspicion. A response may be valid JSON while a field is absent, null, or represented differently from the shape your UI expects. The DTO below recognizes only the five contracted fields and normalizes each to a JSON-safe array. Unknown response fields never enter the application model.

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 [];
    }
}

Create a small exception carrying a stable failure category, retry decision, HTTP status, and optional delay. User-facing code should store a category such as authentication, not a raw response body.

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}");
    }
}

Build a bounded HTTP client

The client uses a three-second connection timeout and a ten-second total response timeout. It deliberately performs one network attempt. Queue retries supply the backoff, preventing nested retry loops from multiplying calls and consuming quota unexpectedly.

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);
    }
}

Validation and authentication failures are permanent until input or configuration changes, so retrying them would only waste quota. Connection failures, request timeouts, rate limits, and server errors are reasonable retry candidates.

Run enrichment in a resilient queue job

The job is safe to execute again after a worker crash: a completed quote exits early, while another successful execution overwrites the same enrichment columns. Backoff increases from ten seconds to one minute and then five minutes. A server-provided numeric Retry-After value is honored within a five-minute bound.

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',
        ]);
    }
}

Accept the form without waiting

The controller validates an HTTP or HTTPS URL, commits the quote, and dispatches the job only after that commit succeeds. Apply rate limiting at the route because an anonymous form can otherwise consume both local queue capacity and service quota.

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']);
});

The public UUID makes accidental enumeration difficult, but it is not a substitute for authorization. If quote details are account-bound, protect the status endpoint with the application’s normal authentication and policy checks.

Test dispatch, mapping, and permanent failures

Http::fake() keeps tests deterministic and proves that no real credential or network connection is required. The central cases are immediate dispatch, correct query parameters and mapping, and no retry loop for rejected credentials.

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',
        ]);
    }
}

Run the suite with php artisan test. Add cases for malformed JSON, connection exceptions, rate limiting, and exhausted retries as the integration evolves.

Security and observability boundaries

Never log the token, full request URL, authorization query string, or raw upstream response. Query-parameter credentials can leak through careless access logs and exception reporting, so structured application logs should contain only the quote’s public identifier, failure category, status code, and attempt number.

Validate the website before queueing it and throttle anonymous submissions. The remote service performs the website extraction, but accepting only public HTTP and HTTPS company URLs still keeps application intent clear. Consider additional business rules for localhost names, literal private IP addresses, and domains your product does not support.

Operationally, monitor counts and age for pending, processing, completed, and failed enrichment records. Alert on sustained authentication failures, unusual rate-limit volume, growing queue depth, and old processing rows. Those signals distinguish bad credentials from capacity problems and upstream incidents.

Deploy the worker, not just the web application

Production requires a continuously supervised queue worker. Supply WEBSITE_COMPANY_TOKEN through the hosting platform’s secret store, run migrations, cache configuration, and start the worker under systemd, Supervisor, or the platform’s worker facility.

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

queue:restart asks existing workers to exit gracefully after their current job, allowing the process supervisor to start workers with the new code and token. Ensure the worker’s own process timeout is longer than the job timeout. Retain failed-job records and inspect them with php artisan queue:failed; replay only after correcting the underlying cause.

Common failures worth diagnosing precisely

  • Every request reports authentication: confirm the environment contains the active service-scoped token, rebuild the configuration cache, and restart workers. A regenerated token invalidates its predecessor.
  • Quotes stay pending: the web application is dispatching jobs, but no worker is consuming the configured queue.
  • Quotes stay processing: inspect failed jobs and worker termination logs. A forced process shutdown may prevent the job’s failure callback from running.
  • Rate limits repeat: reduce submission abuse, verify plan capacity, and retain the bounded backoff. Adding immediate retries makes the problem worse.
  • The curl request works but Laravel does not: compare the worker environment with the interactive shell, clear stale configuration, and check outbound HTTPS access.
  • Fields are unexpectedly empty: inspect a sanitized response in a controlled development environment and update only the boundary mapper. Do not scatter response-shape assumptions across controllers and views.

Final verification checklist

  • The registration, plan activation, documentation, and service-token steps are complete.
  • The token exists only in environment-backed configuration.
  • The minimal GET request succeeds with website and token query parameters.
  • Submitting a quote returns 202 without waiting for enrichment.
  • A real queue worker changes the record from pending to completed.
  • Company, contact, email, phone, and people data are persisted through the DTO mapper.
  • Authentication and validation failures are not retried.
  • Timeouts, rate limits, and server failures receive bounded retries and useful logs.
  • Tests pass without external network access or real credentials.

The important result is larger than one enriched form. The quote request remains dependable even when the enrichment provider is slow or temporarily unavailable, while useful company context arrives moments later. That separation—fast acceptance at the edge, disciplined uncertainty in the background—is what turns a convenient API call into a production integration.

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

Mihajlo

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