Туториали

Enrich Native PHP Quote Forms with Real-Time Company Insights

Збогатете ги изворните PHP формулари за понуди со увиди за компаниите во реално време

A quote form should feel instant, even when the business wants more context than a prospect is willing to type. Asking for a company website is reasonable; asking someone to describe their company, list decision-makers, and copy contact details is not.

The right design is to accept the quote request immediately, then enrich it in a background worker. This tutorial builds that workflow in Native PHP 8.3: a small form writes to SQLite, returns a redirect without waiting on an external service, and lets a bounded worker turn the submitted website into structured company and contact data.

Get access to the Website to Company data service

Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.

Next, open the Website to Company data service page. Choose an available Free, Plus, or Pro plan and complete its activation.

Then visit the official service documentation. Find the Service token panel and copy the service-scoped token shown there. This service requires that token. Regenerating it revokes the previously active token, so rotation must update the application environment before old workers make another request.

The exact API operation is:

GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract

Query parameters:
token={serviceToken}
website={publicCompanyWebsite}

Make one minimal request before writing application code:

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

Keep the credential out of source control. For local development, create an ignored .env file containing shell-compatible assignments:

MIHAJLO_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
QUOTE_DB_PATH=/var/lib/quote-enricher/quotes.sqlite

Load it into the process environment with set -a; . ./.env; set +a. Native PHP does not read .env automatically. In production, inject the same variables through the process manager or an access-controlled systemd EnvironmentFile. Add .env to .gitignore, while committing an .env.example containing placeholders only.

Design for a fast form, not a fast dependency

A synchronous integration would make every quote submission wait for DNS, TLS, remote processing, and possible retries. It would also turn a temporary enrichment outage into a broken lead form.

Instead, the request path performs only local validation and one database insert. A worker later claims the row, calls the enrichment API, maps the response at the application boundary, and records either the result or a structured failure.

Enrichment should improve a quote request after it has been accepted, never become a condition for accepting it.

This example uses SQLite because it is practical for a freelancer or small team running one application instance. Its short write transactions are sufficient for a modest quote queue. Multiple application servers or sustained worker concurrency would justify moving the same state machine to PostgreSQL or a dedicated queue.

Project layout and prerequisites

You need PHP 8.3 or newer with cURL, JSON, PDO, and PDO SQLite, plus Composer. The project contains no runtime framework:

quote-enricher/
├── bin/
│   ├── init-db.php
│   └── worker.php
├── config/
│   └── bootstrap.php
├── database/
│   └── schema.sql
├── public/
│   ├── index.php
│   └── submit.php
├── src/Enrichment/
│   └── WebsiteCompanyClient.php
└── tests/
    └── WebsiteCompanyClientTest.php

Use Composer for autoloading and PHPUnit 11 tests:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "classmap": ["src/"]
  },
  "autoload-dev": {
    "classmap": ["tests/"]
  }
}

Run composer install, then create the queue table:

CREATE TABLE quote_requests (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    requester_email TEXT NOT NULL,
    website TEXT NOT NULL,
    enrichment_state TEXT NOT NULL DEFAULT 'pending',
    enrichment_json TEXT,
    error_kind TEXT,
    attempts INTEGER NOT NULL DEFAULT 0,
    available_at INTEGER NOT NULL,
    locked_until INTEGER,
    created_at TEXT NOT NULL
);

CREATE INDEX quote_enrichment_queue
ON quote_requests (enrichment_state, available_at);

The bootstrap file opens the database with exceptions enabled and a busy timeout:

<?php
// config/bootstrap.php
declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';

$path = getenv('QUOTE_DB_PATH');
if ($path === false || $path === '') {
    throw new RuntimeException('QUOTE_DB_PATH is required');
}

$pdo = new PDO('sqlite:' . $path, null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('PRAGMA busy_timeout = 5000');
$pdo->exec('PRAGMA journal_mode = WAL');

return $pdo;

Build a defensive API boundary

The external response belongs at one narrow boundary. The mapper accepts only the supplied company, contact, email, phone, and people fields, validating their types instead of allowing uncertain JSON to spread through the application.

The client uses bounded one-second connection and four-second total timeouts. It retries transport errors, HTTP 408, and server errors twice with short backoff. Authentication, validation, and other ordinary client errors are not retried. HTTP 429 is returned as a rate-limit state so the durable worker can defer the job instead of sleeping while holding it.

<?php
// src/Enrichment/WebsiteCompanyClient.php
declare(strict_types=1);

namespace App\Enrichment;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body,
    ) {}
}

