Tutorials

Native PHP 8.3: Automate Brand Asset Import for Proposals with Brand Kit API

Native PHP 8.3: Automate Brand Asset Import for Proposals with Brand Kit API

A proposal generator feels polished until someone must hunt through a client’s website for the correct logo, brand colors, fonts, and social links. That manual step is slow, inconsistent, and surprisingly easy to get wrong.

This tutorial builds a production-oriented Native PHP 8.3 integration that accepts a public website URL, extracts its evidence-based visual identity, validates the response, and stores an immutable brand snapshot for proposals and recurring reports. The design keeps the external API behind a small boundary, uses bounded retries, and remains testable without making network calls.

Get access and copy the service token

Start by creating an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.

  1. Open the Brand Kit Extractor service page.
  2. Choose the available Free, Plus, or Pro plan and complete its activation.
  3. Open the official service documentation.
  4. Find the Service token panel and copy the service-scoped token.
  5. Store it in environment-backed configuration, never in PHP source code.

Regenerating the service token revokes the previously active token. Treat regeneration as a credential rotation: update every deployed environment promptly, verify the new token, and remove any obsolete secret references.

The service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. This project uses a Bearer token because headers are less likely than URLs to leak through browser history, proxy logs, and monitoring systems.

Confirm the endpoint before building the feature

The exact request is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON body contains url. Make one minimal request before introducing application code:

export BRAND_KIT_TOKEN='YOUR_SERVICE_TOKEN'

curl --fail-with-body \
  --connect-timeout 3 \
  --max-time 20 \
  -X POST \
  'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit' \
  -H "Authorization: Bearer ${BRAND_KIT_TOKEN}" \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  --data '{"url":"https://example.com"}'

A successful response should contain the brand name, logos, colors, fonts, imagery, social profiles, and CSS variables. Do not send a real customer proposal through the pipeline yet. First inspect the response against the official documentation and confirm that your plan and token are active.

For local development, place the credential in a non-committed .env file:

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_KIT_CONNECT_TIMEOUT_MS=3000
BRAND_KIT_RESPONSE_TIMEOUT_MS=20000
BRAND_KIT_MAX_ATTEMPTS=3
BRAND_SNAPSHOT_DIR=var/brands

Add .env and var/brands/ to .gitignore. In production, inject the same variables through the deployment platform’s secret manager rather than shipping an environment file.

Choose a deliberately small architecture

The proposal generator needs a stable snapshot, not a live dependency on the customer’s website every time a PDF is rendered. Extraction therefore happens during brand onboarding or an explicit refresh.

  • CurlTransport owns HTTP, timeouts, response-size limits, and response headers.
  • BrandKitClient owns authentication, retry policy, status classification, and JSON decoding.
  • BrandKitMapper validates the external representation and creates a domain object.
  • BrandRepository writes an atomic JSON snapshot for the proposal generator.
  • import-brand.php provides an operational command suitable for local use or a scheduled workflow.

A practical project layout is:

proposal-generator/
├── bin/import-brand.php
├── src/BrandKit.php
├── src/BrandKitClient.php
├── src/BrandKitMapper.php
├── src/BrandRepository.php
├── src/CurlTransport.php
├── src/HttpResponse.php
├── src/Transport.php
├── tests/BrandKitClientTest.php
├── var/brands/
├── .env
├── .gitignore
├── composer.json
└── phpunit.xml

Configure PSR-4 autoloading and install PHPUnit 11, which supports PHP 8.3:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}
composer install
composer dump-autoload
set -a
. ./.env
set +a

Build a bounded native cURL transport

The transport must not wait indefinitely or accept an arbitrarily large response. The two-megabyte cap below is an application safeguard; adjust it only after observing legitimate payload sizes.

<?php
// src/Transport.php, src/HttpResponse.php, src/CurlTransport.php
namespace App;

interface Transport
{
    public function postJson(string $url, string $token, array $payload): HttpResponse;
}

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

final class CurlTransport implements Transport
{
    public function __construct(
        private int $connectTimeoutMs,
        private int $responseTimeoutMs,
        private int $maxBytes = 2_097_152,
    ) {}

