Native PHP: Brand Kit Extractor for Effortless Proposals and Reports
A proposal can be technically perfect and still look unfinished when its logo, colors, and typography are wrong. Manually copying those details is tedious, while accepting unvalidated design data from a remote service is risky. The useful middle ground is a small import boundary: extract a public website’s visual identity, validate every required section, and publish an immutable brand snapshot that a proposal or report generator can consume.
This tutorial builds that boundary with Native PHP 8.3, cURL, and PHPUnit. The result is a command that imports evidence-based brand data without coupling the rest of the application to an external API response.
Get access before writing integration code
First, register an account, or use the sign-in page if you already have one.
- 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.
- Store it in your project environment, never in source control.
Regenerating the service token revokes the previously active token, so rotation must update every deployed instance that uses it. This service is not token-free: each request must authenticate with a Bearer token, an X-API-Token header, or a token query parameter. We will use a Bearer token because query-string credentials are more likely to appear in access logs, browser history, and monitoring systems.
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 body contains url, pointing to the public website whose identity should be extracted.
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
curl --request POST \
--url https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit \
--header "Authorization: Bearer ${BRAND_KIT_TOKEN}" \
--header "Content-Type: application/json" \
--data '{"url":"https://www.example.com"}'
This smoke test should produce JSON. Do not persist it yet: successful HTTP transport is not proof that its shape is safe for your application.
Lay out the Native PHP project
The importer separates transport, API policy, domain mapping, and storage. That makes retries testable and prevents external response details from leaking into proposal templates.
brand-proposals/
├── bin/import-brand
├── config/bootstrap.php
├── src/
│ ├── BrandKit.php
│ ├── BrandKitClient.php
│ ├── BrandKitMapper.php
│ ├── CurlTransport.php
│ ├── HttpResponse.php
│ ├── Transport.php
│ └── BrandKitRepository.php
├── storage/brands/
├── tests/BrandKitClientTest.php
├── .env
├── .env.example
├── .gitignore
├── composer.json
└── phpunit.xml
Use cURL for the network boundary and vlucas/phpdotenv only to load local environment configuration. PHPUnit supplies the deterministic test harness.
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
composer install
Put the placeholder in .env.example, copy that file to .env, and replace the placeholder locally. Add /.env and /storage/brands/*.tmp to .gitignore.
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
The bootstrap file loads local configuration and fails early when a deployment is missing its credential.
<?php
// config/bootstrap.php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv\Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
$token = $_ENV['BRAND_KIT_TOKEN'] ?? getenv('BRAND_KIT_TOKEN') ?: null;
if (!is_string($token) || trim($token) === '') {
throw new RuntimeException('BRAND_KIT_TOKEN is not configured.');
}
return ['brand_kit_token' => $token];
Build a bounded, replaceable transport
The transport owns cURL mechanics, not application policy. It applies a three-second connection timeout and a fifteen-second total timeout, captures response headers, and returns the body without logging it.
<?php
// src/Transport.php
namespace App;
interface Transport
{
public function post(string $url, array $headers, string $body): 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;
final class CurlTransport implements Transport
{
public function post(string $url, array $headers, string $body): HttpResponse
{
$responseHeaders = [];
$handle = curl_init($url);
if ($handle === false) {
throw new \RuntimeException('Unable to initialize cURL.');
}
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 15,
CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
use (&$responseHeaders): int {
$length = strlen($line);
if (str_starts_with($line, 'HTTP/')) {
$responseHeaders = [];
} elseif (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($name))] = trim($value);
}
return $length;
},
]);
$bodyResult = curl_exec($handle);
if ($bodyResult === false) {
$message = curl_error($handle);
curl_close($handle);
throw new \RuntimeException('Brand service transport failed: ' . $message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $responseHeaders, $bodyResult);
}
}
Apply deliberate retry policy
Transient transport errors, HTTP 429 responses, and server-side 5xx responses may succeed later. Authentication failures and invalid requests will not improve through repetition, so the client rejects them immediately. Three total attempts bound both latency and quota consumption.
<?php
// src/BrandKitClient.php
namespace App;
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 \Closure $sleep,
) {}
public function extract(string $sourceUrl): array
{
$body = json_encode(
['url' => $sourceUrl],
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->post(self::ENDPOINT, [
'Authorization: Bearer ' . $this->token,
'Content-Type: application/json',
'Accept: application/json',
], $body);
} catch (\RuntimeException $exception) {
if ($attempt === 3) {
throw $exception;
}
($this->sleep)(250 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
try {
$decoded = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException $exception) {
throw new \UnexpectedValueException(
'Brand service returned invalid JSON.',
0,
$exception
);
}
if (!is_array($decoded)) {
throw new \UnexpectedValueException(
'Brand service response must be a JSON object.'
);
}
return $decoded;
}
if (in_array($response->status, [400, 401, 403, 404, 422], true)) {
throw new \DomainException(
'Brand extraction was rejected with HTTP '
. $response->status . '.'
);
}
$retryable = $response->status === 429
|| $response->status >= 500;
if (!$retryable || $attempt === 3) {
throw new \RuntimeException(
'Brand extraction failed with HTTP '
. $response->status . '.'
);
}
$retryAfter = ctype_digit($response->headers['retry-after'] ?? '')
? (int) $response->headers['retry-after'] * 1000
: 250 * (2 ** ($attempt - 1));
($this->sleep)(min($retryAfter, 8000));
}
throw new \LogicException('Unreachable retry state.');
}
}
The sleep dependency is injected as a closure. Production uses usleep($milliseconds * 1000); tests replace it with a no-op. An HTTP-date form of Retry-After falls back to exponential delay rather than relying on uncertain clock synchronization.
Map the response into a domain object
The required boundary accepts the returned brand name, logos, colors, fonts, imagery, social profiles, and CSS variables only when every section has the expected broad JSON type. The nested asset structures remain intact because inventing undocumented logo or font fields would make the integration brittle.
<?php
// src/BrandKit.php
namespace App;
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 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,
];
}
}
// src/BrandKitMapper.php
namespace App;
final class BrandKitMapper
{
public function map(array $payload): BrandKit
{
$name = $payload['brand_name'] ?? null;
if (!is_string($name) || trim($name) === '') {
throw new \UnexpectedValueException(
'Response has no valid brand_name.'
);
}
$sections = [
'logos', 'colors', 'fonts', 'imagery',
'social_profiles', 'css_variables',
];
foreach ($sections as $section) {
if (!isset($payload[$section])
|| !is_array($payload[$section])) {
throw new \UnexpectedValueException(
"Response has no valid {$section} section."
);
}
}
return new BrandKit(
trim($name),
$payload['logos'],
$payload['colors'],
$payload['fonts'],
$payload['imagery'],
$payload['social_profiles'],
$payload['css_variables'],
);
}
}
Here, “verified” means that the imported snapshot passed the application’s schema boundary. It does not assert trademark ownership or independently certify the website operator.
Publish an atomic snapshot
A proposal render should never observe a half-written JSON file. The repository writes beside the destination, sets restrictive permissions, and then atomically renames the completed file.
<?php
// src/BrandKitRepository.php
namespace App;
final class BrandKitRepository
{
public function __construct(private readonly string $directory) {}
public function save(string $key, BrandKit $kit): void
{
if (!preg_match('/\A[a-z0-9][a-z0-9-]{0,63}\z/', $key)) {
throw new \InvalidArgumentException('Invalid brand key.');
}
if (!is_dir($this->directory)
&& !mkdir($this->directory, 0770, true)
&& !is_dir($this->directory)) {
throw new \RuntimeException('Cannot create brand storage.');
}
$temporary = tempnam($this->directory, $key . '-');
if ($temporary === false) {
throw new \RuntimeException('Cannot allocate temporary file.');
}
$json = json_encode(
$kit,
JSON_THROW_ON_ERROR
| JSON_PRETTY_PRINT
| JSON_UNESCAPED_SLASHES
);
try {
if (file_put_contents($temporary, $json, LOCK_EX) === false) {
throw new \RuntimeException('Cannot write brand snapshot.');
}
chmod($temporary, 0660);
if (!rename($temporary, $this->directory . '/' . $key . '.json')) {
throw new \RuntimeException('Cannot publish brand snapshot.');
}
} finally {
if (is_file($temporary)) {
unlink($temporary);
}
}
}
}
The proposal generator can now read storage/brands/acme.json at render time. Keep the stored extraction separate from template-specific interpretation: a PDF adapter may select a primary logo and a conservative color palette, while an HTML report adapter may consume an approved subset of CSS variables.
Wire the import command
The command rejects non-HTTPS URLs and embedded credentials before calling the service. It logs a compact event without the token, response body, or extracted assets.
<?php
#!/usr/bin/env php
// bin/import-brand
declare(strict_types=1);
use App\BrandKitClient;
use App\BrandKitMapper;
use App\BrandKitRepository;
use App\CurlTransport;
$config = require dirname(__DIR__) . '/config/bootstrap.php';
[$script, $key, $url] = $argv + [null, null, null];
$parts = is_string($url) ? parse_url($url) : false;
$validUrl = filter_var($url, FILTER_VALIDATE_URL) !== false
&& is_array($parts)
&& ($parts['scheme'] ?? '') === 'https'
&& !isset($parts['user'], $parts['pass']);
if (!is_string($key) || !$validUrl) {
fwrite(STDERR, "Usage: php bin/import-brand BRAND_KEY HTTPS_URL\n");
exit(64);
}
try {
$client = new BrandKitClient(
new CurlTransport(),
$config['brand_kit_token'],
static fn (int $milliseconds) =>
usleep($milliseconds * 1000),
);
$kit = (new BrandKitMapper())->map($client->extract($url));
(new BrandKitRepository(
dirname(__DIR__) . '/storage/brands'
))->save($key, $kit);
fwrite(STDOUT, json_encode([
'event' => 'brand_kit.imported',
'brand_key' => $key,
'status' => 'ok',
], JSON_THROW_ON_ERROR) . PHP_EOL);
} catch (Throwable $exception) {
fwrite(STDERR, json_encode([
'event' => 'brand_kit.import_failed',
'brand_key' => $key,
'error_type' => $exception::class,
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(1);
}
chmod +x bin/import-brand
php bin/import-brand acme https://www.example.com
Test retries without making network calls
A fake transport makes failure behavior deterministic. This test proves that a rate-limited request retries once and that the mapped result contains every required section.
<?php
// tests/BrandKitClientTest.php
declare(strict_types=1);
use App\BrandKitClient;
use App\BrandKitMapper;
use App\HttpResponse;
use App\Transport;
use PHPUnit\Framework\TestCase;
final class BrandKitClientTest extends TestCase
{
public function testRetriesRateLimitAndMapsResponse(): void
{
$transport = new class implements Transport {
public int $calls = 0;
public function post(
string $url,
array $headers,
string $body
): HttpResponse {
$this->calls++;
if ($this->calls === 1) {
return new HttpResponse(429, ['retry-after' => '0'], '{}');
}
return new HttpResponse(200, [], json_encode([
'brand_name' => 'Example',
'logos' => [],
'colors' => [],
'fonts' => [],
'imagery' => [],
'social_profiles' => [],
'css_variables' => [],
], JSON_THROW_ON_ERROR));
}
};
$client = new BrandKitClient(
$transport,
'test-token',
static fn (int $milliseconds) => null,
);
$kit = (new BrandKitMapper())->map(
$client->extract('https://www.example.com')
);
self::assertSame(2, $transport->calls);
self::assertSame('Example', $kit->brandName);
}
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
colors="true"
failOnWarning="true">
<testsuites>
<testsuite name="Brand importer">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
vendor/bin/phpunit
Security, operations, and deployment
Run imports as an authenticated administrative action or scheduled maintenance command, never as an anonymous public endpoint. Even though the extractor accesses the public website, constrain submitted URLs to HTTPS and consider an allowlist when your business already knows its customers’ domains.
Do not place returned CSS directly into a page. Permit only variables and value formats understood by your renderer. Likewise, fetch remote logos through a controlled asset pipeline that limits size, content type, redirects, and download time before embedding them in HTML or PDFs.
In production, inject BRAND_KIT_TOKEN through the hosting platform’s secret manager instead of deploying .env. Ensure the runtime user can write storage/brands, and keep that directory on shared or durable storage if multiple instances render reports.
Monitor structured counts for successful imports, authentication failures, rate limits, server failures, invalid schemas, and total duration. Alert on sustained failures rather than logging response bodies. A 401 or 403 usually indicates a missing, revoked, or incorrectly deployed token. A 400 or 422 points to a rejected URL or request. Repeated 429 responses require reduced import frequency or a plan review; repeated 5xx responses should preserve the last known-good snapshot.
Final verification checklist
- The account and Free, Plus, or Pro plan are active.
- The service-scoped token is loaded from the environment and absent from Git history.
- The smoke test reaches the exact POST endpoint successfully.
- The command accepts only valid HTTPS source URLs.
- All seven required brand sections pass mapping before storage.
- Authentication and validation failures are never retried blindly.
- Rate limits, transport failures, and server failures receive bounded retries.
- PHPUnit passes without contacting the external service.
- A successful import creates the expected brand JSON snapshot.
- The proposal or report renderer uses only approved assets and CSS values.
The important artifact is not merely a successful API call. It is a dependable handoff between a public website, a validated brand snapshot, and the documents people produce every day. Once that boundary is stable, proposals and reports can stay recognizably on-brand without making every render dependent on the network—or every developer dependent on manual copy-and-paste.