Tutorials

Native PHP 8.3: Draft Landing Page Themes Safely from Client Websites Using Brand Kit API

Native PHP 8.3: Draft Landing Page Themes Safely from Client Websites Using Brand Kit API

A customer pastes a website URL during onboarding. A few seconds later, your application proposes a landing-page theme using the customer’s real visual identity rather than a random palette. That sounds simple until untrusted URLs, upstream failures, malformed data, leaked credentials, and unsafe CSS enter the picture.

This Native PHP 8.3 project treats brand extraction as an external boundary, not a copy-and-paste convenience. It calls the Brand Kit Extractor API, validates every required section, stores an evidence-based draft privately, and exposes only reviewed CSS variables to the eventual page renderer.

Get access before writing integration code

First, register an account, or sign in if you already have one.

  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 or a committed fixture.

This service requires authentication; there is no unauthenticated mode in this integration. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form because it keeps the credential out of URLs and ordinary access logs.

Regenerating the service token revokes the previously active token. Treat rotation as a deployment event: update every running instance, verify the new credential, and remove any stale secret from your deployment system.

Confirm the contract with one minimal request

The exact operation is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON body contains url:

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

Run that only against a public website you are authorized to process. Before persisting the response, the application will require the returned brand name, logos, colors, fonts, imagery, social profiles, and CSS variables.

For local development, create an uncommitted .env.local file:

BRAND_KIT_TOKEN="YOUR_SERVICE_TOKEN"
ONBOARDING_API_KEY="YOUR_INTERNAL_ONBOARDING_KEY"
BRAND_DRAFT_DIRECTORY="/var/lib/landing-onboarding/drafts"

Add .env.local to .gitignore. In production, inject the same names through the process manager or secret store rather than placing a secret-bearing file in the release directory.

Architecture and project layout

The endpoint remains synchronous so onboarding can immediately display a draft. That is a reasonable trade-off while extraction normally fits within a bounded response timeout. If product requirements later permit delayed completion, the application service can move behind a queue without changing the API client or domain mapper.

The important separation is between transport, validation, and storage:

brand-onboarding/
├── composer.json
├── .env.local
├── public/
│   └── index.php
├── src/
│   ├── BrandKit.php
│   ├── BrandKitClient.php
│   ├── CurlTransport.php
│   ├── HttpResponse.php
│   ├── Transport.php
│   ├── ThemeDraftStore.php
│   └── WebsiteUrl.php
└── tests/
    └── BrandKitClientTest.php

PHP 8.3, Composer, the cURL extension, and the JSON extension are required. Production code needs no third-party HTTP package. PHPUnit is development-only:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "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

Reject unsafe source URLs early

The extractor processes a remote site, but your application should still reject obviously unsuitable input. This policy permits HTTPS websites with public DNS addresses and rejects credentials, fragments, localhost, and private or reserved addresses. DNS can change after validation, so this is a useful admission control rather than a complete substitute for the extractor’s own network protections.

<?php
namespace App;

use InvalidArgumentException;

final class WebsiteUrl
{
    public static function validate(string $url): string
    {
        if (strlen($url) > 2048) {
            throw new InvalidArgumentException('Website URL is too long.');
        }

        $parts = parse_url($url);
        if (
            !is_array($parts) ||
            ($parts['scheme'] ?? '') !== 'https' ||
            empty($parts['host']) ||
            isset($parts['user'], $parts['pass']) ||
            isset($parts['fragment'])
        ) {
            throw new InvalidArgumentException('A public HTTPS website URL is required.');
        }

        $host = strtolower($parts['host']);
        if ($host === 'localhost' || !str_contains($host, '.')) {
            throw new InvalidArgumentException('The hostname is not public.');
        }

        $records = dns_get_record($host, DNS_A | DNS_AAAA);
        if ($records === false || $records === []) {
            throw new InvalidArgumentException('The hostname does not resolve.');
        }

        foreach ($records as $record) {
            $ip = $record['ip'] ?? $record['ipv6'] ?? null;
            if (
                !is_string($ip) ||
                filter_var(
                    $ip,
                    FILTER_VALIDATE_IP,
                    FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
                ) === false
            ) {
                throw new InvalidArgumentException('The hostname resolves to a non-public address.');
            }
        }

        return $url;
    }
}

HTTPS-only input is intentionally conservative. If supporting legacy HTTP sites becomes a genuine requirement, make that a reviewed product policy rather than casually broadening the validator.

Build a bounded native cURL transport

A small transport interface makes tests deterministic. The real implementation enables certificate verification, caps connection and total response time, captures response headers, and never logs the authorization header.

<?php
namespace App;

use RuntimeException;

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

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

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

        $responseHeaders = [];
        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => 5000,
            CURLOPT_TIMEOUT_MS => 30000,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HEADERFUNCTION => static function ($handle, string $line)
                use (&$responseHeaders): int {
                $length = strlen($line);
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return $length;
            },
        ]);

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

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

        return new HttpResponse($status, $responseHeaders, $bodyResult);
    }
}

Map the response into a defensive domain object