    public function postJson(string $url, string $token, array $payload): HttpResponse
    {
        $body = '';
        $headers = [];
        $started = hrtime(true);
        $curl = curl_init($url);

        curl_setopt_array($curl, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => false,
            CURLOPT_CONNECTTIMEOUT_MS => $this->connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $this->responseTimeoutMs,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . $token,
                'Accept: application/json',
                'Content-Type: application/json',
            ],
            CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
            CURLOPT_HEADERFUNCTION => static function ($handle, string $line) use (&$headers): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
            CURLOPT_WRITEFUNCTION => function ($handle, string $chunk) use (&$body): int {
                if (strlen($body) + strlen($chunk) > $this->maxBytes) {
                    return 0;
                }
                $body .= $chunk;
                return strlen($chunk);
            },
        ]);

        if (curl_exec($curl) === false) {
            throw new \RuntimeException('Brand service transport failed: ' . curl_error($curl));
        }

        return new HttpResponse(
            curl_getinfo($curl, CURLINFO_RESPONSE_CODE),
            $body,
            $headers,
            (int) ((hrtime(true) - $started) / 1_000_000),
        );
    }
}

Do not include the token, complete response body, or customer URL query string in exceptions and logs. A hostname, local correlation identifier, HTTP status, duration, and attempt number are normally sufficient.

Map external JSON into a trusted domain object

The API boundary is where untrusted JSON becomes application data. The mapper below requires all seven supplied capabilities, rejects oversized or deeply nested structures, and applies stricter checks to CSS variables. The application stores nothing until this mapping succeeds.

<?php
// src/BrandKit.php and src/BrandKitMapper.php
namespace App;

final readonly class BrandKit
{
    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 toArray(): 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 BrandKitMapper
{
    public function map(array $data): BrandKit
    {
        $name = $data['brand_name'] ?? null;
        if (!is_string($name) || trim($name) === '' || strlen($name) > 200) {
            throw new \UnexpectedValueException('Invalid brand_name');
        }

        foreach (['logos', 'colors', 'fonts', 'imagery', 'social_profiles'] as $field) {
            if (!isset($data[$field]) || !is_array($data[$field])) {
                throw new \UnexpectedValueException("Invalid {$field}");
            }
            $this->assertTree($data[$field], $field);
        }

        $css = $data['css_variables'] ?? null;
        if (!is_array($css)) {
            throw new \UnexpectedValueException('Invalid css_variables');
        }

        foreach ($css as $property => $value) {
            if (!is_string($property)
                || preg_match('/^--[A-Za-z0-9_-]{1,80}$/', $property) !== 1
                || !is_string($value)
                || strlen($value) > 512
                || preg_match('/[{};<>]/', $value) === 1
                || stripos($value, 'url(') !== false) {
                throw new \UnexpectedValueException('Unsafe CSS variable');
            }
        }

        return new BrandKit(
            trim($name),
            $data['logos'],
            $data['colors'],
            $data['fonts'],
            $data['imagery'],
            $data['social_profiles'],
            $css,
        );
    }

    private function assertTree(mixed $value, string $path, int $depth = 0): void
    {
        if ($depth > 8 || (is_array($value) && count($value) > 500)) {
            throw new \UnexpectedValueException("Oversized {$path}");
        }

        if (is_string($value) && strlen($value) > 4096) {
            throw new \UnexpectedValueException("Oversized string in {$path}");
        }

        if (is_array($value)) {
            foreach ($value as $child) {
                $this->assertTree($child, $path, $depth + 1);
            }
        } elseif (!is_null($value) && !is_scalar($value)) {
            throw new \UnexpectedValueException("Unsupported value in {$path}");
        }
    }
}

These checks establish structural trust, not trademark ownership or permission to use the assets. If proposals can be created by arbitrary users, add an approval step and restrict imports to domains they are authorized to represent.

Handle retries without multiplying failures

Network failures, HTTP 429, and temporary gateway failures can merit another attempt. Authentication and validation failures do not. Three total attempts with exponential backoff keep the operation bounded.

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

final class ApiFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status,
        string $message,
    ) {
        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 Transport $transport,
        private BrandKitMapper $mapper,
        private string $token,
        private int $maxAttempts,
        private \Closure $sleep,
        private \Closure $log,
    ) {
        if ($token === '') {
            throw new \InvalidArgumentException('BRAND_KIT_TOKEN is missing');
        }
    }

    public function extract(string $website): BrandKit
    {
        $this->assertPublicHttpsUrl($website);

        for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
            try {
                $response = $this->transport->postJson(
                    self::ENDPOINT,
                    $this->token,
                    ['url' => $website],
                );
            } catch (\RuntimeException $error) {
                ($this->log)(['event' => 'brand.transport_failure', 'attempt' => $attempt]);
                if ($attempt === $this->maxAttempts) {
                    throw new ApiFailure('transport', null, 'Brand extraction is unavailable');
                }
                ($this->sleep)(200 * (2 ** ($attempt - 1)));
                continue;
            }

            ($this->log)([
                'event' => 'brand.response',
                'status' => $response->status,
                'duration_ms' => $response->durationMs,
                'attempt' => $attempt,
            ]);

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    return $this->mapper->map(
                        json_decode($response->body, true, 32, JSON_THROW_ON_ERROR)
                    );
                } catch (\JsonException|\UnexpectedValueException $error) {
                    throw new ApiFailure('invalid_response', $response->status, $error->getMessage());
                }
            }

            if (in_array($response->status, [401, 403], true)) {
                throw new ApiFailure('authentication', $response->status, 'Check the service token');
            }

            $retryable = $response->status === 429
                || in_array($response->status, [502, 503, 504], true);

            if (!$retryable || $attempt === $this->maxAttempts) {
                $kind = $response->status === 429 ? 'rate_limit' : 'service';
                throw new ApiFailure($kind, $response->status, 'Brand extraction failed');
            }

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

            ($this->sleep)($retryAfter);
        }

        throw new ApiFailure('service', null, 'Brand extraction failed');
    }

    private function assertPublicHttpsUrl(string $url): void
    {
        $host = parse_url($url, PHP_URL_HOST);
        if (filter_var($url, FILTER_VALIDATE_URL) === false
            || parse_url($url, PHP_URL_SCHEME) !== 'https'
            || !is_string($host)
            || strtolower($host) === 'localhost'
            || filter_var($host, FILTER_VALIDATE_IP) !== false) {
            throw new \InvalidArgumentException('A public HTTPS website URL is required');
        }
    }
}

