Tutorials

Native PHP 8.3: Extract Brand Kits for Instant Landing Page Drafts

Native PHP 8.3: Extract Brand Kits for Instant Landing Page Drafts

A blank landing-page editor is rarely a welcoming first step. During customer onboarding, a better experience is to inspect the customer’s existing public website, extract its visual identity, and prepare a recognizable draft that they can refine immediately.

The difficult part is not calling an API. It is deciding which extracted data can become a theme, validating an external response without trusting it, and ensuring that a logo URL or CSS value never becomes an injection path. This tutorial builds that boundary in Native PHP 8.3 using cURL, immutable domain objects, bounded retries, atomic storage, and deterministic PHPUnit tests.

Get access and create a service token

  1. Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
  2. Open the Brand Kit Extractor service page.
  3. Choose an available Free, Plus, or Pro plan and complete its activation.
  4. Open the official service documentation.
  5. Find the Service token panel and copy its service-scoped token.

The API requires authentication. It supports 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 query strings to appear in access logs and analytics systems.

Regenerating the service token revokes the previously active token. Treat rotation as a deployment operation: install the replacement in every application instance, restart or reload those instances, verify the new credential, and only then remove obsolete secret versions from your infrastructure.

Confirm the exact request

The integration uses POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON body contains url. With the token temporarily exported in your shell, make one minimal request:

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 'Content-Type: application/json' \
  --data '{"url":"https://example.com"}'

Now put the credential in the project’s uncommitted .env file. Native PHP does not load this file automatically; our configuration bootstrap will do so. Commit only .env.example.

# .env.example
BRAND_KIT_TOKEN="YOUR_SERVICE_TOKEN"
DRAFT_STORAGE="/var/lib/landing-drafts"