External JSON must not flow directly into templates. This mapper requires all seven contractual areas, bounds collection sizes and nesting, and treats CSS as hostile input. Extra upstream fields are ignored, which keeps storage stable while allowing compatible API additions.

<?php
namespace App;

use UnexpectedValueException;

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 fromApi(array $data): self
    {
        $name = $data['brand_name'] ?? null;
        if (!is_string($name) || trim($name) === '' || strlen($name) > 200) {
            throw new UnexpectedValueException('Invalid brand_name.');
        }

        $collections = [];
        foreach (['logos', 'colors', 'fonts', 'imagery', 'social_profiles'] as $field) {
            $value = $data[$field] ?? null;
            if (!is_array($value) || count($value) > 100) {
                throw new UnexpectedValueException("Invalid {$field}.");
            }
            self::validateJsonValue($value, 0);
            $collections[$field] = $value;
        }

        $variables = $data['css_variables'] ?? null;
        if (!is_array($variables) || count($variables) > 64) {
            throw new UnexpectedValueException('Invalid css_variables.');
        }

        foreach ($variables as $property => $value) {
            if (
                !is_string($property) ||
                preg_match('/^--[a-z0-9-]{1,80}$/', $property) !== 1 ||
                !is_string($value) ||
                $value === '' ||
                strlen($value) > 160 ||
                preg_match('/[\x00-\x1F\x7F{};]/', $value) === 1 ||
                preg_match('/url\s*\(|expression\s*\(|@import|\/\*|\*\//i', $value) === 1
            ) {
                throw new UnexpectedValueException('Unsafe CSS variable.');
            }
        }

        return new self(
            trim($name),
            $collections['logos'],
            $collections['colors'],
            $collections['fonts'],
            $collections['imagery'],
            $collections['social_profiles'],
            $variables
        );
    }

    private static function validateJsonValue(mixed $value, int $depth): void
    {
        if ($depth > 4) {
            throw new UnexpectedValueException('Brand data is too deeply nested.');
        }

        if (is_string($value)) {
            if (strlen($value) > 2048) {
                throw new UnexpectedValueException('Brand value is too long.');
            }
            return;
        }

        if (is_array($value)) {
            if (count($value) > 100) {
                throw new UnexpectedValueException('Brand collection is too large.');
            }
            foreach ($value as $child) {
                self::validateJsonValue($child, $depth + 1);
            }
            return;
        }

        if (!is_int($value) && !is_float($value) &&
            !is_bool($value) && $value !== null) {
            throw new UnexpectedValueException('Unsupported brand value.');
        }
    }
}

The snake-case keys above are the application boundary for the documented response concepts. If the official documentation changes their representation, update only this mapper and its fixtures. Never “fix” contract drift by silently accepting arbitrary shapes.

Retry only failures that may recover

The client retries transport errors, HTTP 429 responses, and server errors. It does not retry authentication or request-validation failures: another identical call would waste quota and delay a useful error. Backoff is bounded, and an integer Retry-After value is honored up to five seconds.

<?php
namespace App;

use Closure;
use JsonException;
use RuntimeException;

final class BrandKitClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit';

    private Closure $sleep;

    public function __construct(
        private readonly Transport $transport,
        private readonly string $token,
        ?Closure $sleep = null
    ) {
        if ($token === '' || $token === 'YOUR_SERVICE_TOKEN') {
            throw new RuntimeException('BRAND_KIT_TOKEN is not configured.');
        }
        $this->sleep = $sleep ?? static fn (int $ms) => usleep($ms * 1000);
    }

    public function extract(string $url): BrandKit
    {
        $body = json_encode(['url' => $url], JSON_THROW_ON_ERROR);

        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 new RuntimeException('brand_transport_unavailable', 0, $exception);
                }
                ($this->sleep)(250 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $decoded = json_decode(
                        $response->body,
                        true,
                        32,
                        JSON_THROW_ON_ERROR
                    );
                } catch (JsonException $exception) {
                    throw new RuntimeException('brand_response_invalid', 0, $exception);
                }

                if (!is_array($decoded)) {
                    throw new RuntimeException('brand_response_invalid');
                }

                return BrandKit::fromApi($decoded);
            }

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

            if (in_array($response->status, [400, 422], true)) {
                throw new RuntimeException('brand_request_rejected');
            }

            $retryable = $response->status === 429 || $response->status >= 500;
            if (!$retryable || $attempt === 3) {
                $kind = $response->status === 429
                    ? 'brand_rate_limited'
                    : 'brand_upstream_failed';
                throw new RuntimeException($kind);
            }

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

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

        throw new RuntimeException('brand_upstream_failed');
    }
}

Store a draft, not executable presentation

The extracted kit is evidence for a proposed theme. It should not automatically overwrite a live page. Store the complete validated record outside the public directory, label it as a draft, and require approval before publishing.

<?php
namespace App;

