Tutorials

Native PHP 8.3: Brand Kit Extractor for Automated Proposal Design

Native PHP 8.3: Brand Kit Extractor for Automated Proposal Design

A proposal can be technically perfect and still look improvised when its logo, colors, typography, and imagery are assembled by hand. The usual shortcut—copying a logo from a website and guessing its primary color—also creates stale assets, inconsistent templates, and questionable provenance.

This tutorial builds a Native PHP 8.3 integration that extracts a website’s visual identity, validates the result at the application boundary, and stores an immutable brand snapshot for an everyday proposal and report generator. Remote extraction happens during an explicit import command, never while rendering a customer-facing document.

Get access and create a service token

First, register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.

  1. Open the Brand Kit Extractor service page.
  2. Choose the available Free, Plus, or Pro plan and complete 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 or a committed fixture.

This service requires authentication. It accepts 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 and monitoring systems.

Regenerating the service token revokes the previous active token. Treat rotation as a deployment operation: update the secret in every running environment before removing assumptions about the old value.

Confirm the API contract before writing the application

The exact request is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON body contains url. Start with a minimal request against a public site you are authorized to process:

curl --fail-with-body --silent --show-error \
  --request POST \
  'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit' \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{"url":"https://example.com"}'

Inspect the result against the current documentation. The application boundary must validate the returned brand name, logos, colors, fonts, imagery, social profiles, and CSS variables before anything reaches storage or a template.

Create an untracked .env file for local development:

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_DATA_DIR=var/brand-kits

Add .env and var/brand-kits/ to .gitignore. Native PHP does not automatically load dotenv files, so load this local file into the process before running the command:

set -a
. ./.env
set +a
php bin/import-brand.php https://example.com

In production, inject the same variables through the process manager, container secret, or deployment platform. Do not copy the local file onto a server image.

Architecture: import once, render locally

The project deliberately separates four responsibilities:

  • Transport: performs one bounded HTTP request.
  • API client: handles authentication, retries, decoding, and status classification.
  • Domain mapper: rejects incomplete or unsafe brand data.
  • Snapshot store: atomically publishes validated data for proposal rendering.

This keeps network latency and third-party failures outside the document-rendering path. The trade-off is controlled staleness: a brand update is visible only after another import. For proposals and recurring reports, that predictability is normally preferable to changing a document halfway through a generation run.

brand-proposals/
├── bin/import-brand.php
├── src/BrandKit.php
├── src/BrandKitClient.php
├── src/BrandKitStore.php
├── src/Http/CurlTransport.php
├── src/Http/Response.php
├── src/Http/Transport.php
├── tests/BrandKitClientTest.php
├── var/brand-kits/
├── composer.json
└── phpunit.xml

Use Composer only for autoloading and the test runner:

{
  "require": {
    "php": "^8.3"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}

Build a bounded native cURL transport

The transport owns connection mechanics, not business policy. It disables redirects, permits HTTPS only, retains response headers, and applies finite connection and total timeouts.

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

interface Transport
{
    public function postJson(string $url, array $headers, array $body): Response;
}

// src/Http/Response.php
namespace App\Http;

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

// src/Http/CurlTransport.php
namespace App\Http;

use RuntimeException;

final class CurlTransport implements Transport
{
    public function postJson(string $url, array $headers, array $body): Response
    {
        $handle = curl_init($url);
        if ($handle === false) {
            throw new RuntimeException('Unable to initialize cURL');
        }

        $responseHeaders = [];
        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT_MS => 3000,
            CURLOPT_TIMEOUT_MS => 15000,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HTTPHEADER => array_merge(
                ['Content-Type: application/json', 'Accept: application/json'],
                $headers
            ),
            CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR),
            CURLOPT_HEADERFUNCTION => static function ($handle, string $line)
                use (&$responseHeaders): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
        ]);

        try {
            $body = curl_exec($handle);
            if ($body === false) {
                throw new RuntimeException(
                    'Brand Kit transport failed: ' . curl_error($handle)
                );
            }

            if (strlen($body) > 2_000_000) {
                throw new RuntimeException('Brand Kit response exceeds size limit');
            }

            return new Response(
                curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
                $responseHeaders,
                $body
            );
        } finally {
            curl_close($handle);
        }
    }
}