# .gitignore
.env
/storage/*.json

Choose a deliberately small architecture

Onboarding needs one synchronous operation: accept a public website URL, call the extractor, map its evidence into a constrained domain object, derive a safe draft, and store JSON. A queue would complicate the user experience without being necessary here. If observed extraction latency later exceeds the onboarding request budget, the same client and mapper can move behind a worker without changing their contracts.

The important boundary is between extracted evidence and renderable theme data. Logos, colors, fonts, imagery, social profiles, and CSS variables are validated and retained, but raw CSS is never injected into a page. The draft selects only an approved HTTPS asset and a syntactically valid hexadecimal color; font names and other evidence remain suggestions until a renderer applies its own escaping and allowlist.

brand-draft/
  composer.json
  .env.example
  config/app.php
  public/index.php
  src/Http/Transport.php
  src/BrandKitClient.php
  src/BrandKitMapper.php
  src/PublicWebsiteUrl.php
  src/DraftRepository.php
  tests/BrandKitClientTest.php
  tests/BrandKitMapperTest.php

Create the project with PHP 8.3, the cURL extension, Composer, and PHPUnit 11:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  }
}
composer install
composer dump-autoload
vendor/bin/phpunit --testdox

Build a bounded HTTP boundary

The transport performs exactly one cURL operation. Retry policy belongs to the API client, which makes it independently testable. TLS verification remains enabled, connection and total response times are bounded, and response headers are captured for rate-limit handling.

<?php
// src/Http/Transport.php
declare(strict_types=1);

namespace App\Http;

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

interface Transport
{
    public function post(
        string $url,
        array $headers,
        string $body,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): Response;
}

final class CurlTransport implements Transport
{
    public function post(
        string $url,
        array $headers,
        string $body,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): Response {
        $receivedHeaders = [];
        $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_MS => $connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $timeoutMs,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_HEADERFUNCTION => static function (
                $curl,
                string $line
            ) use (&$receivedHeaders): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $receivedHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
        ]);

        $bodyResult = curl_exec($handle);
        if ($bodyResult === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new \RuntimeException('Brand service transport failure: ' . $message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new Response($status, $bodyResult, $receivedHeaders);
    }
}

The client retries transport failures, HTTP 429 responses, and 5xx responses at most twice after the initial attempt. It does not retry authentication or request-validation failures. A numeric Retry-After value is honored but capped so one onboarding request cannot occupy a PHP worker indefinitely.

<?php
// src/BrandKitClient.php
declare(strict_types=1);

namespace App;

use App\Http\Transport;

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

    public function extract(string $websiteUrl): array
    {
        $requestBody = json_encode(
            ['url' => $websiteUrl],
            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',
                    ],
                    $requestBody,
                    2_000,
                    15_000,
                );
            } catch (\RuntimeException $exception) {
                ($this->log)([
                    'event' => 'brand_kit_transport_failure',
                    'attempt' => $attempt,
                ]);

                if ($attempt === 3) {
                    throw new BrandKitFailure('transport', 'Extractor unavailable');
                }

                ($this->sleep)([200, 500][$attempt - 1]);
                continue;
            }

            ($this->log)([
                'event' => 'brand_kit_response',
                'attempt' => $attempt,
                'status' => $response->status,
            ]);

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $decoded = json_decode(
                        $response->body,
                        true,
                        64,
                        JSON_THROW_ON_ERROR
                    );
                } catch (\JsonException) {
                    throw new BrandKitFailure('invalid_response', 'Invalid JSON response');
                }

                if (!is_array($decoded)) {
                    throw new BrandKitFailure('invalid_response', 'Expected a JSON object');
                }

                return $decoded;
            }

            if ($response->status === 401 || $response->status === 403) {
                throw new BrandKitFailure('authentication', 'Service authentication failed');
            }

            if ($response->status === 429 || $response->status >= 500) {
                if ($attempt === 3) {
                    throw new BrandKitFailure(
                        $response->status === 429 ? 'rate_limit' : 'upstream',
                        'Extractor temporarily unavailable'
                    );
                }

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

                ($this->sleep)($delay);
                continue;
            }

            throw new BrandKitFailure('request', 'Extractor rejected the request');
        }

        throw new BrandKitFailure('upstream', 'Unreachable retry state');
    }
}

Validate the website and map evidence defensively

Reject credentials, non-HTTP schemes, unusual ports, local names, and IP addresses in private or reserved ranges. DNS checking reduces accidental internal targets, although it is not a complete defense against DNS rebinding. Keep outbound network policy constrained as an additional layer.

<?php
// src/PublicWebsiteUrl.php
declare(strict_types=1);

namespace App;

final class PublicWebsiteUrl
{
    public static function validate(string $url): string
    {
        if (strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
            throw new \InvalidArgumentException('Invalid website URL');
        }

        $parts = parse_url($url);
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
        $host = strtolower((string) ($parts['host'] ?? ''));

        if (!in_array($scheme, ['http', 'https'], true)
            || $host === ''
            || isset($parts['user'])
            || isset($parts['pass'])
            || (isset($parts['port']) && !in_array($parts['port'], [80, 443], true))
            || $host === 'localhost'
        ) {
            throw new \InvalidArgumentException('A public HTTP(S) URL is required');
        }

        $addresses = filter_var($host, FILTER_VALIDATE_IP)
            ? [$host]
            : array_column(dns_get_record($host, DNS_A | DNS_AAAA), 'ip');

        if ($addresses === []) {
            throw new \InvalidArgumentException('Website host does not resolve');
        }

        foreach ($addresses as $address) {
            if (filter_var(
                $address,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            ) === false) {
                throw new \InvalidArgumentException('Website host is not public');
            }
        }

        return $url;
    }
}

The contract promises brand name, logos, colors, fonts, imagery, social profiles, and CSS variables, but casing and punctuation should not become assumptions at the application boundary. The mapper canonicalizes only field-name separators, requires every logical field, extracts bounded scalar evidence, and filters URLs and colors.

<?php
// src/BrandKitMapper.php
declare(strict_types=1);

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,
    ) {}
}

final class BrandKitMapper
{
    public function map(array $payload): BrandKit
    {
        $brandName = $this->field($payload, 'brandname');
        if (!is_string($brandName) || trim($brandName) === '' || strlen($brandName) > 200) {
            throw new BrandKitFailure('invalid_response', 'Invalid brand name');
        }

        $logos = $this->safeUrls($this->values($this->field($payload, 'logos')));
        $colors = array_values(array_filter(
            $this->values($this->field($payload, 'colors')),
            static fn (string $value): bool =>
                preg_match('/^#[0-9a-fA-F]{3,8}$/', $value) === 1
        ));
        $fonts = $this->shortStrings($this->values($this->field($payload, 'fonts')));
        $imagery = $this->safeUrls($this->values($this->field($payload, 'imagery')));
        $social = $this->safeUrls(
            $this->values($this->field($payload, 'socialprofiles'))
        );

        $css = $this->field($payload, 'cssvariables');
        if (!is_array($css)) {
            throw new BrandKitFailure('invalid_response', 'Invalid CSS variables');
        }

        $safeCss = [];
        foreach ($css as $name => $value) {
            if (is_string($name)
                && preg_match('/^--[a-zA-Z0-9_-]{1,80}$/', $name)
                && is_scalar($value)
                && strlen((string) $value) <= 200
                && !preg_match('/[\x00-\x1F\x7F]/', (string) $value)
            ) {
                $safeCss[$name] = (string) $value;
            }
        }

        return new BrandKit(
            trim($brandName),
            $logos,
            $colors,
            $fonts,
            $imagery,
            $social,
            $safeCss,
        );
    }

    private function field(array $payload, string $logicalName): mixed
    {
        foreach ($payload as $name => $value) {
            $canonical = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', (string) $name));
            if ($canonical === $logicalName) {
                return $value;
            }
        }

        throw new BrandKitFailure(
            'invalid_response',
            'Missing required brand-kit field: ' . $logicalName
        );
    }

    private function values(mixed $value): array
    {
        if (!is_array($value)) {
            throw new BrandKitFailure('invalid_response', 'Expected an evidence collection');
        }

        $iterator = new \RecursiveIteratorIterator(new \RecursiveArrayIterator($value));
        $result = [];

        foreach ($iterator as $item) {
            if (is_scalar($item) && count($result) < 100) {
                $result[] = trim((string) $item);
            }
        }

        return array_values(array_filter($result, static fn ($item) => $item !== ''));
    }

    private function safeUrls(array $values): array
    {
        return array_values(array_unique(array_filter(
            $values,
            static fn (string $value): bool =>
                strlen($value) <= 2048
                && filter_var($value, FILTER_VALIDATE_URL) !== false
                && strtolower((string) parse_url($value, PHP_URL_SCHEME)) === 'https'
        )));
    }

    private function shortStrings(array $values): array
    {
        return array_values(array_unique(array_filter(
            $values,
            static fn (string $value): bool =>
                strlen($value) <= 200
                && !preg_match('/[\x00-\x1F\x7F]/', $value)
        )));
    }
}

Create and store the safe draft

The repository stores normalized evidence alongside a deliberately modest theme. It never turns the returned CSS-variable map into executable CSS. Atomic rename prevents readers from observing partially written JSON.

<?php
// src/DraftRepository.php
declare(strict_types=1);

namespace App;

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

    public function save(string $sourceUrl, BrandKit $kit): array
    {
        $id = bin2hex(random_bytes(16));
        $draft = [
            'id' => $id,
            'sourceUrl' => $sourceUrl,
            'brandName' => $kit->brandName,
            'theme' => [
                'primaryColor' => $kit->colors[0] ?? '#222222',
                'logoUrl' => $kit->logos[0] ?? null,
                'fontSuggestion' => $kit->fonts[0] ?? null,
            ],
            'evidence' => [
                'logos' => $kit->logos,
                'colors' => $kit->colors,
                'fonts' => $kit->fonts,
                'imagery' => $kit->imagery,
                'socialProfiles' => $kit->socialProfiles,
                'cssVariables' => $kit->cssVariables,
            ],
            'status' => 'draft',
        ];

        if (!is_dir($this->directory)
            && !mkdir($this->directory, 0700, true)
            && !is_dir($this->directory)
        ) {
            throw new \RuntimeException('Unable to create draft storage');
        }

        $json = json_encode($draft, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
        $temporary = $this->directory . '/' . $id . '.tmp';
        $destination = $this->directory . '/' . $id . '.json';

        if (file_put_contents($temporary, $json, LOCK_EX) === false
            || !rename($temporary, $destination)
        ) {
            throw new \RuntimeException('Unable to store draft');
        }

        return $draft;
    }
}

The front controller accepts only the onboarding route, limits request size, maps failures to stable HTTP states, and returns no upstream body or secret. Logs contain event names, attempts, and statuses—not tokens, response bodies, or customer URLs.

<?php
// config/app.php
declare(strict_types=1);

$root = dirname(__DIR__);
if (is_file($root . '/.env')) {
    foreach (parse_ini_file($root . '/.env', false, INI_SCANNER_RAW) ?: [] as $key => $value) {
        if (getenv((string) $key) === false) {
            putenv($key . '=' . $value);
        }
    }
}

return [
    'token' => (string) getenv('BRAND_KIT_TOKEN'),
    'storage' => (string) (getenv('DRAFT_STORAGE') ?: $root . '/storage'),
];
<?php
// public/index.php
declare(strict_types=1);

use App\BrandKitClient;
use App\BrandKitFailure;
use App\BrandKitMapper;
use App\DraftRepository;
use App\Http\CurlTransport;
use App\PublicWebsiteUrl;

require dirname(__DIR__) . '/vendor/autoload.php';
$config = require dirname(__DIR__) . '/config/app.php';

header('Content-Type: application/json');

$respond = static function (int $status, array $body): never {
    http_response_code($status);
    echo json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
    exit;
};

if ($_SERVER['REQUEST_METHOD'] !== 'POST'
    || parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) !== '/onboarding/theme-draft'
) {
    $respond(404, ['error' => 'not_found']);
}

if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 4096) {
    $respond(413, ['error' => 'request_too_large']);
}

try {
    $input = json_decode(file_get_contents('php://input'), true, 16, JSON_THROW_ON_ERROR);
    $url = PublicWebsiteUrl::validate((string) ($input['url'] ?? ''));

    $logger = static function (array $record): void {
        error_log(json_encode($record, JSON_THROW_ON_ERROR));
    };
    $sleep = static fn (int $milliseconds) => usleep($milliseconds * 1000);

    $client = new BrandKitClient(
        new CurlTransport(),
        $config['token'],
        $sleep,
        $logger
    );

    $kit = (new BrandKitMapper())->map($client->extract($url));
    $draft = (new DraftRepository($config['storage']))->save($url, $kit);

    $respond(201, $draft);
} catch (\InvalidArgumentException | \JsonException) {
    $respond(422, ['error' => 'invalid_input']);
} catch (BrandKitFailure $failure) {
    $status = match ($failure->kind) {
        'rate_limit' => 429,
        'request' => 422,
        default => 502,
    };
    $respond($status, ['error' => $failure->kind]);
} catch (\Throwable $failure) {
    error_log(json_encode(['event' => 'draft_creation_failure']));
    $respond(500, ['error' => 'internal_error']);
}

Test retries and boundary validation

A deterministic fake transport makes failure paths fast and repeatable. The test below proves that 429 is retried, delays are observable without sleeping, and authentication failures are not retried.

<?php
// tests/BrandKitClientTest.php
declare(strict_types=1);

namespace Tests;

use App\BrandKitClient;
use App\BrandKitFailure;
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 post(
        string $url,
        array $headers,
        string $body,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): Response {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class BrandKitClientTest extends TestCase
{
    public function testRetriesRateLimitThenReturnsPayload(): void
    {
        $transport = new FakeTransport([
            new Response(429, '{}', ['retry-after' => '1']),
            new Response(200, '{"brand_name":"Acme"}'),
        ]);
        $delays = [];

        $client = new BrandKitClient(
            $transport,
            'test-token',
            static function (int $ms) use (&$delays): void {
                $delays[] = $ms;
            },
            static function (array $record): void {},
        );

        self::assertSame(['brand_name' => 'Acme'], $client->extract('https://example.com'));
        self::assertSame(2, $transport->calls);
        self::assertSame([1000], $delays);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $transport = new FakeTransport([new Response(401, '{}')]);
        $client = new BrandKitClient(
            $transport,
            'test-token',
            static function (int $ms): void {},
            static function (array $record): void {},
        );

        try {
            $client->extract('https://example.com');
            self::fail('Expected authentication failure');
        } catch (BrandKitFailure $failure) {
            self::assertSame('authentication', $failure->kind);
            self::assertSame(1, $transport->calls);
        }
    }
}

Add mapper tests using a fixture containing all seven required logical fields. Assert that missing fields fail, HTTP logo URLs are discarded, malformed colors never become the primary color, and CSS-variable values containing control characters are excluded. Fixtures should use an obviously fake token and synthetic brand data, never copied customer responses.

Deploy with the boundary intact

Run the application behind PHP-FPM and route /onboarding/theme-draft to public/index.php. Inject BRAND_KIT_TOKEN through the hosting platform’s secret manager; production should not depend on a deployed .env file. Ensure PHP-FPM preserves the required environment variables, and mount DRAFT_STORAGE outside the public document root with write access only for the application user.

Keep cURL certificate verification enabled. Restrict outbound traffic to the service host where the deployment platform supports it. Protect the onboarding endpoint with your application’s user authentication, CSRF policy, and per-account rate limiting. Define retention for source URLs and extracted evidence because public data can still identify customers.

Monitor counts and latency for successful extractions, transport failures, 429 responses, upstream 5xx responses, invalid response contracts, and draft-storage failures. Alert on sustained authentication failures because they commonly indicate an expired, revoked, or incompletely deployed token rather than customer input.

Common failures

  • 401 or 403: confirm that the service-scoped token is current and that no instance still uses the token revoked during regeneration.
  • 422 from the application: inspect the submitted URL or an upstream request rejection; do not retry it automatically.
  • 429: the client performs bounded backoff, but repeated limits should become a user-visible retry-later state rather than an endless loop.
  • Invalid response: retain the failure classification and inspect redacted diagnostics. Never silently store a partial kit when a required brand field is absent.
  • No usable logo or color: this is not necessarily an API failure. The safe draft intentionally falls back to no logo and #222222 while preserving validated evidence for review.

Final verification checklist

  • The service plan is active and the token came from the documentation page’s Service token panel.
  • The credential exists only in environment-backed configuration and secret storage.
  • 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.
  • Raw CSS and unvalidated asset URLs are never rendered.
  • Timeouts and retries are bounded, while authentication and validation failures are not retried.
  • PHPUnit tests cover success, rate limiting, authentication failure, missing fields, and unsafe values.
  • A real onboarding request returns HTTP 201 and creates one complete JSON draft atomically.

The useful product outcome is not merely an extracted brand kit. It is a restrained first draft that feels familiar without pretending external data is trustworthy. That distinction—evidence first, rendering second—is what turns a convenient API call into a production integration customers can safely meet on their first screen.

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.