interface HttpTransport
{
    public function get(
        string $url,
        array $query,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse;
}

final class TransportException extends \RuntimeException {}

final class ApiException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status = null,
        public readonly ?int $retryAfter = null,
    ) {
        parent::__construct($kind);
    }
}

final readonly class CompanyInsights
{
    public function __construct(
        public ?array $company,
        public ?array $contact,
        public ?string $email,
        public ?string $phone,
        public array $people,
    ) {}

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

    private static function stringOrNull(mixed $value): ?string
    {
        if (!is_string($value) || trim($value) === '') {
            return null;
        }

        return trim($value);
    }

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

final class CurlTransport implements HttpTransport
{
    public function get(
        string $url,
        array $query,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse {
        $headers = [];
        $requestUrl = $url . '?' . http_build_query(
            $query,
            '',
            '&',
            PHP_QUERY_RFC3986
        );

        $handle = curl_init($requestUrl);
        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $timeoutMs,
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
            CURLOPT_USERAGENT => 'quote-enricher/1.0',
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$headers): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
        ]);

        $body = curl_exec($handle);
        if ($body === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new TransportException($message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResponse($status, $headers, $body);
    }
}

final class WebsiteCompanyClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract';

    private \Closure $sleep;

    public function __construct(
        private readonly HttpTransport $http,
        private readonly string $token,
        ?\Closure $sleep = null,
    ) {
        if ($token === '') {
            throw new \InvalidArgumentException('Service token is required');
        }

        $this->sleep = $sleep ?? static fn (int $microseconds) =>
            usleep($microseconds);
    }

    public function extract(string $website): CompanyInsights
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->get(
                    self::ENDPOINT,
                    ['token' => $this->token, 'website' => $website],
                    1000,
                    4000,
                );
            } catch (TransportException $exception) {
                if ($attempt === 3) {
                    throw new ApiException('transport');
                }
                ($this->sleep)($attempt === 1 ? 250000 : 750000);
                continue;
            }

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $payload = json_decode(
                        $response->body,
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );
                } catch (\JsonException) {
                    throw new ApiException('invalid_response', $response->status);
                }

                if (!is_array($payload)) {
                    throw new ApiException('invalid_response', $response->status);
                }

                return CompanyInsights::fromPayload($payload);
            }

            if ($response->status === 429) {
                $value = $response->headers['retry-after'] ?? null;
                $delay = is_string($value) && ctype_digit($value)
                    ? min(3600, max(1, (int) $value))
                    : 60;
                throw new ApiException('rate_limited', 429, $delay);
            }

            if ($response->status === 401 || $response->status === 403) {
                throw new ApiException('authentication', $response->status);
            }

            $retryable = $response->status === 408 ||
                $response->status >= 500;

            if (!$retryable) {
                throw new ApiException('request_rejected', $response->status);
            }

            if ($attempt === 3) {
                throw new ApiException('upstream', $response->status);
            }

            ($this->sleep)($attempt === 1 ? 250000 : 750000);
        }

        throw new ApiException('upstream');
    }
}

Accept the quote before enrichment begins

The form should collect a name, reply address, and public company website. Generate a CSRF token with random_bytes() when rendering public/index.php and include it in a hidden field:

<?php
declare(strict_types=1);
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
?>
<form method="post" action="/submit.php">
  <input name="name" required>
  <input name="email" type="email" required>
  <input name="website" type="url" required>
  <input type="hidden" name="csrf"
         value="<?= htmlspecialchars($_SESSION['csrf'], ENT_QUOTES) ?>">
  <button type="submit">Request a quote</button>
</form>

The submission endpoint performs no API call. It validates the public URL shape, writes a pending job, and redirects immediately:

<?php
// public/submit.php
declare(strict_types=1);

session_start();
$pdo = require dirname(__DIR__) . '/config/bootstrap.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

$csrf = (string) ($_POST['csrf'] ?? '');
if (!isset($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $csrf)) {
    http_response_code(403);
    exit;
}

$name = trim((string) ($_POST['name'] ?? ''));
$email = trim((string) ($_POST['email'] ?? ''));
$website = trim((string) ($_POST['website'] ?? ''));
$parts = parse_url($website);

$validWebsite = filter_var($website, FILTER_VALIDATE_URL) !== false
    && is_array($parts)
    && in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true)
    && isset($parts['host'])
    && !isset($parts['user'], $parts['pass']);

if ($name === '' ||
    !filter_var($email, FILTER_VALIDATE_EMAIL) ||
    !$validWebsite
) {
    http_response_code(422);
    exit;
}