Do not log the request headers: they contain the token. Likewise, avoid logging the complete response because extracted profiles and asset URLs may be customer-associated data.

Map the response into a strict domain object

The mapper is the trust boundary. The following canonical object uses snake-case names internally. If the current service documentation wraps or names properties differently, translate those documented properties in fromPayload(); do not spread raw response assumptions throughout the renderer.

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

use DomainException;

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 static function fromPayload(array $data): self
    {
        $requiredArrays = [
            'logos', 'colors', 'fonts', 'imagery',
            'social_profiles', 'css_variables',
        ];

        if (!isset($data['brand_name'])
            || !is_string($data['brand_name'])
            || trim($data['brand_name']) === ''
            || strlen($data['brand_name']) > 200
        ) {
            throw new DomainException('Invalid brand name');
        }

        foreach ($requiredArrays as $field) {
            if (!array_key_exists($field, $data) || !is_array($data[$field])) {
                throw new DomainException("Invalid or missing {$field}");
            }
        }

        foreach ($data['css_variables'] as $name => $value) {
            if (!is_string($name)
                || preg_match('/^--[a-z0-9-]{1,64}$/i', $name) !== 1
                || !is_string($value)
                || strlen($value) > 200
                || strpbrk($value, ';{}') !== false
            ) {
                throw new DomainException('Unsafe CSS variable');
            }
        }

        self::validateTree($data['logos']);
        self::validateTree($data['colors']);
        self::validateTree($data['fonts']);
        self::validateTree($data['imagery']);
        self::validateTree($data['social_profiles']);

        return new self(
            trim($data['brand_name']),
            $data['logos'],
            $data['colors'],
            $data['fonts'],
            $data['imagery'],
            $data['social_profiles'],
            $data['css_variables'],
        );
    }

    private static function validateTree(array $items, int $depth = 0): void
    {
        if ($depth > 8 || count($items) > 500) {
            throw new DomainException('Brand data exceeds structural limits');
        }

        foreach ($items as $value) {
            if (is_array($value)) {
                self::validateTree($value, $depth + 1);
            } elseif (!is_string($value) && !is_int($value)
                && !is_float($value) && !is_bool($value)
                && $value !== null
            ) {
                throw new DomainException('Unsupported brand data value');
            }

            if (is_string($value) && strlen($value) > 4096) {
                throw new DomainException('Brand data value is too long');
            }
        }
    }

    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,
        ];
    }
}

Validation establishes structural safety, not permission to inject arbitrary values into HTML or CSS. Templates should escape text, allow only expected asset URL shapes, and use approved CSS properties. Fonts and remote imagery should not be downloaded merely because they appear in the response.

Add status-aware retries and failure states

The client retries only transient transport failures and selected temporary HTTP responses. Authentication and validation failures are terminal. A long Retry-After becomes a structured failure for a scheduler to revisit later instead of tying up a PHP worker.

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

