Vodiči

Native PHP 8.3: Enrich Quote Forms with Company Data Using Website Intelligence

Izvorni PHP 8.3: Obogatite obrasce za ponude podacima o tvrtkama pomoću inteligencije web-stranica

A quote form should feel instant, even when the business wants more context than a visitor is willing to type. Asking prospects for industry, company profile, public contacts, and team information creates friction. Looking up those details during the request creates latency and introduces a new failure mode into a conversion-critical path.

The better design is asynchronous enrichment: validate and save the quote immediately, enqueue a small job in the same database transaction, and let a CLI worker call the Website to Company data service afterward. The customer receives a fast response; the team receives a richer quote record a few seconds later.

This tutorial builds that design in Native PHP 8.3 using cURL, PDO with SQLite, a transactional queue, defensive response mapping, bounded retries, and PHPUnit tests with a deterministic fake transport.

Get access before writing integration code

  1. Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
  2. Open the Website to Company data service page.
  3. Choose the available Free, Plus, or Pro plan and complete its activation.
  4. Open the official service documentation.
  5. Find the Service token panel and copy the service-scoped token shown there.

This service requires a token. Regenerating it revokes the previously active token, so coordinate rotation with deployment: update the application secret first, deploy or restart the worker, and then confirm a request succeeds with the new value. Never place the token in source control, fixtures, logs, screenshots, or exception messages.

The exact request is:

GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract
Query parameters:
  token={serviceToken}
  website={publicCompanyWebsite}

After activation, make one minimal test request. The use of --get and --data-urlencode prevents special characters in query values from corrupting the URL.

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

Store the credential in a local .env file, exclude that file from version control, and inject the same names through your deployment platform in production.

# .env
WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
DATABASE_PATH=var/app.sqlite

