Native PHP 8.3: Enrich Quote Requests with Company Data Seamlessly
A quote form should feel instant, even when the useful work begins after the customer presses Submit. A freelancer or small development team may want to know which company is asking for a proposal, but making an external enrichment request inside the form’s HTTP lifecycle introduces avoidable latency and another reason for submission to fail.
This project solves that tension with a small, production-minded Native PHP 8.3 application. The public endpoint validates and stores the quote request, queues an enrichment job in SQLite, and immediately returns 202 Accepted. A separate worker turns the submitted public website into structured company and contact data through the Website to Company data API.
The result is deliberately modest architecture: PHP, PDO, native cURL, and a worker supervised by the operating system. There is no framework or message broker to operate, yet the important boundaries remain explicit and testable.
Get access and copy the service token
- Create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.
- Open the Website to Company data service page.
- Choose the available Free, Plus, or Pro plan and complete its activation.
- Open the official service documentation.
- Find the Service token panel and copy the service-scoped token shown there.
This service is not token-free. Every request must send the credential through the token={serviceToken} query parameter. Regenerating the service token revokes the previously active token, so treat rotation as a coordinated deployment change rather than an incidental dashboard action.
Confirm the exact API call
The integration uses GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. It sends both token and website as query parameters. Before writing application code, make one minimal request from a trusted terminal:
read -sr -p "Service token: " SERVICE_TOKEN
curl --fail-with-body --silent --show-error --get \
"https://ai.mihajlo.mk/api/website-to-company-data/v1/extract" \
--data-urlencode "token=${SERVICE_TOKEN}" \
--data-urlencode "website=https://example.com"
unset SERVICE_TOKEN
--data-urlencode prevents query-string corruption. Avoid typing the token directly into the command because shell history, process inspection, and copied terminal output can expose it.
Now create .env.local outside the public directory:
SERVICE_TOKEN="YOUR_SERVICE_TOKEN"
DATABASE_PATH="/var/lib/quote-enricher/quotes.sqlite"
Add .env.local, the SQLite database, and its sidecar files to .gitignore. In deployment, prefer real process environment variables supplied by the service manager or secret store. The local file is a development convenience, not something to commit or place beneath the web root.
Architecture and project structure
The request path and enrichment path have different responsibilities:
- The HTTP endpoint validates the quote, inserts it, and creates a job in the same database transaction.
- The worker claims one ready job, calls the external service, maps its response into domain data, and updates the quote.
- Temporary failures return to the queue with bounded exponential backoff. Authentication, request, and schema failures are marked terminal.
This transactional outbox-style design prevents a stored quote from losing its corresponding job. SQLite is appropriate for a single small deployment when the database lives on durable local storage and there are only a few workers. Multiple application nodes should use a shared transactional database and the same conditional-claim pattern.
quote-enricher/
bin/migrate.php
bin/worker.php
public/index.php
src/CompanyData.php
src/Http.php
src/WebsiteCompanyClient.php
tests/WebsiteCompanyClientTest.php
bootstrap.php
composer.json
phpunit.xml
.env.local
The runtime needs PHP 8.3 with curl, pdo_sqlite, and json. Composer is used only for autoloading and PHPUnit:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
composer install
composer dump-autoload --optimize
php bin/migrate.php
Create the database and configuration bootstrap
The bootstrap loads the local file first and then lets actual environment variables override it. It also configures SQLite for concurrent reads and bounded lock waiting.
<?php
// bootstrap.php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
$local = [];
$file = __DIR__ . '/.env.local';
if (is_file($file)) {
$local = parse_ini_file($file, false, INI_SCANNER_RAW);
if ($local === false) {
throw new RuntimeException('Cannot parse .env.local');
}
}
$config = [
'token' => getenv('SERVICE_TOKEN') ?: ($local['SERVICE_TOKEN'] ?? ''),
'database' => getenv('DATABASE_PATH')
?: ($local['DATABASE_PATH'] ?? __DIR__ . '/var/quotes.sqlite'),
];
if ($config['token'] === '') {
throw new RuntimeException('SERVICE_TOKEN is required');
}
$pdo = new PDO('sqlite:' . $config['database'], options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA busy_timeout=5000');
return [$config, $pdo];
<?php
// bin/migrate.php
declare(strict_types=1);
[, $pdo] = require dirname(__DIR__) . '/bootstrap.php';
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS quote_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL,
website TEXT NOT NULL,
brief TEXT NOT NULL,
enrichment_status TEXT NOT NULL DEFAULT 'queued',
company_data TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS enrichment_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 TEXT NOT NULL,
last_error TEXT,
FOREIGN KEY (quote_id) REFERENCES quote_requests(id)
);
CREATE INDEX IF NOT EXISTS enrichment_ready
ON enrichment_jobs(status, available_at);
SQL);
echo "Database ready\n";
Isolate HTTP and map the external response
The transport owns connection mechanics; the client owns API policy. Keeping those concerns separate makes deterministic tests possible without network calls.
<?php
// src/Http.php
namespace App;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
public ?string $networkError = null,
) {}
}
interface Transport
{
public function get(string $url, array $query): HttpResponse;
}
final class CurlTransport implements Transport
{
public function get(string $url, array $query): HttpResponse
{
$requestUrl = $url . '?' . http_build_query(
$query, '', '&', PHP_QUERY_RFC3986
);
$handle = curl_init($requestUrl);
if ($handle === false) {
return new HttpResponse(0, '', 'initialization_failed');
}
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT_MS => 1500,
CURLOPT_TIMEOUT_MS => 5000,
CURLOPT_USERAGENT => 'quote-enricher/1.0',
]);
$body = curl_exec($handle);
$error = $body === false ? curl_error($handle) : null;
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $body === false ? '' : $body, $error);
}
}
The supplied contract names company, contact, email, phone, and people, but it does not justify assuming that every value will always be populated or scalar. The boundary below normalizes absent values and rejects incompatible shapes instead of allowing arbitrary response data to spread through the application.
<?php
// src/CompanyData.php
namespace App;
use JsonSerializable;
use UnexpectedValueException;
final readonly class CompanyData implements JsonSerializable
{
public function __construct(
public array $company,
public array $contact,
public array $email,
public array $phone,
public array $people,
) {}
public static function fromPayload(array $payload): self
{
return new self(
self::object($payload['company'] ?? null, 'company'),
self::object($payload['contact'] ?? null, 'contact'),
self::strings($payload['email'] ?? null, 'email'),
self::strings($payload['phone'] ?? null, 'phone'),
self::people($payload['people'] ?? null),
);
}
private static function object(mixed $value, string $field): array
{
if ($value === null) return [];
if (is_string($value)) return ['value' => $value];
if (is_array($value)) return $value;
throw new UnexpectedValueException("Invalid {$field} data");
}
private static function strings(mixed $value, string $field): array
{
if ($value === null || $value === '') return [];
if (is_string($value)) return [$value];
if (is_array($value)) {
foreach ($value as $item) {
if (!is_string($item)) {
throw new UnexpectedValueException("Invalid {$field} data");
}
}
return array_values($value);
}
throw new UnexpectedValueException("Invalid {$field} data");
}
private static function people(mixed $value): array
{
if ($value === null) return [];
if (!is_array($value)) {
throw new UnexpectedValueException('Invalid people data');
}
return array_values(array_map(
static function (mixed $person): array {
if (is_string($person)) return ['name' => $person];
if (is_array($person)) return $person;
throw new UnexpectedValueException('Invalid person data');
},
$value
));
}
public function jsonSerialize(): array
{
return get_object_vars($this);
}
}
Apply bounded retries and structured failures
Network failures, 429 responses, and server errors may be transient. Authentication and other client errors generally are not. The client performs at most three short attempts; the durable job queue handles longer delays.
<?php
// src/WebsiteCompanyClient.php
namespace App;
use Closure;
use JsonException;
use RuntimeException;
use Throwable;
final class EnrichmentFailure extends RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly bool $retryable,
) {
parent::__construct($kind);
}
}
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 Transport $transport,
private readonly string $token,
?callable $sleep = null,
) {
$this->sleep = $sleep === null
? static fn(int $milliseconds) => usleep($milliseconds * 1000)
: Closure::fromCallable($sleep);
}
public function extract(string $website): CompanyData
{
foreach ([200, 600, 0] as $attempt => $delay) {
$response = $this->transport->get(self::ENDPOINT, [
'token' => $this->token,
'website' => $website,
]);
$temporary = $response->networkError !== null
|| $response->status === 429
|| $response->status >= 500;
if ($temporary && $attempt < 2) {
($this->sleep)($delay);
continue;
}
if ($temporary) {
throw new EnrichmentFailure('temporary_api_failure', true);
}
if (in_array($response->status, [401, 403], true)) {
throw new EnrichmentFailure('authentication_failure', false);
}
if ($response->status < 200 || $response->status >= 300) {
throw new EnrichmentFailure('request_rejected', false);
}
try {
$payload = json_decode(
$response->body, true, flags: JSON_THROW_ON_ERROR
);
if (!is_array($payload)) {
throw new JsonException('Expected an object');
}
return CompanyData::fromPayload($payload);
} catch (Throwable) {
throw new EnrichmentFailure('invalid_response_schema', false);
}
}
throw new EnrichmentFailure('retry_exhausted', true);
}
}
Accept the quote without waiting for enrichment
The public endpoint validates size and shape before opening a transaction. It stores no remote data during the request, so an API outage cannot prevent the quote from being accepted.
<?php
// public/index.php
declare(strict_types=1);
[, $pdo] = require dirname(__DIR__) . '/bootstrap.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
echo json_encode(['error' => 'method_not_allowed']);
exit;
}
$name = trim((string) ($_POST['name'] ?? ''));
$email = trim((string) ($_POST['email'] ?? ''));
$website = trim((string) ($_POST['website'] ?? ''));
$brief = trim((string) ($_POST['brief'] ?? ''));
$scheme = strtolower((string) parse_url($website, PHP_URL_SCHEME));
$valid = $name !== ''
&& strlen($name) <= 120
&& filter_var($email, FILTER_VALIDATE_EMAIL)
&& strlen($email) <= 254
&& filter_var($website, FILTER_VALIDATE_URL)
&& in_array($scheme, ['http', 'https'], true)
&& parse_url($website, PHP_URL_HOST) !== null
&& $brief !== ''
&& strlen($brief) <= 5000;
if (!$valid) {
http_response_code(422);
echo json_encode(['error' => 'invalid_quote']);
exit;
}
$now = gmdate('Y-m-d\TH:i:s\Z');
try {
$pdo->beginTransaction();
$statement = $pdo->prepare(
'INSERT INTO quote_requests
(name, email, website, brief, created_at)
VALUES (?, ?, ?, ?, ?)'
);
$statement->execute([$name, $email, $website, $brief, $now]);
$quoteId = (int) $pdo->lastInsertId();
$job = $pdo->prepare(
'INSERT INTO enrichment_jobs (quote_id, available_at)
VALUES (?, ?)'
);
$job->execute([$quoteId, $now]);
$pdo->commit();
http_response_code(202);
echo json_encode(['quote_id' => $quoteId, 'status' => 'accepted']);
} catch (Throwable $exception) {
if ($pdo->inTransaction()) $pdo->rollBack();
error_log(json_encode(['event' => 'quote_store_failed']));
http_response_code(503);
echo json_encode(['error' => 'temporarily_unavailable']);
}
A browser-facing deployment should also apply CSRF protection, rate limiting, a deliberate CORS policy, and bot controls appropriate to the form. If duplicate submissions matter, accept an idempotency key and enforce it with a unique database constraint.
Run the enrichment worker
The worker claims a job with a conditional update, so two processes cannot intentionally execute the same queued row. Success stores the mapped JSON. Temporary failure schedules another attempt; terminal failure preserves a structured status for support and reporting.
<?php
// bin/worker.php
declare(strict_types=1);
use App\CurlTransport;
use App\EnrichmentFailure;
use App\WebsiteCompanyClient;
[$config, $pdo] = require dirname(__DIR__) . '/bootstrap.php';
$now = gmdate('Y-m-d\TH:i:s\Z');
$pdo->beginTransaction();
$select = $pdo->prepare(
"SELECT j.*, q.website
FROM enrichment_jobs j
JOIN quote_requests q ON q.id = j.quote_id
WHERE j.status = 'queued' AND j.available_at <= ?
ORDER BY j.id LIMIT 1"
);
$select->execute([$now]);
$job = $select->fetch();
if (!$job) {
$pdo->commit();
exit(0);
}
$claim = $pdo->prepare(
"UPDATE enrichment_jobs SET status = 'running'
WHERE id = ? AND status = 'queued'"
);
$claim->execute([$job['id']]);
if ($claim->rowCount() !== 1) {
$pdo->rollBack();
exit(0);
}
$pdo->commit();
$client = new WebsiteCompanyClient(
new CurlTransport(),
$config['token']
);
try {
$data = $client->extract($job['website']);
$pdo->beginTransaction();
$update = $pdo->prepare(
"UPDATE quote_requests
SET company_data = ?, enrichment_status = 'complete'
WHERE id = ?"
);
$update->execute([
json_encode($data, JSON_THROW_ON_ERROR),
$job['quote_id'],
]);
$pdo->prepare('DELETE FROM enrichment_jobs WHERE id = ?')
->execute([$job['id']]);
$pdo->commit();
error_log(json_encode([
'event' => 'enrichment_complete',
'quote_id' => (int) $job['quote_id'],
]));
} catch (EnrichmentFailure $failure) {
$attempts = (int) $job['attempts'] + 1;
$retry = $failure->retryable && $attempts < 5;
$delay = min(3600, 60 * (2 ** $attempts));
$available = gmdate('Y-m-d\TH:i:s\Z', time() + $delay);
$pdo->prepare(
'UPDATE enrichment_jobs
SET status = ?, attempts = ?, available_at = ?, last_error = ?
WHERE id = ?'
)->execute([
$retry ? 'queued' : 'failed',
$attempts,
$available,
$failure->kind,
$job['id'],
]);
if (!$retry) {
$pdo->prepare(
"UPDATE quote_requests SET enrichment_status = 'failed'
WHERE id = ?"
)->execute([$job['quote_id']]);
}
error_log(json_encode([
'event' => 'enrichment_failed',
'quote_id' => (int) $job['quote_id'],
'kind' => $failure->kind,
'retrying' => $retry,
]));
exit($retry ? 75 : 1);
}
Logs intentionally omit the token, website, email, response body, and extracted people. Record identifiers and structured failure kinds instead. Because authentication uses a query parameter, never log the constructed request URL.
Run the worker repeatedly through a systemd timer, cron, or a supervised loop. A production supervisor should start multiple processes only after validating SQLite contention under the expected workload. It should also recover jobs left in running after a crashed process, using a claim timestamp and a conservative stale-job threshold.
Test the boundary without calling the service
A deterministic fake transport verifies mapping and retry policy. No test fixture contains a real credential.
<?php
// tests/WebsiteCompanyClientTest.php
declare(strict_types=1);
use App\EnrichmentFailure;
use App\HttpResponse;
use App\Transport;
use App\WebsiteCompanyClient;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function get(string $url, array $query): HttpResponse
{
$this->calls++;
return array_shift($this->responses);
}
}
final class WebsiteCompanyClientTest extends TestCase
{
public function testMapsAValidResponse(): void
{
$fake = new FakeTransport([new HttpResponse(200, json_encode([
'company' => ['name' => 'Example Ltd'],
'contact' => ['city' => 'Skopje'],
'email' => '[email protected]',
'phone' => ['+389000000'],
'people' => [['name' => 'Alex', 'role' => 'Owner']],
]))]);
$data = (new WebsiteCompanyClient(
$fake, 'test-token', static fn(int $ms) => null
))->extract('https://example.com');
self::assertSame(['[email protected]'], $data->email);
self::assertSame('Example Ltd', $data->company['name']);
self::assertSame(1, $fake->calls);
}
public function testRetriesRateLimitThenSucceeds(): void
{
$fake = new FakeTransport([
new HttpResponse(429, '{}'),
new HttpResponse(200, json_encode([
'company' => [], 'contact' => [],
'email' => [], 'phone' => [], 'people' => [],
])),
]);
(new WebsiteCompanyClient(
$fake, 'test-token', static fn(int $ms) => null
))->extract('https://example.com');
self::assertSame(2, $fake->calls);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$fake = new FakeTransport([new HttpResponse(401, '{}')]);
try {
(new WebsiteCompanyClient(
$fake, 'bad-token', static fn(int $ms) => null
))->extract('https://example.com');
self::fail('Expected EnrichmentFailure');
} catch (EnrichmentFailure $failure) {
self::assertSame('authentication_failure', $failure->kind);
self::assertFalse($failure->retryable);
self::assertSame(1, $fake->calls);
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="unit">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
Run the suite with vendor/bin/phpunit. Add endpoint tests against a temporary SQLite file before extending validation or idempotency behavior.
Deploy, observe, and diagnose
Serve only public/ through PHP-FPM or the development server. Ensure the web process and worker can access the database directory, while other system users cannot read it. Keep the database on persistent storage, back it up consistently, and do not place a live SQLite database on an unsuitable network filesystem.
Monitor counts of queued, running, failed, and unusually old jobs. Alert separately on authentication_failure, because it commonly indicates a missing environment variable or a regenerated token whose replacement has not reached every worker. Sustained temporary_api_failure may represent connectivity trouble, service errors, quota pressure, or rate limiting; the queue should absorb it without affecting quote acceptance.
Common failure patterns are concrete:
- Immediate 401 or 403: verify the service-scoped token and restart workers after updating the environment. Do not retry blindly.
- Repeated 429 responses: reduce worker concurrency, retain durable backoff, and verify plan capacity.
- Invalid response schema: retain the quote, mark enrichment failed, and compare the boundary mapper with the official documentation before changing it.
- Database locked: shorten transactions, ensure network calls never occur inside them, and reduce concurrent SQLite writers.
- Jobs remain running: add the claim timestamp and stale-job recovery policy before relying on automatic crash recovery.
Final verification checklist
- The registration, plan activation, documentation, and service-token flow has been completed.
- The token exists only in environment-backed configuration and is absent from source, fixtures, logs, and command history.
- A valid form submission returns
202before any enrichment request begins. - The quote and job are created atomically.
- The worker calls the exact GET endpoint with
tokenandwebsitequery parameters. - Company, contact, email, phone, and people data are validated and mapped at the API boundary.
- Timeouts are bounded, temporary failures back off, and authentication or validation failures are not blindly retried.
- PHPUnit passes with the deterministic fake transport.
- Production monitoring can distinguish queue delay, rate limiting, authentication failure, and schema failure.
The important feature is not merely that a website becomes company data. It is that the quote remains dependable when enrichment is slow, unavailable, rate-limited, or malformed. Fast acceptance, durable work, a strict application boundary, and restrained logging turn a useful API call into a production integration that can be trusted.