use App\Http\Transport;
use RuntimeException;
use Throwable;

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 $sleeper = null,
    ) {}

    public function extract(string $websiteUrl): BrandKit
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->postJson(
                    self::ENDPOINT,
                    ['Authorization: Bearer ' . $this->token],
                    ['url' => $websiteUrl]
                );
            } catch (Throwable $error) {
                if ($attempt === 3) {
                    throw new RuntimeException(
                        'Brand extraction transport unavailable', 0, $error
                    );
                }
                $this->pause(250 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $payload = json_decode(
                        $response->body, true, 512, JSON_THROW_ON_ERROR
                    );
                } catch (\JsonException $error) {
                    throw new RuntimeException('Service returned invalid JSON', 0, $error);
                }

                if (!is_array($payload)) {
                    throw new RuntimeException('Service returned an invalid payload');
                }

                return BrandKit::fromPayload($payload);
            }

            if (in_array($response->status, [401, 403], true)) {
                throw new RuntimeException('Brand Kit authentication rejected');
            }

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

            if (!$retryable || $attempt === 3) {
                throw new RuntimeException(
                    "Brand extraction failed with HTTP {$response->status}"
                );
            }

            $retryAfter = filter_var(
                $response->headers['retry-after'] ?? null,
                FILTER_VALIDATE_INT
            );

            if ($retryAfter !== false && $retryAfter > 5) {
                throw new RuntimeException(
                    "Brand extraction rate limited; retry after {$retryAfter} seconds"
                );
            }

            $this->pause(
                $retryAfter !== false
                    ? $retryAfter * 1000
                    : 250 * (2 ** ($attempt - 1))
            );
        }

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

    private function pause(int $milliseconds): void
    {
        if ($this->sleeper !== null) {
            ($this->sleeper)($milliseconds);
            return;
        }
        usleep($milliseconds * 1000);
    }
}

Retries can consume quota and a timed-out POST may already have reached the service. Keep the attempt count small, cache successful snapshots, and let an operator or scheduled process handle prolonged outages.

Publish an atomic snapshot from a CLI command

The store writes a temporary file and renames it only after the complete JSON document is durable. A renderer therefore sees either the old snapshot or the new one, never a partially written file.

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

use RuntimeException;

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

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

        $path = $this->directory . '/' . hash('sha256', $sourceUrl) . '.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),
            'kit' => $kit->toArray(),
        ];

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

        return $path;
    }
}
<?php
// bin/import-brand.php
use App\BrandKitClient;
use App\BrandKitStore;
use App\Http\CurlTransport;

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

$url = $argv[1] ?? '';
$token = getenv('BRAND_KIT_TOKEN');
$dataDir = getenv('BRAND_DATA_DIR') ?: 'var/brand-kits';

if ($token === false || $token === '') {
    fwrite(STDERR, "BRAND_KIT_TOKEN is not configured\n");
    exit(2);
}

$parts = parse_url($url);
if (!filter_var($url, FILTER_VALIDATE_URL)
    || ($parts['scheme'] ?? '') !== 'https'
    || empty($parts['host'])
    || strtolower($parts['host']) === 'localhost'
) {
    fwrite(STDERR, "Supply a public HTTPS website URL\n");
    exit(2);
}

$started = hrtime(true);

try {
    $kit = (new BrandKitClient(new CurlTransport(), $token))->extract($url);
    $path = (new BrandKitStore($dataDir))->save($url, $kit);

    error_log(json_encode([
        'event' => 'brand_kit_imported',
        'source_host' => $parts['host'],
        'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000),
    ], JSON_THROW_ON_ERROR));

    fwrite(STDOUT, "Imported {$kit->brandName} into {$path}\n");
} catch (Throwable $error) {
    error_log(json_encode([
        'event' => 'brand_kit_import_failed',
        'source_host' => $parts['host'],
        'error_type' => $error::class,
    ], JSON_THROW_ON_ERROR));

    fwrite(STDERR, $error->getMessage() . "\n");
    exit(1);
}

The proposal generator can load the snapshot, reconstruct BrandKit from its kit member, and use the validated brand name and CSS-variable map. Keep logos, imagery, colors, fonts, and social profiles available as structured inputs, but escape every HTML value and allowlist any property used in generated CSS. Persist the snapshot identifier with each proposal so a regenerated document can use the same branding revision.

Test without contacting the service

A fake transport makes retries and failure paths deterministic. It also prevents credentials or live quotas from becoming test dependencies.

<?php
// tests/BrandKitClientTest.php
use App\BrandKitClient;
use App\Http\Response;
use App\Http\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, array $headers, array $body): Response
    {
        $response = $this->responses[$this->calls] ?? null;
        $this->calls++;

        if (!$response instanceof Response) {
            throw new RuntimeException('No fake response configured');
        }
        return $response;
    }
}