Honor numeric Retry-After values, but cap the delay so a command cannot hang indefinitely. Quota availability depends on the activated plan; expose rate-limit failures to operators instead of disguising them as malformed brand data.

Persist an atomic snapshot for proposal rendering

Store the source URL, import time, and validated kit together. The proposal generator can reference the resulting snapshot ID, ensuring that an old proposal does not silently change when a website is redesigned.

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

final class BrandRepository
{
    public function __construct(private string $directory) {}

    public function save(string $sourceUrl, BrandKit $kit): string
    {
        if (!is_dir($this->directory)
            && !mkdir($this->directory, 0750, true)
            && !is_dir($this->directory)) {
            throw new \RuntimeException('Cannot create brand snapshot directory');
        }

        $id = hash('sha256', $sourceUrl . "\0" . microtime(true));
        $target = $this->directory . '/' . $id . '.json';
        $temporary = tempnam($this->directory, 'brand-');

        if ($temporary === false) {
            throw new \RuntimeException('Cannot create temporary snapshot');
        }

        $document = [
            'source_url' => $sourceUrl,
            'imported_at' => gmdate(DATE_ATOM),
            'brand' => $kit->toArray(),
        ];

        try {
            $bytes = file_put_contents(
                $temporary,
                json_encode($document, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
                LOCK_EX,
            );
            if ($bytes === false || !chmod($temporary, 0640) || !rename($temporary, $target)) {
                throw new \RuntimeException('Cannot commit brand snapshot');
            }
        } finally {
            if (is_file($temporary)) {
                unlink($temporary);
            }
        }

        return $id;
    }
}

The command composes the components and prints only the snapshot identifier:

<?php
// bin/import-brand.php
require dirname(__DIR__) . '/vendor/autoload.php';

use App\{BrandKitClient, BrandKitMapper, BrandRepository, CurlTransport};

$url = $argv[1] ?? '';
$logger = static fn(array $event) =>
    error_log(json_encode($event, JSON_THROW_ON_ERROR));

$client = new BrandKitClient(
    new CurlTransport(
        (int) (getenv('BRAND_KIT_CONNECT_TIMEOUT_MS') ?: 3000),
        (int) (getenv('BRAND_KIT_RESPONSE_TIMEOUT_MS') ?: 20000),
    ),
    new BrandKitMapper(),
    (string) getenv('BRAND_KIT_TOKEN'),
    (int) (getenv('BRAND_KIT_MAX_ATTEMPTS') ?: 3),
    static fn(int $milliseconds) => usleep($milliseconds * 1000),
    $logger,
);

$repository = new BrandRepository(
    (string) (getenv('BRAND_SNAPSHOT_DIR') ?: dirname(__DIR__) . '/var/brands')
);

try {
    $id = $repository->save($url, $client->extract($url));
    fwrite(STDOUT, $id . PHP_EOL);
    exit(0);
} catch (\Throwable $error) {
    $logger(['event' => 'brand.import_failed', 'type' => $error::class]);
    fwrite(STDERR, "Brand import failed\n");
    exit(1);
}
php bin/import-brand.php 'https://example.com'

The report renderer should load that snapshot by ID and map approved logo, color, font, imagery, and social-profile entries into its existing template model. Escape textual values, proxy or explicitly allow remote image hosts, and never paste returned CSS directly into a document. Even structurally valid external data remains untrusted rendering input.

Test retries and validation without the network

A deterministic fake transport makes failure paths fast and repeatable:

<?php
// tests/BrandKitClientTest.php
use App\{ApiFailure, BrandKitClient, BrandKitMapper, HttpResponse, Transport};
use PHPUnit\Framework\TestCase;

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

