PHP 8.3: Integrirajte resurse robne marke u prijedloge pomoću API-ja Brand Kit Extractor
A proposal generator becomes genuinely useful when it stops treating branding as a last-minute copy-and-paste exercise. Given a customer’s website, this project imports its visual identity once, validates it at the application boundary, stores an immutable snapshot, and uses a safe subset when rendering proposals and reports.
The implementation uses PHP 8.3, native cURL, environment-backed credentials, bounded retries, atomic storage, and PHPUnit with a deterministic fake transport. The Brand Kit Extractor remains behind a dedicated API boundary, so changes to presentation or persistence do not leak into network code.
Get access before writing integration code
- Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
- Open the Brand Kit Extractor 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.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form because credentials in query strings are more likely to appear in proxy and access logs.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment operation: update the secret, deploy or reload every process that uses it, verify extraction, and only then consider the rotation complete.
Confirm the endpoint with a minimal request
The exact operation is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON request contains url.
export BRAND_KIT_TOKEN='YOUR_SERVICE_TOKEN'
curl --fail-with-body \
--request POST \
--url 'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit' \
--header "Authorization: Bearer ${BRAND_KIT_TOKEN}" \
--header 'Accept: application/json' \
--header 'Content-Type: application/json' \
--data '{"url":"https://example.com"}'
Before building the feature, create an uncommitted .env.local file:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
Add .env.local, generated brand snapshots, and proposal output to .gitignore. Native PHP does not automatically read dotenv files, so the local commands below export it into the process environment. Production should inject the same variable through its secret manager, container runtime, or service manager.
Architecture: import once, render repeatedly
Calling an external extractor while somebody waits for a PDF or proposal preview creates avoidable coupling. Instead, an explicit import command calls the API and saves a versioned JSON snapshot. The report generator reads that local snapshot without network access.
The resulting boundary is small:
CurlTransportowns HTTP mechanics and timeouts.BrandKitClientowns authentication, retries, response decoding, and failure classification.BrandKitmaps and validates the domain response before storage.BrandKitRepositorywrites snapshots atomically.ProposalRendererconsumes only presentation-safe values.
“Verified” here means the snapshot came from the deterministic, evidence-based extraction service and passed our structural and safety checks. It does not establish trademark ownership or grant permission to use another organization’s assets.
Project structure and dependencies
brand-proposals/
├── bin/
│ ├── import-brand-kit
│ └── generate-proposal
├── src/
│ ├── Http.php
│ ├── BrandKit.php
│ └── Proposal.php
├── tests/
│ └── BrandKitClientTest.php
├── var/
│ ├── brand-kits/
│ └── proposals/
├── composer.json
├── .env.local
└── .gitignore
Use PHP 8.3 with the cURL and JSON extensions. PHPUnit 11.5 is the only third-party package and is development-only.
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.5"
},
"autoload": {
"psr-4": {
"ProposalApp\\": "src/"
}
},
"scripts": {
"test": "phpunit tests"
}
}
composer install
set -a
. ./.env.local
set +a
Build a bounded native cURL transport
The transport performs one attempt. Retry policy belongs in the service client, where HTTP status and application meaning are available.
<?php
// src/Http.php
declare(strict_types=1);
namespace ProposalApp;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
interface Transport
{
public function postJson(
string $url,
array $headers,
array $payload,
int $connectTimeoutMs,
int $timeoutMs,
): HttpResponse;
}
final class TransportFailure extends \RuntimeException {}
final class CurlTransport implements Transport
{
public function postJson(
string $url,
array $headers,
array $payload,
int $connectTimeoutMs,
int $timeoutMs,
): HttpResponse {
$handle = curl_init($url);
if ($handle === false) {
throw new TransportFailure('Unable to initialize cURL');
}
$headerLines = [];
foreach ($headers as $name => $value) {
$headerLines[] = $name . ': ' . $value;
}
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
CURLOPT_HTTPHEADER => $headerLines,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
CURLOPT_TIMEOUT_MS => $timeoutMs,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
]);
$raw = curl_exec($handle);
if ($raw === false) {
$message = curl_error($handle);
curl_close($handle);
throw new TransportFailure($message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($handle, CURLINFO_HEADER_SIZE);
curl_close($handle);
$parsedHeaders = [];
foreach (preg_split('/\r\n|\r|\n/', substr($raw, 0, $headerSize)) as $line) {
if (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$parsedHeaders[strtolower(trim($name))] = trim($value);
}
}
return new HttpResponse(
$status,
$parsedHeaders,
substr($raw, $headerSize),
);
}
}
The five-second connection timeout and 30-second overall timeout used below are finite by design. Tune them from observed production latency, but never allow proposal workers to wait indefinitely.
Map the response into a strict domain object
The application must validate the returned brand name, logos, colors, fonts, imagery, social profiles, and CSS variables before storage. The mapper rejects missing fields, malformed collections, excessive nesting, oversized strings, and URI schemes that should never reach a renderer.
<?php
// src/BrandKit.php
declare(strict_types=1);
namespace ProposalApp;
final readonly class BrandKit implements \JsonSerializable
{
public function __construct(
public string $brandName,
public array $logos,
public array $colors,
public array $fonts,
public array $imagery,
public array $socialProfiles,
public array $cssVariables,
) {}
public static function fromApi(array $data): self
{
$keys = [
'brand_name', 'logos', 'colors', 'fonts', 'imagery',
'social_profiles', 'css_variables',
];
foreach ($keys as $key) {
if (!array_key_exists($key, $data)) {
throw new \UnexpectedValueException("Missing response field: {$key}");
}
}
$name = trim(is_string($data['brand_name']) ? $data['brand_name'] : '');
if ($name === '' || strlen($name) > 200) {
throw new \UnexpectedValueException('Invalid brand_name');
}
foreach (array_slice($keys, 1) as $key) {
if (!is_array($data[$key])) {
throw new \UnexpectedValueException("{$key} must be a collection");
}
self::validateTree($data[$key], $key);
}
foreach (['logos', 'imagery', 'social_profiles'] as $key) {
self::rejectUnsafeUris($data[$key], $key);
}
return new self(
$name,
$data['logos'],
$data['colors'],
$data['fonts'],
$data['imagery'],
$data['social_profiles'],
$data['css_variables'],
);
}
private static function validateTree(mixed $value, string $path, int $depth = 0): void
{
if ($depth > 8) {
throw new \UnexpectedValueException("Excessive nesting at {$path}");
}
if (is_string($value) && strlen($value) > 4096) {
throw new \UnexpectedValueException("Oversized string at {$path}");
}
if (is_array($value)) {
if (count($value) > 500) {
throw new \UnexpectedValueException("Oversized collection at {$path}");
}
foreach ($value as $key => $child) {
self::validateTree($child, $path . '.' . $key, $depth + 1);
}
}
}
private static function rejectUnsafeUris(mixed $value, string $path): void
{
if (is_string($value)
&& preg_match('/^[a-z][a-z0-9+.-]*:/i', $value)
&& !preg_match('/^https?:\/\//i', $value)
) {
throw new \UnexpectedValueException("Unsafe URI at {$path}");
}
if (is_array($value)) {
foreach ($value as $key => $child) {
self::rejectUnsafeUris($child, $path . '.' . $key);
}
}
}
public function jsonSerialize(): array
{
return [
'brand_name' => $this->brandName,
'logos' => $this->logos,
'colors' => $this->colors,
'fonts' => $this->fonts,
'imagery' => $this->imagery,
'social_profiles' => $this->socialProfiles,
'css_variables' => $this->cssVariables,
];
}
}
final class ApiFailure extends \RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly ?int $httpStatus = null,
string $message = 'Brand extraction failed',
) {
parent::__construct($message);
}
}
final class BrandKitClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit';
public function __construct(
private readonly Transport $transport,
private readonly string $token,
private readonly mixed $sleeper = null,
) {}
public function extract(string $website): BrandKit
{
$parts = parse_url($website);
if (($parts['scheme'] ?? null) !== 'https' || empty($parts['host'])) {
throw new \InvalidArgumentException('A public HTTPS website URL is required');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->postJson(
self::ENDPOINT,
[
'Authorization' => 'Bearer ' . $this->token,
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
['url' => $website],
5000,
30000,
);
} catch (TransportFailure $exception) {
$this->log('transport_failure', null, $attempt);
if ($attempt === 3) {
throw new ApiFailure('transport_error', null, $exception->getMessage());
}
$this->pause(200 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
try {
$decoded = json_decode($response->body, true, 64, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
throw new \UnexpectedValueException('JSON root must be an object');
}
return BrandKit::fromApi($decoded);
} catch (\JsonException|\UnexpectedValueException $exception) {
throw new ApiFailure('invalid_response', $response->status, $exception->getMessage());
}
}
if (in_array($response->status, [401, 403], true)) {
throw new ApiFailure('authentication_failed', $response->status);
}
if (in_array($response->status, [400, 404, 422], true)) {
throw new ApiFailure('request_rejected', $response->status);
}
$retryable = in_array($response->status, [429, 502, 503, 504], true);
$this->log($response->status === 429 ? 'rate_limited' : 'api_failure',
$response->status, $attempt);
if (!$retryable || $attempt === 3) {
throw new ApiFailure(
$response->status === 429 ? 'rate_limited' : 'upstream_error',
$response->status,
);
}
$retryAfter = $response->headers['retry-after'] ?? null;
$delay = ctype_digit((string) $retryAfter)
? min(2000, (int) $retryAfter * 1000)
: 200 * (2 ** ($attempt - 1));
$this->pause($delay);
}
throw new ApiFailure('upstream_error');
}
private function pause(int $milliseconds): void
{
if (is_callable($this->sleeper)) {
($this->sleeper)($milliseconds);
return;
}
usleep($milliseconds * 1000);
}
private function log(string $event, ?int $status, int $attempt): void
{
error_log(json_encode([
'event' => 'brand_kit.' . $event,
'status' => $status,
'attempt' => $attempt,
], JSON_THROW_ON_ERROR));
}
}
Validation and authentication errors are not retried. Network faults, quota responses, and selected transient gateway failures receive at most three attempts with bounded backoff. The logger records classification, status, and attempt, but never the token, response body, or customer URL.
Persist atomically and render conservatively
Keep the complete validated snapshot for auditing, but do not inject arbitrary extracted CSS into a document. This renderer searches for a plain hexadecimal color and HTTPS logo, escapes user-facing text, and otherwise falls back safely.
<?php
// src/Proposal.php
declare(strict_types=1);
namespace ProposalApp;
final class BrandKitRepository
{
public function __construct(private readonly string $directory) {}
public function save(string $slug, string $sourceUrl, BrandKit $kit): void
{
$path = $this->path($slug);
if (!is_dir($this->directory)
&& !mkdir($this->directory, 0700, true)
&& !is_dir($this->directory)
) {
throw new \RuntimeException('Cannot create brand-kit directory');
}
$document = [
'schema_version' => 1,
'source_url' => $sourceUrl,
'fetched_at' => gmdate(DATE_ATOM),
'brand_kit' => $kit,
];
$temporary = $path . '.' . bin2hex(random_bytes(6)) . '.tmp';
$json = json_encode($document, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
if (file_put_contents($temporary, $json, LOCK_EX) === false) {
throw new \RuntimeException('Cannot write brand-kit snapshot');
}
chmod($temporary, 0600);
if (!rename($temporary, $path)) {
unlink($temporary);
throw new \RuntimeException('Cannot publish brand-kit snapshot');
}
}
public function load(string $slug): BrandKit
{
$document = json_decode(
file_get_contents($this->path($slug)) ?: '',
true,
64,
JSON_THROW_ON_ERROR,
);
return BrandKit::fromApi($document['brand_kit'] ?? []);
}
private function path(string $slug): string
{
if (!preg_match('/^[a-z0-9][a-z0-9-]{0,62}$/', $slug)) {
throw new \InvalidArgumentException('Invalid customer slug');
}
return $this->directory . '/' . $slug . '.json';
}
}
final class ProposalRenderer
{
public function render(BrandKit $kit, string $subject, string $summary): string
{
$color = $this->firstMatch($kit->colors, '/^#[0-9a-f]{6}$/i') ?? '#243447';
$logo = $this->firstMatch($kit->logos, '/^https:\/\//i');
$escape = static fn(string $value): string =>
htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$logoHtml = $logo === null ? '' :
'<img src="' . $escape($logo) . '" alt="" style="max-height:64px">';
return '<!doctype html><html><body>'
. $logoHtml
. '<h1 style="color:' . $escape($color) . '">'
. $escape($kit->brandName) . ': ' . $escape($subject)
. '</h1><p>' . $escape($summary) . '</p>'
. '</body></html>';
}
private function firstMatch(array $values, string $pattern): ?string
{
foreach ($values as $value) {
if (is_string($value) && preg_match($pattern, $value)) {
return $value;
}
if (is_array($value) && ($found = $this->firstMatch($value, $pattern))) {
return $found;
}
}
return null;
}
}
Wire the two everyday commands
<?php
// bin/import-brand-kit
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use ProposalApp\{BrandKitClient, BrandKitRepository, CurlTransport};
[$script, $slug, $website] = array_pad($argv, 3, null);
$token = getenv('BRAND_KIT_TOKEN');
if (!$slug || !$website || !is_string($token) || $token === '') {
fwrite(STDERR, "Usage: BRAND_KIT_TOKEN=... php {$script} SLUG HTTPS_URL\n");
exit(2);
}
try {
$kit = (new BrandKitClient(new CurlTransport(), $token))->extract($website);
(new BrandKitRepository(dirname(__DIR__) . '/var/brand-kits'))
->save($slug, $website, $kit);
fwrite(STDOUT, "Imported {$kit->brandName}\n");
} catch (Throwable $exception) {
fwrite(STDERR, get_class($exception) . ': ' . $exception->getMessage() . "\n");
exit(1);
}
<?php
// bin/generate-proposal
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use ProposalApp\{BrandKitRepository, ProposalRenderer};
[$script, $slug] = array_pad($argv, 2, null);
if (!$slug) {
fwrite(STDERR, "Usage: php {$script} SLUG\n");
exit(2);
}
$root = dirname(__DIR__);
$kit = (new BrandKitRepository($root . '/var/brand-kits'))->load($slug);
$html = (new ProposalRenderer())->render(
$kit,
'Website delivery proposal',
'Scope, milestones, responsibilities, and commercial terms.',
);
if (!is_dir($root . '/var/proposals')) {
mkdir($root . '/var/proposals', 0700, true);
}
file_put_contents($root . '/var/proposals/' . $slug . '.html', $html, LOCK_EX);
Test retries and failure boundaries without the network
The fake transport supplies a fixed response queue. Tests therefore run quickly and never consume quota or depend on the service’s availability.
<?php
// tests/BrandKitClientTest.php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use ProposalApp\{ApiFailure, BrandKitClient, HttpResponse, Transport};
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function postJson(
string $url,
array $headers,
array $payload,
int $connectTimeoutMs,
int $timeoutMs,
): HttpResponse {
$this->calls++;
return array_shift($this->responses);
}
}
final class BrandKitClientTest extends TestCase
{
private function validBody(): string
{
return json_encode([
'brand_name' => 'Example Studio',
'logos' => ['https://example.com/logo.svg'],
'colors' => ['#18324A'],
'fonts' => ['Inter'],
'imagery' => [],
'social_profiles' => [],
'css_variables' => ['--brand-primary' => '#18324A'],
], JSON_THROW_ON_ERROR);
}
public function testRetriesRateLimitThenMapsResponse(): void
{
$transport = new FakeTransport([
new HttpResponse(429, ['retry-after' => '0'], ''),
new HttpResponse(200, [], $this->validBody()),
]);
$kit = (new BrandKitClient($transport, 'test-token', static fn(int $ms) => null))
->extract('https://example.com');
self::assertSame('Example Studio', $kit->brandName);
self::assertSame(2, $transport->calls);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$transport = new FakeTransport([new HttpResponse(401, [], '')]);
try {
(new BrandKitClient($transport, 'invalid', static fn(int $ms) => null))
->extract('https://example.com');
self::fail('Expected ApiFailure');
} catch (ApiFailure $failure) {
self::assertSame('authentication_failed', $failure->kind);
self::assertSame(1, $transport->calls);
}
}
}
composer test
php bin/import-brand-kit example https://example.com
php bin/generate-proposal example
Production hardening and common failures
Run imports as controlled jobs or scheduled commands rather than inside a public rendering request. Permit only authorized customer domains, cap concurrency, and apply your own per-account rate limit. Although this application does not fetch the submitted site itself, restricting input reduces abuse and accidental extraction of unrelated brands.
Use a read-only secret injection mechanism, redact authorization headers at proxies, keep snapshot and proposal directories outside the public document root, and serve generated files through an authenticated download path. If remote images are later converted into PDFs, fetch them through a separate downloader with DNS and IP-range protections; never assume a URL is safe because it appeared in extracted data.
Monitor counts of successful imports, transport failures, authentication failures, rate limits, invalid responses, and total latency. Alert on sustained authentication failures after token rotation and on invalid-response failures, which may indicate a contract change that should be handled deliberately in BrandKit::fromApi().
- 401 or 403: verify plan activation and the service-scoped token. Do not retry blindly.
- 429: respect bounded
Retry-After, reduce concurrency, and inspect plan usage. - 400 or 422: check that
urlis a complete public website URL. - Invalid response: retain the failure classification, but do not store partially validated data.
- Missing logo in output: the renderer intentionally accepts only an HTTPS string it can identify safely; use a local approved fallback when the extracted structure has no suitable candidate.
Final verification checklist
- The account, plan, and service token are active, and no credential is committed or logged.
- The minimal POST request succeeds with the exact extraction endpoint and JSON
url. - All seven brand-data areas pass boundary validation before an atomic snapshot is published.
- Authentication and validation failures receive no retry; transient failures receive no more than three attempts.
- PHPUnit passes without a real network request.
- An import creates
var/brand-kits/example.json, and generation createsvar/proposals/example.html. - The proposal displays an escaped brand name, a safe color, and a safe logo or an intentional fallback.
The valuable result is not merely an API call. It is a dependable boundary between public brand evidence and documents your business can regenerate every day. Once that boundary validates aggressively, stores snapshots atomically, and renders cautiously, branding becomes a repeatable part of proposal production instead of a fragile finishing chore.