Tutorials

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

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

A quote form should feel immediate. The visitor submits a few details, receives confirmation, and moves on. Yet the sales team benefits from knowing more than the form should reasonably ask: the company name, public contact details, and relevant people associated with the submitted website.

The clean solution is not a longer form or a synchronous API call. It is a short transaction followed by queued enrichment. Laravel returns the response as soon as the quote is stored, while a worker turns the company website into structured data in the background.

Get access to the company data service

Start by creating an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login 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.

This service is not token-free. It requires the token in the token={serviceToken} query parameter. Regenerating the token revokes the previously active token, so coordinate rotation with application deployment rather than regenerating it casually.

The exact request is an HTTPS GET to https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Before building the feature, make a minimal request with placeholder values:

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"

Be aware that command history and process inspection can expose command-line arguments. Use this only as a controlled smoke test, never paste the real token into documentation or tickets, and clear sensitive local history according to your operating procedures.

Now place the credential in the project environment rather than source control:

# .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',
    ],
];

Commit only a placeholder in .env.example. Production secrets belong in the deployment platform’s secret store or protected environment configuration.

Architecture: save first, enrich second

This project assumes PHP 8.3 or newer, an existing Laravel application, a configured database, and a real asynchronous queue connection. Do not use the sync queue driver in production: it would execute enrichment inside the form request and defeat the design.

The request path has only three responsibilities:

  1. Validate and store the quote.
  2. Dispatch an enrichment job after the database transaction commits.
  3. Return HTTP 202 Accepted.

The queue job calls the external service, maps company, contact, email, phone, and people at the application boundary, then saves the normalized result. Transient failures are retried with bounded backoff; authentication and validation failures are recorded immediately.

The relevant project structure is deliberately small:

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

Persist explicit enrichment states

A nullable JSON column alone cannot distinguish “not started” from “failed.” Store a state and a machine-readable error code alongside the mapped data.

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

Map uncertain data at the boundary

The service contract names the returned fields, but production code should not assume undocumented inner shapes. The mapper below accepts JSON-safe scalar and array values, discards objects or resources, and stores only the five relevant fields. That prevents response-shape assumptions from spreading through the application.

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

Build a bounded HTTP client

Laravel’s built-in HTTP client is sufficient. Keep transport policy here so controllers and jobs do not need to understand status codes. Connection failures, server failures, malformed success bodies, and HTTP 429 are transient. Authentication and request rejection are permanent until configuration or input changes.

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

There is intentionally no immediate retry loop inside the HTTP client. Queue-level retries avoid holding a worker in repeated network calls, and their delay gives a rate limit or temporary outage time to recover.

Keep the form request fast

<?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');

Because this route lives in web.php, ordinary browser submissions also receive Laravel’s CSRF protection. The throttle limits simple abuse; public forms may additionally need application-appropriate bot controls.

Process enrichment with controlled retries

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

The logs contain quote IDs, safe reason codes, and HTTP statuses. They deliberately exclude the website, returned people data, response body, request URL, and token. This matters especially because query-string credentials can leak through indiscriminate URL logging.

Test both speed and boundary behavior

Http::fake() makes the external contract deterministic. One test proves that submission queues work without making an HTTP request; another verifies the exact method, endpoint, query parameters, and response mapping.

<?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

Security, observability, and deployment

Treat the enrichment as personal and business contact data, not harmless metadata. Restrict staff access, define a retention policy, and avoid copying the entire upstream response when the application needs only five fields. Validate only HTTP and HTTPS websites, and never use the submitted URL as a redirect or local fetch target elsewhere without separate safeguards.

Track counts of complete, pending, and failed records, queue age, job failures, HTTP status classes, and enrichment latency. Alert on sustained authentication failures because they usually indicate an absent, revoked, or stale token. Alert separately on rate limiting so plan capacity and traffic can be evaluated without confusing quota pressure with an outage.

Deploy the database migration before code that writes the new columns. Then cache configuration, restart workers so they load the new token and code, and run a supervised queue 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

Run the worker under the operating system’s process supervisor or the hosting platform’s managed worker facility. Configure the queue connection’s retry window to exceed the job and worker timeouts; otherwise a slow job can become visible twice and produce overlapping execution.

Common failures worth diagnosing deliberately

  • Every job reports authentication failure: confirm that the service-scoped token is present in production configuration. If it was regenerated, the previous active token is revoked. Refresh the secret, rebuild the configuration cache, and restart workers.
  • The form is still slow: verify that QUEUE_CONNECTION is not sync and that dispatch occurs after the quote is stored.
  • Quotes remain pending: check that a worker is running, consuming the correct queue, and able to reach the HTTPS endpoint.
  • HTTP 429 repeats: preserve the bounded delay instead of retrying immediately. Review request volume and the activated Free, Plus, or Pro plan.
  • Data is unexpectedly empty: inspect the fake and a safely redacted response shape, then adjust only the boundary mapper. Do not scatter response-shape guesses throughout controllers and models.

Final verification checklist

  • The form returns 202 after the local database transaction, without waiting for enrichment.
  • The queued request uses GET, the exact /v1/extract endpoint, and the required token and website query parameters.
  • The application maps only company, contact, email, phone, and people at the boundary.
  • Connection and response timeouts are bounded, transient retries are limited, and authentication or validation failures are not blindly retried.
  • No token, full request URL, response body, or personal contact data appears in logs or fixtures.
  • Production workers use refreshed configuration and are monitored for queue age, failures, and rate limiting.

The strongest enrichment feature is almost invisible to the person completing the form. The visitor gets a quick confirmation; the team gets useful company context moments later; and temporary upstream trouble becomes a controlled background state instead of a broken customer interaction. That separation is the difference between merely calling an API and integrating one responsibly.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.