$id = bin2hex(random_bytes(16));
$statement = $pdo->prepare(
    'INSERT INTO quote_requests
     (id, name, requester_email, website, available_at, created_at)
     VALUES (:id, :name, :email, :website, :available_at, :created_at)'
);
$statement->execute([
    'id' => $id,
    'name' => $name,
    'email' => $email,
    'website' => $website,
    'available_at' => time(),
    'created_at' => gmdate(DATE_ATOM),
]);

unset($_SESSION['csrf']);
http_response_code(303);
header('Location: /thanks.php?id=' . rawurlencode($id));
exit;

A real form should show friendly validation messages rather than empty 422 responses, but the queue boundary remains the same. Notice that the requester’s email and the enriched company email are stored separately; silently overwriting user-supplied contact data would be a serious domain mistake.

Process jobs with leases and durable backoff

The worker claims a row inside a short BEGIN IMMEDIATE transaction. A lease allows another invocation to recover work if the process crashes. Rate limits and exhausted transient calls return to the queue with bounded delays; authentication and invalid-response failures remain visible for intervention.

<?php
// bin/worker.php
declare(strict_types=1);

use App\Enrichment\ApiException;
use App\Enrichment\CurlTransport;
use App\Enrichment\WebsiteCompanyClient;

$pdo = require dirname(__DIR__) . '/config/bootstrap.php';
$token = getenv('MIHAJLO_COMPANY_TOKEN') ?: '';
$client = new WebsiteCompanyClient(new CurlTransport(), $token);

function claim(PDO $pdo): ?array
{
    $now = time();
    $pdo->exec('BEGIN IMMEDIATE');

    try {
        $statement = $pdo->prepare(
            "SELECT * FROM quote_requests
             WHERE available_at <= :now
               AND (
                 enrichment_state IN ('pending', 'deferred')
                 OR (enrichment_state = 'processing' AND locked_until < :now)
               )
             ORDER BY created_at
             LIMIT 1"
        );
        $statement->execute(['now' => $now]);
        $row = $statement->fetch();

        if (!$row) {
            $pdo->commit();
            return null;
        }

        $update = $pdo->prepare(
            "UPDATE quote_requests
             SET enrichment_state = 'processing',
                 attempts = attempts + 1,
                 locked_until = :locked_until
             WHERE id = :id"
        );
        $update->execute([
            'locked_until' => $now + 30,
            'id' => $row['id'],
        ]);
        $pdo->commit();

        $row['attempts'] = (int) $row['attempts'] + 1;
        return $row;
    } catch (Throwable $exception) {
        $pdo->rollBack();
        throw $exception;
    }
}

for ($processed = 0; $processed < 25; $processed++) {
    $quote = claim($pdo);
    if ($quote === null) {
        break;
    }

    try {
        $insights = $client->extract($quote['website']);
        $update = $pdo->prepare(
            "UPDATE quote_requests
             SET enrichment_state = 'complete',
                 enrichment_json = :json,
                 error_kind = NULL,
                 locked_until = NULL
             WHERE id = :id AND enrichment_state = 'processing'"
        );
        $update->execute([
            'json' => json_encode(
                $insights->toArray(),
                JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
            ),
            'id' => $quote['id'],
        ]);

        error_log(json_encode([
            'event' => 'quote_enrichment_complete',
            'quote_id' => $quote['id'],
            'attempt' => $quote['attempts'],
        ], JSON_THROW_ON_ERROR));
    } catch (ApiException $exception) {
        $transient = in_array(
            $exception->kind,
            ['rate_limited', 'transport', 'upstream'],
            true
        );

        if ($transient && $quote['attempts'] < 5) {
            $delay = $exception->retryAfter ??
                min(3600, 60 * (2 ** ($quote['attempts'] - 1)));
            $state = 'deferred';
        } else {
            $delay = 0;
            $state = 'failed';
        }

        $update = $pdo->prepare(
            'UPDATE quote_requests
             SET enrichment_state = :state,
                 error_kind = :kind,
                 available_at = :available_at,
                 locked_until = NULL
             WHERE id = :id'
        );
        $update->execute([
            'state' => $state,
            'kind' => $exception->kind,
            'available_at' => time() + $delay,
            'id' => $quote['id'],
        ]);

        error_log(json_encode([
            'event' => 'quote_enrichment_failed',
            'quote_id' => $quote['id'],
            'kind' => $exception->kind,
            'status' => $exception->status,
            'attempt' => $quote['attempts'],
            'next_delay_seconds' => $delay,
        ], JSON_THROW_ON_ERROR));
    }
}