use RuntimeException;

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

    public function save(string $sourceUrl, BrandKit $kit): string
    {
        if (!is_dir($this->directory) &&
            !mkdir($this->directory, 0700, true) &&
            !is_dir($this->directory)) {
            throw new RuntimeException('Unable to create draft directory.');
        }

        $id = bin2hex(random_bytes(16));
        $document = [
            'id' => $id,
            'status' => 'draft',
            'source_host' => parse_url($sourceUrl, PHP_URL_HOST),
            'created_at' => gmdate(DATE_ATOM),
            'brand' => [
                'name' => $kit->brandName,
                'logos' => $kit->logos,
                'colors' => $kit->colors,
                'fonts' => $kit->fonts,
                'imagery' => $kit->imagery,
                'social_profiles' => $kit->socialProfiles,
            ],
            'theme' => ['css_variables' => $kit->cssVariables],
        ];

        $target = $this->directory . '/' . $id . '.json';
        $temporary = tempnam($this->directory, '.draft-');
        if ($temporary === false) {
            throw new RuntimeException('Unable to allocate draft file.');
        }

        $json = json_encode($document, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
        if (file_put_contents($temporary, $json, LOCK_EX) === false) {
            throw new RuntimeException('Unable to write draft.');
        }

        chmod($temporary, 0600);
        if (!rename($temporary, $target)) {
            throw new RuntimeException('Unable to commit draft.');
        }

        return $id;
    }
}

The application-owned onboarding controller should authenticate its caller, parse JSON, call WebsiteUrl::validate(), then invoke BrandKitClient::extract() and ThemeDraftStore::save(). Return 201 with the draft ID. Map invalid customer input to 422, exhausted quota to 503, and other upstream failures to 502. Do not send upstream bodies, tokens, or internal exception details to the browser.

Test retries and boundary validation without the network

A fake transport makes both the response and retry sequence deterministic:

<?php
namespace Tests;

use App\BrandKitClient;
use App\HttpResponse;
use App\Transport;
use PHPUnit\Framework\TestCase;
use RuntimeException;

final class FakeTransport implements Transport
{
    public array $requests = [];

    public function __construct(private array $responses) {}

    public function post(string $url, array $headers, string $body): HttpResponse
    {
        $this->requests[] = compact('url', 'headers', 'body');
        return array_shift($this->responses);
    }
}

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

    public function testRetriesRateLimitThenMapsResponse(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(429, ['retry-after' => '1'], ''),
            new HttpResponse(200, [], $this->validBody()),
        ]);
        $delays = [];

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

        $kit = $client->extract('https://www.example.com');

        self::assertSame('Example', $kit->brandName);
        self::assertSame([1000], $delays);
        self::assertCount(2, $fake->requests);
        self::assertContains(
            'Authorization: Bearer test-token',
            $fake->requests[0]['headers']
        );
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $fake = new FakeTransport([new HttpResponse(401, [], '')]);
        $client = new BrandKitClient($fake, 'test-token', static fn () => null);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('brand_authentication_failed');

        try {
            $client->extract('https://www.example.com');
        } finally {
            self::assertCount(1, $fake->requests);
        }
    }

    public function testRejectsMissingRequiredFields(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(200, [], '{"brand_name":"Incomplete"}'),
        ]);

        $this->expectException(\UnexpectedValueException::class);
        (new BrandKitClient($fake, 'test-token'))
            ->extract('https://www.example.com');
    }
}

Production security and observability

Authenticate and rate-limit the application’s onboarding route independently of the service token. Limit request body size at the web server, keep draft storage non-public, and escape every value again at its final HTML or CSS output context. Validation at ingestion is not permission to concatenate data into markup.

Emit structured logs with an event name, attempt number, upstream status, elapsed milliseconds, draft ID, and any upstream request identifier returned in headers. Log only the source hostname, not its full path or query string. Never log authorization headers or response bodies because either may contain sensitive customer data.

Track counts and latency for successes, validation rejections, authentication failures, rate limits, timeouts, and server errors. Alert on persistent authentication failures immediately; they usually indicate an expired, regenerated, or incorrectly deployed token. A short burst of 429 responses should trigger controlled backoff, not an aggressive retry storm.

During deployment, verify that PHP has cURL and JSON enabled, the CA certificate bundle is current, the draft directory is writable only by the application user, and outbound HTTPS permits the documented host. Roll out token rotation to all instances together. Health checks should verify configuration presence without calling the paid extraction operation.

Final verification checklist

  • The account and selected Free, Plus, or Pro plan are active.
  • The service-scoped token comes from the documentation page’s Service token panel.
  • The token and internal onboarding credential are absent from source control and logs.
  • The client calls the exact POST endpoint with a JSON url field.
  • Connection and response deadlines are bounded.
  • Only transport errors, 429 responses, and server failures are retried.
  • Brand name, logos, colors, fonts, imagery, social profiles, and CSS variables are validated before storage.
  • Unsafe CSS values and non-public website addresses are rejected.
  • Draft files are private, atomic, and never published automatically.
  • Unit tests pass with no external network access.
  • A staging extraction produces a reviewable draft and no secret-bearing log entry.

The best onboarding automation does not pretend extraction is creative certainty. It converts a public site into bounded, inspectable evidence, then gives a human a safe draft to approve. That distinction turns a clever API call into a production feature you can trust.

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.