final class BrandKitClientTest extends TestCase
{
    private function validPayload(): string
    {
        return json_encode([
            'brand_name' => 'Example',
            'logos' => [],
            'colors' => ['#123456'],
            'fonts' => ['Example Sans'],
            'imagery' => [],
            'social_profiles' => [],
            'css_variables' => ['--brand-primary' => '#123456'],
        ], JSON_THROW_ON_ERROR);
    }

    public function testRetriesTemporaryFailureThenMapsBrand(): void
    {
        $transport = new FakeTransport([
            new Response(503, [], '{}'),
            new Response(200, [], $this->validPayload()),
        ]);

        $client = new BrandKitClient($transport, 'test-token', static fn () => null);
        $kit = $client->extract('https://example.com');

        self::assertSame('Example', $kit->brandName);
        self::assertSame(2, $transport->calls);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $transport = new FakeTransport([new Response(401, [], '{}')]);
        $client = new BrandKitClient($transport, 'expired-token', static fn () => null);

        try {
            $client->extract('https://example.com');
            self::fail('Expected authentication failure');
        } catch (RuntimeException $error) {
            self::assertSame('Brand Kit authentication rejected', $error->getMessage());
            self::assertSame(1, $transport->calls);
        }
    }
}

Run composer install, then vendor/bin/phpunit tests. Add cases for malformed JSON, missing categories, unsafe CSS variables, HTTP 429, an excessive Retry-After, and transport exhaustion.

Security, observability, and deployment

Accept imports only from authorized users. For a multi-customer tool, maintain approved domains and reject local, private, or reserved destinations before submission. The local application does not fetch the supplied site directly, but domain restrictions still prevent misuse of your paid service integration.

Keep the token out of logs, exception context, command history, fixtures, and generated reports. Restrict snapshot-directory permissions, encrypt storage when customer policy requires it, and rotate the token through the Service token panel. Remember that regeneration immediately invalidates the former active token.

Emit structured events for success, failure category, source hostname, latency, and retry count. Do not label every failure as an outage: distinguish authentication rejection, rate limiting, response validation, transport errors, and filesystem publication errors. Alert on sustained failure rates rather than one failed import.

Deployment needs the PHP 8.3 CLI, cURL extension, CA certificates, Composer’s optimized autoloader, a writable persistent snapshot directory, and an injected BRAND_KIT_TOKEN. Run imports as background CLI work or scheduled jobs, with only one import per brand at a time. Document rendering should remain read-only.

Common failures worth rehearsing

  • 401 or 403: verify activation and the service-scoped token. If it was regenerated, deploy the replacement everywhere.
  • 429: honor a short retry delay; defer longer waits to the scheduler instead of blocking a worker.
  • Malformed or incomplete JSON: retain the last valid snapshot and record a validation failure without storing the new response.
  • Timeout or temporary 5xx response: use the bounded retry policy, then fail visibly.
  • Unwritable storage: fix ownership or the mounted volume; never fall back to an unprotected public directory.
  • Brand appearance is outdated: run an authorized re-import and associate new proposals with the new snapshot.

Final verification checklist

  • The account and Free, Plus, or Pro plan are active.
  • The service token comes from the documentation page’s Service token panel.
  • No token exists in source control, logs, tests, or generated files.
  • The command sends only url to the exact HTTPS endpoint.
  • Brand name, logos, colors, fonts, imagery, social profiles, and CSS variables are validated before storage.
  • Authentication and validation failures are never retried blindly.
  • Snapshots are written atomically and document rendering makes no remote API call.
  • Tests cover success, retries, authentication rejection, malformed data, and rate limiting.

The durable result is more than a convenient API call. It is a small, auditable content pipeline: extraction gathers evidence, the domain boundary decides what is trustworthy, atomic storage preserves a known-good revision, and the proposal generator renders from stable local data. That separation is what turns automated branding from a visual shortcut into dependable production infrastructure.

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.