# .gitignore
.env
var/*.sqlite
var/*.sqlite-*

Architecture and trade-offs

The HTTP request performs only validation and a local transaction. That transaction inserts both the quote and its queue job, avoiding the awkward state where a quote is saved but its job is lost. The worker owns remote I/O and updates the quote independently.

  • Fast path: POST /quotes validates the submission, commits locally, and returns 202 Accepted.
  • Slow path: a CLI worker claims the job, calls the enrichment API, maps the response, and stores structured JSON.
  • Failure path: transient failures are rescheduled with bounded backoff; invalid credentials and malformed successful responses fail without blind retries.

SQLite is appropriate for a small site running one worker and keeps the example operationally modest. A busier multi-host deployment should retain the same boundaries while replacing the queue table with its established durable queue. Do not run several SQLite workers without designing and load-testing the claim strategy for that workload.

Project structure

quote-enrichment/
├── bin/
│   ├── init.php
│   └── worker.php
├── public/
│   └── index.php
├── src/
│   ├── CompanyData.php
│   ├── CompanyDataClient.php
│   ├── CurlTransport.php
│   ├── HttpResponse.php
│   ├── Transport.php
│   └── QuoteController.php
├── tests/
│   └── CompanyDataClientTest.php
├── var/
├── .env
└── composer.json

Bootstrap the Native PHP project

Prerequisites are PHP 8.3 or later, Composer, cURL, PDO SQLite, and JSON support. Install PHPUnit as the only package; production HTTP traffic still uses native cURL.

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  },
  "scripts": {
    "test": "phpunit"
  }
}

The initializer creates both records and queue jobs. Enrichment remains nullable because a quote is useful even when the remote service is unavailable.

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

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

$path = getenv('DATABASE_PATH') ?: $root . '/var/app.sqlite';
if (!str_starts_with($path, '/')) {
    $path = $root . '/' . $path;
}
if (!is_dir(dirname($path))) {
    mkdir(dirname($path), 0770, true);
}

$db = new PDO('sqlite:' . $path, options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$db->exec('<<<SQL
CREATE TABLE IF NOT EXISTS quotes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT NOT NULL,
    website TEXT NOT NULL,
    message TEXT NOT NULL,
    enrichment_json TEXT,
    enrichment_status TEXT NOT NULL DEFAULT "pending",
    created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS jobs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    quote_id INTEGER NOT NULL UNIQUE,
    status TEXT NOT NULL DEFAULT "queued",
    attempts INTEGER NOT NULL DEFAULT 0,
    available_at INTEGER NOT NULL,
    last_error TEXT,
    FOREIGN KEY (quote_id) REFERENCES quotes(id)
);
SQL);

Isolate the HTTP boundary

A transport interface makes tests deterministic. The cURL implementation captures response headers, enforces TLS verification, and places strict limits on connection and total response time.

<?php
// src/Transport.php
namespace App;

interface Transport
{
    public function get(string $url, int $connectTimeout, int $timeout): HttpResponse;
}

// src/HttpResponse.php
namespace App;

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

// src/CurlTransport.php
namespace App;

use RuntimeException;

final class CurlTransport implements Transport
{
    public function get(string $url, int $connectTimeout, int $timeout): HttpResponse
    {
        $headers = [];
        $handle = curl_init($url);

        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => $connectTimeout,
            CURLOPT_TIMEOUT => $timeout,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_USERAGENT => 'quote-enrichment/1.0',
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$headers): int {
                $length = strlen($line);
                if (str_contains($line, ':')) {
                    [$name, $value] = explode(':', $line, 2);
                    $headers[strtolower(trim($name))] = trim($value);
                }
                return $length;
            },
        ]);

        $body = curl_exec($handle);
        if ($body === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new RuntimeException('Transport failure: ' . $message);
        }

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

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

The domain object exposes only the supplied contract fields. Because the documentation contract does not require a more specific shape for every field, the mapper accepts strings, arrays, or null for company, contact, email, and phone, while requiring people to be an array. It also tolerates either a top-level result or a conventional data envelope without depending on undocumented nested properties.

<?php
// src/CompanyData.php
namespace App;

use UnexpectedValueException;

final readonly class CompanyData
{
    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
    {
        $source = isset($payload['data']) && is_array($payload['data'])
            ? $payload['data']
            : $payload;

        $value = static function (string $key) use ($source): array|string|null {
            $item = $source[$key] ?? null;
            if ($item !== null && !is_string($item) && !is_array($item)) {
                throw new UnexpectedValueException("Invalid {$key} field");
            }
            return $item;
        };

        $people = $source['people'] ?? [];
        if (!is_array($people)) {
            throw new UnexpectedValueException('Invalid people field');
        }

        return new self(
            $value('company'),
            $value('contact'),
            $value('email'),
            $value('phone'),
            $people
        );
    }

    public function toArray(): array
    {
        return get_object_vars($this);
    }
}

Add deliberate retry behavior

The client retries transport errors, 429, and server errors. It does not retry authentication, authorization, validation, or other client failures. A Retry-After value is honored when it is a reasonable number of seconds; otherwise the client uses short exponential backoff. The job queue supplies a second, longer retry boundary after the in-process attempts are exhausted.

<?php
// src/CompanyDataClient.php
namespace App;

use JsonException;
use RuntimeException;
use Throwable;

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

    public function __construct(
        private Transport $transport,
        private string $token,
        private $sleep = null
    ) {
        $this->sleep ??= static fn(int $milliseconds) =>
            usleep($milliseconds * 1000);
    }

    public function extract(string $website): CompanyData
    {
        $url = self::ENDPOINT . '?' . http_build_query([
            'token' => $this->token,
            'website' => $website,
        ], encoding_type: PHP_QUERY_RFC3986);

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->get($url, 2, 8);
            } catch (Throwable $error) {
                if ($attempt === 3) {
                    throw new RuntimeException('Enrichment transport unavailable', 0, $error);
                }
                ($this->sleep)(200 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status === 429 || $response->status >= 500) {
                if ($attempt === 3) {
                    throw new RuntimeException(
                        'Retryable enrichment HTTP status ' . $response->status
                    );
                }

                $retryAfter = ctype_digit($response->headers['retry-after'] ?? '')
                    ? (int) $response->headers['retry-after'] * 1000
                    : 200 * (2 ** ($attempt - 1));

                ($this->sleep)(min($retryAfter, 5000));
                continue;
            }

            if ($response->status < 200 || $response->status >= 300) {
                throw new RuntimeException(
                    'Non-retryable enrichment HTTP status ' . $response->status
                );
            }

            try {
                $payload = json_decode(
                    $response->body,
                    true,
                    flags: JSON_THROW_ON_ERROR
                );
            } catch (JsonException $error) {
                throw new RuntimeException('Invalid enrichment JSON', 0, $error);
            }

            if (!is_array($payload)) {
                throw new RuntimeException('Unexpected enrichment response');
            }

            return CompanyData::fromPayload($payload);
        }

        throw new RuntimeException('Unreachable retry state');
    }
}

Keep the quote request fast

The controller rejects malformed email addresses, non-HTTP websites, IP-literal hosts, and oversized input. Blocking IP literals reduces abuse and accidental submission of internal-looking addresses, although the remote service must still enforce its own safe-fetch policy.

<?php
// src/QuoteController.php
namespace App;

use PDO;

final class QuoteController
{
    public function __construct(private PDO $db) {}

    public function create(array $input): array
    {
        $name = trim((string) ($input['name'] ?? ''));
        $email = trim((string) ($input['email'] ?? ''));
        $website = trim((string) ($input['website'] ?? ''));
        $message = trim((string) ($input['message'] ?? ''));

        $parts = parse_url($website);
        $host = $parts['host'] ?? '';
        $validWebsite = in_array($parts['scheme'] ?? '', ['http', 'https'], true)
            && $host !== ''
            && filter_var($host, FILTER_VALIDATE_IP) === false;

        if ($name === '' || strlen($name) > 120
            || !filter_var($email, FILTER_VALIDATE_EMAIL)
            || !$validWebsite || strlen($website) > 2048
            || $message === '' || strlen($message) > 5000) {
            return [422, ['error' => 'Invalid quote request']];
        }

        $this->db->beginTransaction();
        try {
            $quote = $this->db->prepare(
                'INSERT INTO quotes
                 (name, email, website, message, created_at)
                 VALUES (?, ?, ?, ?, ?)'
            );
            $quote->execute([
                $name, $email, $website, $message, gmdate(DATE_ATOM)
            ]);

            $id = (int) $this->db->lastInsertId();
            $job = $this->db->prepare(
                'INSERT INTO jobs (quote_id, available_at) VALUES (?, ?)'
            );
            $job->execute([$id, time()]);
            $this->db->commit();

            return [202, ['quote_id' => $id, 'enrichment_status' => 'pending']];
        } catch (\Throwable $error) {
            $this->db->rollBack();
            throw $error;
        }
    }
}

The front controller loads JSON, opens the database, and returns a consistent JSON response. In production, configure the web server so only public/ is web-accessible.

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

use App\QuoteController;

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

header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
    || parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) !== '/quotes') {
    http_response_code(404);
    echo json_encode(['error' => 'Not found']);
    exit;
}

$path = getenv('DATABASE_PATH') ?: dirname(__DIR__) . '/var/app.sqlite';
$db = new PDO('sqlite:' . $path, options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);

try {
    $input = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR);
    [$status, $body] = (new QuoteController($db))->create(
        is_array($input) ? $input : []
    );
} catch (JsonException) {
    $status = 400;
    $body = ['error' => 'Invalid JSON'];
} catch (Throwable $error) {
    error_log(json_encode([
        'event' => 'quote_create_failed',
        'type' => $error::class,
    ]));
    $status = 500;
    $body = ['error' => 'Unable to accept quote'];
}

http_response_code($status);
echo json_encode($body, JSON_THROW_ON_ERROR);

Process enrichment outside the request

The worker claims one queued job inside an immediate SQLite transaction, increments its attempt count, and then releases the database lock before making the network call. It logs identifiers and states, never the token, full response, prospect message, or contact data.

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

use App\CompanyDataClient;
use App\CurlTransport;

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

$root = dirname(__DIR__);
$token = getenv('WEBSITE_COMPANY_TOKEN');
if (!is_string($token) || $token === '') {
    throw new RuntimeException('WEBSITE_COMPANY_TOKEN is required');
}

$path = getenv('DATABASE_PATH') ?: $root . '/var/app.sqlite';
$db = new PDO('sqlite:' . $path, options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$client = new CompanyDataClient(new CurlTransport(), $token);

while (true) {
    $db->exec('BEGIN IMMEDIATE');
    $job = $db->query(
        'SELECT jobs.id, jobs.quote_id, jobs.attempts, quotes.website
         FROM jobs JOIN quotes ON quotes.id = jobs.quote_id
         WHERE jobs.status = "queued" AND jobs.available_at <= ' . time() . '
         ORDER BY jobs.id LIMIT 1'
    )->fetch();

    if (!$job) {
        $db->exec('COMMIT');
        sleep(1);
        continue;
    }

    $claim = $db->prepare(
        'UPDATE jobs SET status = "processing", attempts = attempts + 1
         WHERE id = ? AND status = "queued"'
    );
    $claim->execute([$job['id']]);
    $db->exec('COMMIT');

    try {
        $data = $client->extract($job['website']);
        $db->beginTransaction();

        $update = $db->prepare(
            'UPDATE quotes SET enrichment_json = ?, enrichment_status = "complete"
             WHERE id = ?'
        );
        $update->execute([
            json_encode($data->toArray(), JSON_THROW_ON_ERROR),
            $job['quote_id'],
        ]);
        $db->prepare('UPDATE jobs SET status = "complete" WHERE id = ?')
            ->execute([$job['id']]);
        $db->commit();

        error_log(json_encode([
            'event' => 'enrichment_complete',
            'quote_id' => (int) $job['quote_id'],
        ]));
    } catch (Throwable $error) {
        $attempt = (int) $job['attempts'] + 1;
        $permanent = str_contains($error->getMessage(), 'Non-retryable')
            || str_contains($error->getMessage(), 'Invalid enrichment')
            || str_contains($error->getMessage(), 'Unexpected enrichment');
        $retry = !$permanent && $attempt < 5;
        $delay = min(60 * (2 ** ($attempt - 1)), 900);

        $statement = $db->prepare(
            'UPDATE jobs SET status = ?, available_at = ?, last_error = ?
             WHERE id = ?'
        );
        $statement->execute([
            $retry ? 'queued' : 'failed',
            time() + $delay,
            substr($error->getMessage(), 0, 300),
            $job['id'],
        ]);
        if (!$retry) {
            $db->prepare(
                'UPDATE quotes SET enrichment_status = "failed" WHERE id = ?'
            )->execute([$job['quote_id']]);
        }

        error_log(json_encode([
            'event' => 'enrichment_failed',
            'quote_id' => (int) $job['quote_id'],
            'attempt' => $attempt,
            'retrying' => $retry,
            'type' => $error::class,
        ]));
    }
}

Test retries and boundary mapping

The fake transport returns predetermined responses without network access or real credentials. The test proves that a quota response is retried and that the supplied company, contact, email, phone, and people fields reach the domain object.

<?php
// tests/CompanyDataClientTest.php
namespace Tests;

use App\CompanyDataClient;
use App\HttpResponse;
use App\Transport;
use PHPUnit\Framework\TestCase;

final class CompanyDataClientTest extends TestCase
{
    public function testRetriesRateLimitAndMapsResponse(): void
    {
        $fake = new class implements Transport {
            public int $calls = 0;

            public function get(
                string $url,
                int $connectTimeout,
                int $timeout
            ): HttpResponse {
                $this->calls++;
                TestCase::assertStringContainsString(
                    'website=https%3A%2F%2Fexample.com',
                    $url
                );
                TestCase::assertSame(2, $connectTimeout);
                TestCase::assertSame(8, $timeout);

                if ($this->calls === 1) {
                    return new HttpResponse(429, ['retry-after' => '1'], '{}');
                }

                return new HttpResponse(200, [], json_encode([
                    'data' => [
                        'company' => ['name' => 'Example'],
                        'contact' => 'Contact page',
                        'email' => '[email protected]',
                        'phone' => null,
                        'people' => [['name' => 'A. Person']],
                    ],
                ], JSON_THROW_ON_ERROR));
            }
        };

        $sleeps = [];
        $client = new CompanyDataClient(
            $fake,
            'test-token',
            function (int $milliseconds) use (&$sleeps): void {
                $sleeps[] = $milliseconds;
            }
        );

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

        self::assertSame(2, $fake->calls);
        self::assertSame([1000], $sleeps);
        self::assertSame('[email protected]', $result->email);
        self::assertCount(1, $result->people);
    }
}

Run and verify the complete flow

composer install
set -a
. ./.env
set +a

php bin/init.php
php -S 127.0.0.1:8080 -t public
# In another terminal with the same environment:
php bin/worker.php

curl --silent --show-error \
  -H 'Content-Type: application/json' \
  -d '{"name":"Sam","email":"[email protected]","website":"https://example.com","message":"Please quote the redesign."}' \
  http://127.0.0.1:8080/quotes

composer test

Security, observability, and deployment notes

Protect the quote route with the same CSRF defense used by the surrounding website, and add request throttling before exposing it publicly. Treat enrichment as untrusted external data: escape it when rendered, restrict access to staff who need it, define a retention period, and avoid copying personal contact details into broad logs or analytics systems.

Monitor counts of accepted quotes, completed enrichments, failures, retry attempts, and job age. A growing oldest-job age is often more useful than raw error volume because it exposes a stopped worker as well as a slow provider. Alert separately on authentication failures; repeated retries cannot repair a revoked token.

Run the worker under a process supervisor or container restart policy. During deployment, apply the schema before starting new workers, inject the token through the platform’s secret store, and ensure var/ is writable by the application identity but not publicly served. Back up the quote database, test restoration, and restart long-running workers after rotating configuration.

Common failures

  • Immediate 401 or 403 response: verify the service-scoped token and whether it was regenerated. Do not retry indefinitely.
  • Repeated 429 responses: allow the queue to delay work, review plan capacity, and avoid adding more web-request retries.
  • Valid JSON with unexpected field types: keep the quote, fail enrichment visibly, and update the boundary mapper only after confirming the official contract.
  • Quotes remain pending: check that the worker is running with the same database path and environment as the web process.
  • SQLite lock pressure: shorten transactions, retain network calls outside locks, or move the established queue boundary to infrastructure suited to higher concurrency.

Final verification checklist

  • The quote endpoint returns 202 before enrichment finishes.
  • The quote and queue job are created atomically.
  • The worker sends the exact GET endpoint with token and website query parameters.
  • Company, contact, email, phone, and people are validated at the application boundary.
  • Connection and response timeouts are bounded.
  • Only transport errors, rate limits, and server failures receive automatic retries.
  • No token, response payload, or prospect contact data appears in logs.
  • PHPUnit passes without a network connection or genuine credential.
  • Worker health, job age, failures, and token rotation are covered operationally.

The lasting design lesson is larger than this particular form: enrichment should improve a business workflow without becoming a condition for accepting the customer’s request. Save the valuable intent first, enrich it behind a durable boundary, and make failure explicit. That separation gives visitors a responsive form and gives operators a system they can understand when the network, quota, token, or upstream response inevitably behaves differently from the happy path.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.