The logs deliberately contain a quote identifier and operational state, but no service token, response body, website, personal email, or phone number. That is enough to correlate failures without turning centralized logs into another sensitive-data store.

Test retries without calling the network

A transport interface makes failure paths deterministic. The fake below proves mapping, server-error retry behavior, and the rule that authentication failures are never retried:

<?php
declare(strict_types=1);

use App\Enrichment\ApiException;
use App\Enrichment\HttpResponse;
use App\Enrichment\HttpTransport;
use App\Enrichment\WebsiteCompanyClient;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements HttpTransport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function get(
        string $url,
        array $query,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse {
        return $this->responses[$this->calls++];
    }
}

final class WebsiteCompanyClientTest extends TestCase
{
    public function testRetriesServerFailureAndMapsKnownFields(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(503, [], '{}'),
            new HttpResponse(200, [], json_encode([
                'company' => ['name' => 'Example Ltd'],
                'contact' => ['page' => '/contact'],
                'email' => '[email protected]',
                'phone' => '+1 555 0100',
                'people' => [['name' => 'Alex'], 'invalid-entry'],
            ], JSON_THROW_ON_ERROR)),
        ]);
        $delays = [];

        $client = new WebsiteCompanyClient(
            $fake,
            'test-token',
            static function (int $delay) use (&$delays): void {
                $delays[] = $delay;
            }
        );

        $result = $client->extract('https://example.com');

        self::assertSame(2, $fake->calls);
        self::assertSame([250000], $delays);
        self::assertSame('Example Ltd', $result->company['name']);
        self::assertSame('[email protected]', $result->email);
        self::assertCount(1, $result->people);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(401, [], '{}'),
        ]);
        $client = new WebsiteCompanyClient(
            $fake,
            'bad-token',
            static fn (int $delay) => null
        );

        try {
            $client->extract('https://example.com');
            self::fail('Expected ApiException');
        } catch (ApiException $exception) {
            self::assertSame('authentication', $exception->kind);
            self::assertSame(1, $fake->calls);
        }
    }
}

Run the suite with vendor/bin/phpunit tests. Add worker-level integration tests against a temporary SQLite database for lease recovery, maximum attempts, and transitions from pending to complete, deferred, or failed.

Security, deployment, and common failures

Restrict the token to worker processes if your deployment permits separate environments. Protect the environment file and database with operating-system permissions, serve only the public directory, retain CSRF protection, and escape enrichment values whenever they enter HTML. Treat returned people and contact data as untrusted input, not presentation-ready markup.

Because authentication is a query parameter by contract, access logs and error tooling must not record complete outbound URLs. HTTPS protects the request in transit, but application and proxy logging still require deliberate redaction.

Run bin/worker.php every minute with a systemd timer or scheduler, under a dedicated user with write access to the SQLite directory. Deploy schema changes before application code, run composer install --no-dev --classmap-authoritative, and restart long-lived processes after token rotation. Alert on growing pending counts, repeated authentication failures, rate-limit deferrals, and jobs reaching failed.

Typical failure patterns are straightforward:

  • Immediate 401 or 403 responses: verify the service-scoped token, plan activation, and whether someone regenerated the token.
  • Repeated 429 responses: preserve the durable deferral, reduce worker throughput, and review plan capacity rather than adding aggressive retries.
  • Invalid-response failures: retain the structured failure, inspect a securely captured response outside normal logs, and update only the boundary mapper if the documented contract changed.
  • Jobs stuck in processing: confirm the scheduler is running and that the lease can expire; do not manually duplicate the quote request.
  • Database lock errors: keep transactions short, verify WAL support and directory permissions, or move the queue to a server database when concurrency outgrows SQLite.

Final verification checklist

  • The account and Free, Plus, or Pro plan are active, and the service token comes from the documentation page’s Service token panel.
  • The token exists only in environment-backed configuration and is absent from source, fixtures, screenshots, and logs.
  • A valid form submission redirects before any enrichment request begins.
  • The worker calls the exact GET endpoint with token and website query parameters.
  • Only validated company, contact, email, phone, and people values cross the API boundary.
  • Timeouts, bounded retries, rate-limit deferral, leases, and maximum attempts behave as tested.
  • Operational logs identify states without exposing credentials or enriched personal data.
  • A temporary API failure never prevents the original quote request from being saved.

The most valuable part of this integration is not the extra company data. It is the boundary around it. The quote form remains dependable, the worker remains honest about failure, and uncertain external JSON becomes a small, explicit domain object. That is how enrichment earns its place in a production application: useful when available, observable when troubled, and never standing between a prospective customer and the submit button.

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

Mihajlo

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