    public function __construct(private array $responses) {}

    public function postJson(string $url, string $token, array $payload): HttpResponse
    {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class BrandKitClientTest extends TestCase
{
    public function testRetriesRateLimitThenMapsBrand(): void
    {
        $valid = json_encode([
            'brand_name' => 'Example',
            'logos' => [], 'colors' => ['#112233'],
            'fonts' => ['Inter'], 'imagery' => [],
            'social_profiles' => [],
            'css_variables' => ['--brand-primary' => '#112233'],
        ], JSON_THROW_ON_ERROR);

        $fake = new FakeTransport([
            new HttpResponse(429, '{}', ['retry-after' => '1'], 5),
            new HttpResponse(200, $valid, [], 8),
        ]);
        $delays = [];

        $client = new BrandKitClient(
            $fake,
            new BrandKitMapper(),
            'test-token',
            3,
            static function (int $ms) use (&$delays): void { $delays[] = $ms; },
            static fn(array $event) => null,
        );

        self::assertSame('Example', $client->extract('https://example.com')->brandName);
        self::assertSame(2, $fake->calls);
        self::assertSame([1000], $delays);
    }

    public function testAuthenticationFailureIsNotRetried(): void
    {
        $fake = new FakeTransport([new HttpResponse(401, '{}', [], 4)]);
        $client = new BrandKitClient(
            $fake, new BrandKitMapper(), 'bad-token', 3,
            static fn(int $ms) => null,
            static fn(array $event) => null,
        );

        try {
            $client->extract('https://example.com');
            self::fail('Expected ApiFailure');
        } catch (ApiFailure $failure) {
            self::assertSame('authentication', $failure->kind);
            self::assertSame(1, $fake->calls);
        }
    }
}
vendor/bin/phpunit --testdox

Deploy with operational guardrails

Production readiness is mostly disciplined behavior around the happy path. Inject the token from a secret manager, verify that the cURL and JSON extensions are enabled, make var/brands writable only by the application identity, and persist snapshots on durable storage if deployments use ephemeral filesystems.

Emit structured events for latency, status, attempt count, validation rejection, and final outcome. Alert on sustained authentication failures, because they often indicate an incomplete token rotation. Track rate-limit failures separately from service failures so plan exhaustion is visible.

Common failures

  • 401 or 403: confirm activation and the service-scoped token; a regeneration may have revoked the deployed value.
  • 429: honor the bounded retry delay, then defer the import or review plan availability.
  • Invalid response: retain no partial snapshot; compare the documented response with the mapper before changing validation.
  • Timeouts: retry only within the configured attempt budget and investigate DNS, TLS, or upstream availability.
  • Snapshot write failure: check directory ownership, free space, and whether the runtime filesystem is persistent.

Final verification checklist

  • The account and selected plan are active.
  • The token comes from environment-backed configuration and is absent from source control and logs.
  • The application calls the exact POST endpoint with a JSON url.
  • Brand name, logos, colors, fonts, imagery, social profiles, and CSS variables are validated before storage.
  • Authentication and validation failures are never retried blindly.
  • Rate limits and temporary gateway failures use bounded backoff.
  • Tests pass without external HTTP traffic.
  • A real import creates an atomic snapshot that the proposal or report generator can load by ID.

The valuable result is not merely a successful API call. It is a controlled handoff from a changing public website to a stable, reviewable proposal theme. Once that boundary is explicit, brand imports stop being a collection of copied files and become a dependable part of the document workflow.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.